@effected/schemastore-cli 0.18.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AjvInstanceValidator.js +73 -0
- package/AjvValidator.js +7 -28
- package/README.md +21 -8
- package/cli/commands/validate.js +257 -0
- package/cli/root.js +9 -2
- package/index.d.ts +60 -2
- package/index.js +2 -1
- package/internal/ajv.js +51 -0
- package/main.js +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { makeAjv } from "./internal/ajv.js";
|
|
2
|
+
import { InstanceFinding, InstanceValidator, InstanceValidatorError } from "@effected/schemastore";
|
|
3
|
+
import { Effect, Layer } from "effect";
|
|
4
|
+
|
|
5
|
+
//#region src/AjvInstanceValidator.ts
|
|
6
|
+
const findingFromAjvError = (error) => InstanceFinding.make({
|
|
7
|
+
path: error.instancePath,
|
|
8
|
+
message: error.message ?? "instance is not valid",
|
|
9
|
+
keyword: error.keyword
|
|
10
|
+
});
|
|
11
|
+
/**
|
|
12
|
+
* The shipped `InstanceValidator` implementation: the same ajv strict-mode
|
|
13
|
+
* setup `AjvValidator` gates documents with — the declared `KeywordFamilies`
|
|
14
|
+
* keywords and the standard `ajv-formats` vocabulary registered, through the
|
|
15
|
+
* one shared `makeAjv` — pointed at a payload instance instead of the
|
|
16
|
+
* meta-schema.
|
|
17
|
+
*
|
|
18
|
+
* @remarks
|
|
19
|
+
* Lives in the CLI, not the library, for the same reason `AjvValidator`
|
|
20
|
+
* does: `@effected/schemastore` owns the `InstanceValidator` contract and
|
|
21
|
+
* its doubles, an application that imports it at runtime never pulls an
|
|
22
|
+
* engine, and the `schemastore validate` command composes this layer at its
|
|
23
|
+
* edge. It is exported for a program that validates payloads itself and
|
|
24
|
+
* wants the same verdict the command gives — a document the `check` gate
|
|
25
|
+
* admits always compiles here, so the two engines cannot drift.
|
|
26
|
+
*
|
|
27
|
+
* `validate` compiles the document and runs the instance against it. An
|
|
28
|
+
* instance the document rejects answers `InstanceFinding` values — ajv's
|
|
29
|
+
* structured `instancePath` pointer and `keyword` preserved, `allErrors`
|
|
30
|
+
* on, so one run reports every problem, not just the first. A document the
|
|
31
|
+
* engine cannot compile (a meta-schema failure, a strict-mode rejection, a
|
|
32
|
+
* declared keyword whose NAME ajv's grammar refuses) fails
|
|
33
|
+
* `InstanceValidatorError`: unlike `AjvValidator`, whose subject IS the
|
|
34
|
+
* document, this contract's subject is the instance, and a document that
|
|
35
|
+
* yields no verdict is the engine failing to run as a mechanism. The
|
|
36
|
+
* document's own gate is `SchemaValidator`'s job — `schemastore check` runs
|
|
37
|
+
* it before a document is ever published.
|
|
38
|
+
*
|
|
39
|
+
* `strict` defaults to `true` — SchemaStore's gate. Each call builds its own
|
|
40
|
+
* ajv instance, so documents sharing an `$id` never collide.
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* ```ts
|
|
44
|
+
* import { InstanceValidator } from "@effected/schemastore";
|
|
45
|
+
* import { AjvInstanceValidator } from "@effected/schemastore-cli";
|
|
46
|
+
* import { Effect } from "effect";
|
|
47
|
+
*
|
|
48
|
+
* const program = Effect.gen(function* () {
|
|
49
|
+
* const validator = yield* InstanceValidator;
|
|
50
|
+
* return yield* validator.validate({ type: "object" }, { any: "payload" });
|
|
51
|
+
* });
|
|
52
|
+
*
|
|
53
|
+
* Effect.runPromise(Effect.provide(program, AjvInstanceValidator.layer));
|
|
54
|
+
* // => []
|
|
55
|
+
* ```
|
|
56
|
+
*
|
|
57
|
+
* @public
|
|
58
|
+
*/
|
|
59
|
+
var AjvInstanceValidator = class {
|
|
60
|
+
constructor() {}
|
|
61
|
+
/** The engine, as an `InstanceValidator` layer. */
|
|
62
|
+
static layer = Layer.succeed(InstanceValidator, { validate: (document, instance, options) => Effect.try({
|
|
63
|
+
try: () => {
|
|
64
|
+
const validate = makeAjv(document, options?.strict ?? true).compile(document);
|
|
65
|
+
if (validate(instance)) return [];
|
|
66
|
+
return (validate.errors ?? []).map(findingFromAjvError);
|
|
67
|
+
},
|
|
68
|
+
catch: (cause) => InstanceValidatorError.make({ cause })
|
|
69
|
+
}) });
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
//#endregion
|
|
73
|
+
export { AjvInstanceValidator };
|
package/AjvValidator.js
CHANGED
|
@@ -1,25 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import ajvFormats from "ajv-formats";
|
|
1
|
+
import { makeAjv } from "./internal/ajv.js";
|
|
2
|
+
import { SchemaValidator, SchemaValidatorError, ValidationFinding } from "@effected/schemastore";
|
|
4
3
|
import { Effect, Layer } from "effect";
|
|
5
4
|
|
|
6
5
|
//#region src/AjvValidator.ts
|
|
7
|
-
const addFormats = ajvFormats.default;
|
|
8
|
-
const MAX_KEYWORD_WALK_DEPTH = 256;
|
|
9
|
-
const collectDeclaredKeywords = (node, into, depth) => {
|
|
10
|
-
if (depth >= MAX_KEYWORD_WALK_DEPTH || typeof node !== "object" || node === null) return;
|
|
11
|
-
if (Array.isArray(node)) {
|
|
12
|
-
for (const element of node) collectDeclaredKeywords(element, into, depth + 1);
|
|
13
|
-
return;
|
|
14
|
-
}
|
|
15
|
-
for (const [key, value] of Object.entries(node)) {
|
|
16
|
-
if (KeywordFamilies.isDeclared(key)) {
|
|
17
|
-
into.add(key);
|
|
18
|
-
continue;
|
|
19
|
-
}
|
|
20
|
-
collectDeclaredKeywords(value, into, depth + 1);
|
|
21
|
-
}
|
|
22
|
-
};
|
|
23
6
|
const findingFromAjvError = (error) => ValidationFinding.make({
|
|
24
7
|
path: error.instancePath,
|
|
25
8
|
message: error.message ?? "schema is not valid",
|
|
@@ -47,13 +30,16 @@ const findingFromAjvError = (error) => ValidationFinding.make({
|
|
|
47
30
|
* carrying a dot or a space. The error channel stays reserved for the engine
|
|
48
31
|
* failing as a mechanism (`SchemaValidatorError`).
|
|
49
32
|
*
|
|
33
|
+
* The ajv setup is `internal/ajv.ts`'s `makeAjv`, shared with
|
|
34
|
+
* `AjvInstanceValidator` so a document this gate admits always compiles in
|
|
35
|
+
* the instance engine too — the two verdicts cannot drift.
|
|
36
|
+
*
|
|
50
37
|
* `strict` defaults to `true` — SchemaStore's gate. Each call builds its own
|
|
51
38
|
* ajv instance, so documents sharing an `$id` never collide. Registering the
|
|
52
39
|
* declared families keeps the engine's verdict consistent with
|
|
53
40
|
* `DocumentLint`'s through the same predicate, so the two cannot drift; the
|
|
54
41
|
* plugin's `formatMaximum` / `formatMinimum` limit keywords are deliberately
|
|
55
42
|
* NOT registered, because the lint answers those as unknown keywords.
|
|
56
|
-
*
|
|
57
43
|
* @example
|
|
58
44
|
* ```ts
|
|
59
45
|
* import { SchemaValidator } from "@effected/schemastore";
|
|
@@ -76,15 +62,8 @@ var AjvValidator = class {
|
|
|
76
62
|
/** The engine, as a `SchemaValidator` layer. */
|
|
77
63
|
static layer = Layer.succeed(SchemaValidator, { validate: (document, options) => Effect.try({
|
|
78
64
|
try: () => {
|
|
79
|
-
const ajv = new Ajv({
|
|
80
|
-
strict: options?.strict ?? true,
|
|
81
|
-
allErrors: true
|
|
82
|
-
});
|
|
83
|
-
addFormats(ajv, { keywords: false });
|
|
84
|
-
const declared = /* @__PURE__ */ new Set();
|
|
85
|
-
collectDeclaredKeywords(document, declared, 0);
|
|
86
65
|
try {
|
|
87
|
-
|
|
66
|
+
const ajv = makeAjv(document, options?.strict ?? true);
|
|
88
67
|
if (!ajv.validateSchema(document)) return (ajv.errors ?? []).map(findingFromAjvError);
|
|
89
68
|
ajv.compile(document);
|
|
90
69
|
} catch (cause) {
|
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[](https://nodejs.org/)
|
|
6
6
|
[](https://www.typescriptlang.org/)
|
|
7
7
|
|
|
8
|
-
The `schemastore` command: build and check SchemaStore-shaped JSON Schema documents from a `schemastore.config.ts
|
|
8
|
+
The `schemastore` command: build and check SchemaStore-shaped JSON Schema documents from a `schemastore.config.ts`, and validate a payload against a published document. It is the companion to [`@effected/schemastore`](https://www.npmjs.com/package/@effected/schemastore), which owns the pipeline and every type a config needs; this package ships the plumbing every consumer used to write by hand — the config loader, the drift policy, the frozen-label checks, the catalog file, exit codes, a GitHub step summary — once, as a `bin`. It is also where the validation engines live: `AjvValidator` (documents) and `AjvInstanceValidator` (payloads), both ajv in strict mode over one shared setup, composed by the commands and exported for a program that drives the pipeline or validates payloads itself, so the library stays free of ajv.
|
|
9
9
|
|
|
10
10
|
> **Pre-`1.0.0`.** This package is part of the `@effected/*` kit, built on stable
|
|
11
11
|
> Effect v4 (`effect` `^4.0.0`) and still in `0.x` development. Stable Effect
|
|
@@ -114,17 +114,19 @@ export default defineConfig({
|
|
|
114
114
|
```text
|
|
115
115
|
schemastore build [config] [--drift=strict|semantic|allow] [--on-drift=error|warn] [--force] [--format=human|json]
|
|
116
116
|
schemastore check [config] [--drift=strict|semantic|allow] [--on-drift=error|warn] [--force] [--format=human|json]
|
|
117
|
+
schemastore validate <payload.json> [config] [--schema <path|$id|url>] [--format=human|json]
|
|
117
118
|
```
|
|
118
119
|
|
|
119
120
|
- Before anything is generated, every frozen label is verified — present on disk, and self-identified by the derived `$id`.
|
|
120
121
|
- `build` generates every schema, runs the gates (the structural lint and ajv strict mode), applies the drift policy, and writes what passes — content-compared, so an unchanged file is untouched — plus the config's catalog slice when any entry declares one, and the merged catalog over every slice.
|
|
121
122
|
- `check` is the identical walk with no writes: it reports what `build` would do under the same flags and exits the same way, and also fails (exit `1`) whenever a build would write anything — a stale or missing document is fixed by running `schemastore build` and committing the result. A catalog slice left behind after the config's last `catalog` block was removed is reported `orphaned` and fails `check` the same way, but `build` never deletes it — and the merged catalog keeps advertising its entries until it is gone: delete the file by hand, or restore a `catalog` block; a merged `catalog.json` with no slice left is orphaned the same way. So is a document left behind under an old derived name — an `appendVersion` flip or a `layout` change moved its path, and nothing claims the old file any more: both commands probe the sibling shapes (`<name>.json`, `<name>-<v>.json`, `<v>/<name>.json`, `<v>/<name>-<v>.json`) of every label the config still declares and report each one that exists as an orphaned document, failed by `check`, never deleted by `build`. Nothing else in `outputDir` is looked at, so sharing it with another config, a deploy folder, or the repository root is safe (unless two configs derive the same schema name and version under different layouts into it); a `name` change or a dropped label leaves a file the command cannot know about — delete those by hand. A catalog URL advertised by two slices, or a slice that cannot be read or is not a catalog entry array (an undeclared key included), blocks the merged catalog: both commands fail (exit `1`) naming the URL and its slices or the invalid slice, and the merged file is left as it is until the configs or slices are fixed.
|
|
123
|
+
- `validate` answers the question the publication story exists for: does THIS payload conform to the published document it names? The reference is the `--schema` flag or the payload's own `$schema`, resolved file-first and then against every identity a config schema derives (a target `$id`, a frozen version's `$id`/`url`, the catalog `url`) — CI validates an action's output against the committed document with no third-party tool and no network fetch. The payload's `$schema` self-reference is the pointer naming the document: it is stripped before validating only when the resolved document does not declare `$schema` as a root property, since a generated document's `additionalProperties: false` would otherwise reject the very self-reference that names it. A document that does declare `$schema` — the `HostedSchema` pattern above, where the source struct carries `$schema: Schema.Literal(...)` and the generated document requires and const-constrains the key — validates the payload verbatim. A non-conforming payload fails (exit `1`) with one finding per problem, each carrying the JSON pointer into the instance and the keyword; `--format=json` writes one report document to stdout and moves the human lines to stderr.
|
|
122
124
|
- `--drift` and `--on-drift` override the config for one run; `--force` is sugar for `--drift=allow` (combined with a different explicit `--drift` it is a usage error).
|
|
123
125
|
- `--format=json` emits one JSON document on stdout (per-schema outcome and effective tolerance, the catalog slice and merged-catalog outcomes, the `orphaned` document paths when any, `drift: { onDrift, policy? }`); human text moves to stderr. When `GITHUB_STEP_SUMMARY` is set, both commands append a markdown table.
|
|
124
126
|
|
|
125
|
-
## The
|
|
127
|
+
## The engines, as library exports
|
|
126
128
|
|
|
127
|
-
The
|
|
129
|
+
The commands validate with ajv in strict mode — SchemaStore's own gate — registering the keyword families `@effected/schemastore` declares and the standard `ajv-formats` vocabulary (formats only, never the `formatMaximum` family), one fresh instance per document, through one shared setup both engines use so a document the `check` gate admits always compiles in the instance engine too. The two engine layers are this package's only exports, for a program composing the pipeline or validating payloads directly:
|
|
128
130
|
|
|
129
131
|
```ts
|
|
130
132
|
import { SchemaFile, SchemaPipeline } from "@effected/schemastore";
|
|
@@ -137,17 +139,28 @@ const AppLayer = Layer.mergeAll(SchemaFile.layer, AjvValidator.layer).pipe(Layer
|
|
|
137
139
|
const program = SchemaPipeline.run(targets).pipe(Effect.provide(AppLayer));
|
|
138
140
|
```
|
|
139
141
|
|
|
140
|
-
|
|
142
|
+
```ts
|
|
143
|
+
import { InstanceValidator } from "@effected/schemastore";
|
|
144
|
+
import { AjvInstanceValidator } from "@effected/schemastore-cli";
|
|
145
|
+
import { Effect } from "effect";
|
|
146
|
+
|
|
147
|
+
const program = Effect.gen(function* () {
|
|
148
|
+
const validator = yield* InstanceValidator;
|
|
149
|
+
return yield* validator.validate(document, payload);
|
|
150
|
+
}).pipe(Effect.provide(AjvInstanceValidator.layer));
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Findings come back as values — `ValidationFinding` pointers into the document for `AjvValidator`, `InstanceFinding` pointers into the payload for `AjvInstanceValidator`; the error channel carries `SchemaValidatorError` / `InstanceValidatorError` only when an engine fails as a mechanism.
|
|
141
154
|
|
|
142
155
|
## Exit codes
|
|
143
156
|
|
|
144
157
|
| code | meaning |
|
|
145
158
|
| ---- | -------------------------------------------------------------------------- |
|
|
146
159
|
| 0 | success, including drift under `onDrift: warn` |
|
|
147
|
-
| 1 | drift under `onDrift: error` (one line per drifting schema: `$id`, change, current and next version), a gate failure, a missing or mis-identified frozen version, a merged catalog blocked by a URL two slices advertise or an invalid slice,
|
|
148
|
-
| 2 | config not found, failed to load, failed `defineConfig` validation,
|
|
149
|
-
| 3 | infrastructure failure |
|
|
150
|
-
| 64 | usage error |
|
|
160
|
+
| 1 | drift under `onDrift: error` (one line per drifting schema: `$id`, change, current and next version), a gate failure, a missing or mis-identified frozen version, a merged catalog blocked by a URL two slices advertise or an invalid slice, — for `check` — anything `build` would write or an output nothing claims (an orphaned catalog slice or merged catalog, or an orphaned document at a sibling shape of a derived path), or — for `validate` — a payload that does not conform to the resolved document (one finding per problem, pointer and keyword each) |
|
|
161
|
+
| 2 | config not found, failed to load, failed `defineConfig` validation, a `catalogDir` that is a file or cannot be listed (checked before anything is written), or — for `validate` — a payload that cannot be read or parsed, or a `--schema`/`$schema` reference that is neither an existing file nor an identity any config schema derives, or names a document that cannot be read or parsed |
|
|
162
|
+
| 3 | infrastructure failure (for `validate`, an engine mechanism failure — a document the instance engine cannot compile — included) |
|
|
163
|
+
| 64 | usage error (for `validate`, a payload with no `$schema` and no `--schema` given, included) |
|
|
151
164
|
|
|
152
165
|
## License
|
|
153
166
|
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { AjvInstanceValidator } from "../../AjvInstanceValidator.js";
|
|
2
|
+
import { ConfigLoader } from "../../ConfigLoader.js";
|
|
3
|
+
import { configArgument, formatFlag } from "../flags.js";
|
|
4
|
+
import { InstanceValidator } from "@effected/schemastore";
|
|
5
|
+
import { Console, Effect, FileSystem, JsonPointer, Option, Path, Predicate, Schema } from "effect";
|
|
6
|
+
import { CliRuntime } from "@effected/cli";
|
|
7
|
+
import { Argument, Command, Flag } from "effect/cli";
|
|
8
|
+
|
|
9
|
+
//#region src/cli/commands/validate.ts
|
|
10
|
+
/**
|
|
11
|
+
* The payload file could not be read or parsed: it does not exist, the
|
|
12
|
+
* filesystem refused it, or its contents are not JSON. Nothing was
|
|
13
|
+
* validated. Exit `2`.
|
|
14
|
+
*
|
|
15
|
+
* @public
|
|
16
|
+
*/
|
|
17
|
+
var PayloadError = class extends Schema.TaggedError()("PayloadError", {
|
|
18
|
+
path: Schema.String,
|
|
19
|
+
reason: Schema.String
|
|
20
|
+
}) {
|
|
21
|
+
get message() {
|
|
22
|
+
return `Failed to read payload ${this.path}: ${this.reason}`;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* The payload names no schema and `--schema` was not given, so there is
|
|
27
|
+
* nothing to validate against. A usage error — the remedy is in how the
|
|
28
|
+
* command is invoked. Exit `64`.
|
|
29
|
+
*
|
|
30
|
+
* @public
|
|
31
|
+
*/
|
|
32
|
+
var MissingSchemaRefError = class extends Schema.TaggedError()("MissingSchemaRefError", { path: Schema.String }) {
|
|
33
|
+
get message() {
|
|
34
|
+
return `Payload ${this.path} has no "$schema" property; pass --schema <path|$id> to name the document to validate against.`;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* The schema reference — `--schema` or the payload's `$schema` — resolved to
|
|
39
|
+
* no readable document: it is neither an existing file nor an identity any
|
|
40
|
+
* config schema derives (`$id`, a frozen `$id`/`url`, a catalog `url`), or
|
|
41
|
+
* the document it names could not be read or parsed. Exit `2`.
|
|
42
|
+
*
|
|
43
|
+
* @public
|
|
44
|
+
*/
|
|
45
|
+
var SchemaResolutionError = class extends Schema.TaggedError()("SchemaResolutionError", {
|
|
46
|
+
$ref: Schema.String,
|
|
47
|
+
reason: Schema.String
|
|
48
|
+
}) {
|
|
49
|
+
get message() {
|
|
50
|
+
return `Failed to resolve schema "${this.$ref}": ${this.reason}`;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* The payload does not conform to the resolved document: the engine
|
|
55
|
+
* answered findings, each already reported. Exit `1` — a validation
|
|
56
|
+
* verdict, the reason CI runs the command.
|
|
57
|
+
*
|
|
58
|
+
* @public
|
|
59
|
+
*/
|
|
60
|
+
var ValidationFailedError = class extends Schema.TaggedError()("ValidationFailedError", {
|
|
61
|
+
count: Schema.Number,
|
|
62
|
+
payload: Schema.String,
|
|
63
|
+
schema: Schema.String
|
|
64
|
+
}) {
|
|
65
|
+
get message() {
|
|
66
|
+
return `${this.count} finding(s): ${this.payload} does not conform to ${this.schema}`;
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
var ReadJsonError = class extends Schema.TaggedError()("ReadJsonError", {
|
|
70
|
+
path: Schema.String,
|
|
71
|
+
reason: Schema.String
|
|
72
|
+
}) {};
|
|
73
|
+
const readJson = Effect.fn("schemastore.validate.readJson")(function* (file) {
|
|
74
|
+
const text = yield* (yield* FileSystem.FileSystem).readFileString(file).pipe(Effect.mapError((cause) => new ReadJsonError({
|
|
75
|
+
path: file,
|
|
76
|
+
reason: cause.message
|
|
77
|
+
})));
|
|
78
|
+
return yield* Effect.try({
|
|
79
|
+
try: () => JSON.parse(text),
|
|
80
|
+
catch: (cause) => new ReadJsonError({
|
|
81
|
+
path: file,
|
|
82
|
+
reason: cause instanceof Error ? cause.message : String(cause)
|
|
83
|
+
})
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
const identityMap = (config) => {
|
|
87
|
+
const identities = /* @__PURE__ */ new Map();
|
|
88
|
+
const add = (identity, name, path) => {
|
|
89
|
+
if (identity !== void 0 && !identities.has(identity)) identities.set(identity, {
|
|
90
|
+
name,
|
|
91
|
+
path
|
|
92
|
+
});
|
|
93
|
+
};
|
|
94
|
+
for (const schema of config.schemas) {
|
|
95
|
+
add(schema.target.$id, schema.name, schema.target.path);
|
|
96
|
+
for (const frozen of schema.frozen) {
|
|
97
|
+
add(frozen.$id, schema.name, frozen.path);
|
|
98
|
+
add(frozen.url, schema.name, frozen.path);
|
|
99
|
+
}
|
|
100
|
+
add(schema.catalog?.url, schema.name, schema.target.path);
|
|
101
|
+
}
|
|
102
|
+
return identities;
|
|
103
|
+
};
|
|
104
|
+
const resolveDocument = Effect.fn("schemastore.validate.resolveDocument")(function* ($ref, input, deps) {
|
|
105
|
+
const path = yield* Path.Path;
|
|
106
|
+
const fs = yield* FileSystem.FileSystem;
|
|
107
|
+
const unresolved = (reason) => Effect.fail(CliRuntime.reported(new SchemaResolutionError({
|
|
108
|
+
$ref,
|
|
109
|
+
reason
|
|
110
|
+
}), 2));
|
|
111
|
+
const candidate = path.resolve(deps.cwd, $ref);
|
|
112
|
+
if (yield* fs.exists(candidate).pipe(Effect.orElseSucceed(() => false))) return {
|
|
113
|
+
parsed: yield* readJson(candidate).pipe(Effect.catchTag("ReadJsonError", ({ reason }) => unresolved(reason))),
|
|
114
|
+
source: candidate
|
|
115
|
+
};
|
|
116
|
+
const loaded = yield* ConfigLoader.load({
|
|
117
|
+
cwd: deps.cwd,
|
|
118
|
+
...Option.isSome(input.config) ? { explicit: input.config.value } : {},
|
|
119
|
+
...deps.importModule !== void 0 ? { importModule: deps.importModule } : {}
|
|
120
|
+
});
|
|
121
|
+
const match = identityMap(loaded.config).get($ref);
|
|
122
|
+
if (match === void 0) return yield* unresolved("not an existing file, and no schema in the config derives this $id or url");
|
|
123
|
+
return {
|
|
124
|
+
parsed: yield* readJson(match.path).pipe(Effect.catchTag("ReadJsonError", ({ reason }) => unresolved(`schema "${match.name}" resolves to ${match.path}, which ${reason}`))),
|
|
125
|
+
source: match.path
|
|
126
|
+
};
|
|
127
|
+
});
|
|
128
|
+
const findingLine = (finding) => ` ${finding.path === "" ? "(root)" : finding.path}: ${finding.message}${finding.keyword !== void 0 ? ` [${finding.keyword}]` : ""}`;
|
|
129
|
+
const jsonReport = (payload, $ref, source, findings) => JSON.stringify({
|
|
130
|
+
payload,
|
|
131
|
+
schema: $ref,
|
|
132
|
+
document: source,
|
|
133
|
+
valid: findings.length === 0,
|
|
134
|
+
findings: findings.map((finding) => ({
|
|
135
|
+
path: finding.path,
|
|
136
|
+
message: finding.message,
|
|
137
|
+
...finding.keyword !== void 0 ? { keyword: finding.keyword } : {}
|
|
138
|
+
}))
|
|
139
|
+
});
|
|
140
|
+
const schemaPropertyOf = (payload) => Predicate.isObject(payload) && !Array.isArray(payload) && typeof payload.$schema === "string" ? payload.$schema : void 0;
|
|
141
|
+
const defsTarget = (document, ref) => {
|
|
142
|
+
if (!ref.startsWith("#/")) return;
|
|
143
|
+
let name;
|
|
144
|
+
try {
|
|
145
|
+
const tokens = ref.slice(2).split("/").map((token) => JsonPointer.unescapeToken(decodeURIComponent(token)));
|
|
146
|
+
if (tokens.length !== 2 || tokens[0] !== "$defs") return;
|
|
147
|
+
name = tokens[1];
|
|
148
|
+
} catch {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const defs = document.$defs;
|
|
152
|
+
if (!Predicate.isObject(defs) || Array.isArray(defs) || !Object.hasOwn(defs, name)) return;
|
|
153
|
+
const entry = defs[name];
|
|
154
|
+
return Predicate.isObject(entry) && !Array.isArray(entry) ? entry : void 0;
|
|
155
|
+
};
|
|
156
|
+
const declaresSchemaProperty = (document) => {
|
|
157
|
+
const declares = (node) => {
|
|
158
|
+
const properties = node.properties;
|
|
159
|
+
return Predicate.isObject(properties) && !Array.isArray(properties) && "$schema" in properties;
|
|
160
|
+
};
|
|
161
|
+
const seen = /* @__PURE__ */ new Set();
|
|
162
|
+
const queue = [document];
|
|
163
|
+
for (let node = queue.shift(); node !== void 0; node = queue.shift()) {
|
|
164
|
+
if (declares(node)) return true;
|
|
165
|
+
if (typeof node.$ref === "string" && !seen.has(node.$ref)) {
|
|
166
|
+
seen.add(node.$ref);
|
|
167
|
+
const target = defsTarget(document, node.$ref);
|
|
168
|
+
if (target !== void 0) queue.push(target);
|
|
169
|
+
}
|
|
170
|
+
if (Array.isArray(node.allOf)) {
|
|
171
|
+
for (const member of node.allOf) if (Predicate.isObject(member) && !Array.isArray(member)) queue.push(member);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return false;
|
|
175
|
+
};
|
|
176
|
+
const withoutSchemaRef = (payload, document) => {
|
|
177
|
+
if (schemaPropertyOf(payload) === void 0 || declaresSchemaProperty(document)) return payload;
|
|
178
|
+
const rest = { ...payload };
|
|
179
|
+
delete rest.$schema;
|
|
180
|
+
return rest;
|
|
181
|
+
};
|
|
182
|
+
/**
|
|
183
|
+
* Run one `schemastore validate`.
|
|
184
|
+
*
|
|
185
|
+
* @remarks
|
|
186
|
+
* Reads the payload, resolves the schema reference (`--schema`, else the
|
|
187
|
+
* payload's `$schema`) file-first then against the config's derived
|
|
188
|
+
* identities, validates with `deps.instanceValidator` or the real engine,
|
|
189
|
+
* and reports. A top-level string `$schema` on the payload is the pointer
|
|
190
|
+
* naming the document: it is stripped from the instance before validating
|
|
191
|
+
* only when the resolved document does not declare `$schema` as a root
|
|
192
|
+
* property; a document that does declare it (the HostedSchema pattern,
|
|
193
|
+
* where the key is required and const-constrained) sees the payload
|
|
194
|
+
* verbatim, and the rest of the payload always goes verbatim. Findings fail
|
|
195
|
+
* `ValidationFailedError` at exit `1`; a
|
|
196
|
+
* payload that cannot be read or parsed is `PayloadError` at `2`, an
|
|
197
|
+
* unresolvable reference `SchemaResolutionError` at `2`, a payload naming
|
|
198
|
+
* nothing `MissingSchemaRefError` at `64`, and an engine mechanism failure
|
|
199
|
+
* (`InstanceValidatorError`) flows unmarked to the runtime's exit `3`.
|
|
200
|
+
* `--format json` writes one report document to stdout and moves the human
|
|
201
|
+
* lines to stderr, exactly as `build`/`check` do.
|
|
202
|
+
*
|
|
203
|
+
* @public
|
|
204
|
+
*/
|
|
205
|
+
const runValidate = Effect.fn("schemastore.validate")(function* (input, deps) {
|
|
206
|
+
const payloadPath = (yield* Path.Path).resolve(deps.cwd, input.payload);
|
|
207
|
+
const payload = yield* readJson(payloadPath).pipe(Effect.catchTag("ReadJsonError", ({ reason }) => Effect.fail(CliRuntime.reported(new PayloadError({
|
|
208
|
+
path: payloadPath,
|
|
209
|
+
reason
|
|
210
|
+
}), 2))));
|
|
211
|
+
const $ref = Option.getOrUndefined(input.schema) ?? schemaPropertyOf(payload);
|
|
212
|
+
if ($ref === void 0) return yield* Effect.fail(CliRuntime.reported(new MissingSchemaRefError({ path: payloadPath }), 64));
|
|
213
|
+
const resolved = yield* resolveDocument($ref, input, deps);
|
|
214
|
+
const document = resolved.parsed;
|
|
215
|
+
if (!Predicate.isObject(document) || Array.isArray(document)) return yield* Effect.fail(CliRuntime.reported(new SchemaResolutionError({
|
|
216
|
+
$ref,
|
|
217
|
+
reason: `${resolved.source} is not a JSON object`
|
|
218
|
+
}), 2));
|
|
219
|
+
const findings = yield* Effect.gen(function* () {
|
|
220
|
+
return yield* (yield* InstanceValidator).validate(document, withoutSchemaRef(payload, document));
|
|
221
|
+
}).pipe(Effect.provide(deps.instanceValidator ?? AjvInstanceValidator.layer));
|
|
222
|
+
const human = findings.length === 0 ? [`valid ${payloadPath} against ${resolved.source}`] : [...findings.map(findingLine), `${findings.length} finding(s): ${payloadPath} does not conform to ${$ref}`];
|
|
223
|
+
if (input.format === "json") {
|
|
224
|
+
yield* Console.log(jsonReport(payloadPath, $ref, resolved.source, findings));
|
|
225
|
+
for (const line of human) yield* Effect.logInfo(line);
|
|
226
|
+
} else for (const line of human) yield* Console.log(line);
|
|
227
|
+
if (findings.length > 0) return yield* Effect.fail(CliRuntime.reported(new ValidationFailedError({
|
|
228
|
+
count: findings.length,
|
|
229
|
+
payload: payloadPath,
|
|
230
|
+
schema: $ref
|
|
231
|
+
}), 1));
|
|
232
|
+
});
|
|
233
|
+
/**
|
|
234
|
+
* `schemastore validate`: check a payload against a published schema
|
|
235
|
+
* document. The reference (`--schema`, else the payload's `$schema`) is an
|
|
236
|
+
* existing file path or an identity the config derives — a schema's `$id`,
|
|
237
|
+
* a frozen version's `$id`/`url`, or a catalog `url` — so CI validates an
|
|
238
|
+
* action's output against the committed document without a third-party
|
|
239
|
+
* tool or a network fetch. A top-level string `$schema` on the payload is
|
|
240
|
+
* consumed as that pointer; it is stripped before validating only when the
|
|
241
|
+
* resolved document does not declare `$schema` as a root property, since a
|
|
242
|
+
* generated document's `additionalProperties: false` would otherwise
|
|
243
|
+
* reject the very self-reference that names it. A document that declares
|
|
244
|
+
* `$schema` — the HostedSchema pattern — validates the payload verbatim
|
|
245
|
+
* and enforces its own const constraint on the key.
|
|
246
|
+
*
|
|
247
|
+
* @public
|
|
248
|
+
*/
|
|
249
|
+
const makeValidateCommand = (deps) => Command.make("validate", {
|
|
250
|
+
payload: Argument.String("payload").pipe(Argument.withDescription("Path to the JSON payload to validate")),
|
|
251
|
+
schema: Flag.String("schema").pipe(Flag.optional, Flag.withDescription("The document to validate against: a file path, or a $id/url a config schema derives; omitted, the payload's own $schema property")),
|
|
252
|
+
config: configArgument,
|
|
253
|
+
format: formatFlag
|
|
254
|
+
}, (input) => runValidate(input, deps)).pipe(Command.withDescription("Validate a JSON payload against a published schema document, resolved by path or by the config's derived $id/url identities"));
|
|
255
|
+
|
|
256
|
+
//#endregion
|
|
257
|
+
export { MissingSchemaRefError, PayloadError, SchemaResolutionError, ValidationFailedError, makeValidateCommand, runValidate };
|
package/cli/root.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { makeBuildCommand } from "./commands/build.js";
|
|
2
2
|
import { makeCheckCommand } from "./commands/check.js";
|
|
3
|
+
import { makeValidateCommand } from "./commands/validate.js";
|
|
3
4
|
import { Command } from "effect/cli";
|
|
4
5
|
|
|
5
6
|
//#region src/cli/root.ts
|
|
@@ -12,10 +13,16 @@ import { Command } from "effect/cli";
|
|
|
12
13
|
const makeCommands = (deps) => {
|
|
13
14
|
const build = makeBuildCommand(deps);
|
|
14
15
|
const check = makeCheckCommand(deps);
|
|
16
|
+
const validate = makeValidateCommand(deps);
|
|
15
17
|
return {
|
|
16
|
-
root: Command.make("schemastore", {}).pipe(Command.withDescription("Build and check SchemaStore-shaped JSON Schema documents from a schemastore.config.ts"), Command.withSubcommands([
|
|
18
|
+
root: Command.make("schemastore", {}).pipe(Command.withDescription("Build and check SchemaStore-shaped JSON Schema documents from a schemastore.config.ts, and validate payloads against them"), Command.withSubcommands([
|
|
19
|
+
build,
|
|
20
|
+
check,
|
|
21
|
+
validate
|
|
22
|
+
])),
|
|
17
23
|
build,
|
|
18
|
-
check
|
|
24
|
+
check,
|
|
25
|
+
validate
|
|
19
26
|
};
|
|
20
27
|
};
|
|
21
28
|
|
package/index.d.ts
CHANGED
|
@@ -1,5 +1,60 @@
|
|
|
1
|
-
import { SchemaValidator } from "@effected/schemastore";
|
|
1
|
+
import { InstanceValidator, SchemaValidator } from "@effected/schemastore";
|
|
2
2
|
import { Layer } from "effect";
|
|
3
|
+
//#region src/AjvInstanceValidator.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* The shipped `InstanceValidator` implementation: the same ajv strict-mode
|
|
6
|
+
* setup `AjvValidator` gates documents with — the declared `KeywordFamilies`
|
|
7
|
+
* keywords and the standard `ajv-formats` vocabulary registered, through the
|
|
8
|
+
* one shared `makeAjv` — pointed at a payload instance instead of the
|
|
9
|
+
* meta-schema.
|
|
10
|
+
*
|
|
11
|
+
* @remarks
|
|
12
|
+
* Lives in the CLI, not the library, for the same reason `AjvValidator`
|
|
13
|
+
* does: `@effected/schemastore` owns the `InstanceValidator` contract and
|
|
14
|
+
* its doubles, an application that imports it at runtime never pulls an
|
|
15
|
+
* engine, and the `schemastore validate` command composes this layer at its
|
|
16
|
+
* edge. It is exported for a program that validates payloads itself and
|
|
17
|
+
* wants the same verdict the command gives — a document the `check` gate
|
|
18
|
+
* admits always compiles here, so the two engines cannot drift.
|
|
19
|
+
*
|
|
20
|
+
* `validate` compiles the document and runs the instance against it. An
|
|
21
|
+
* instance the document rejects answers `InstanceFinding` values — ajv's
|
|
22
|
+
* structured `instancePath` pointer and `keyword` preserved, `allErrors`
|
|
23
|
+
* on, so one run reports every problem, not just the first. A document the
|
|
24
|
+
* engine cannot compile (a meta-schema failure, a strict-mode rejection, a
|
|
25
|
+
* declared keyword whose NAME ajv's grammar refuses) fails
|
|
26
|
+
* `InstanceValidatorError`: unlike `AjvValidator`, whose subject IS the
|
|
27
|
+
* document, this contract's subject is the instance, and a document that
|
|
28
|
+
* yields no verdict is the engine failing to run as a mechanism. The
|
|
29
|
+
* document's own gate is `SchemaValidator`'s job — `schemastore check` runs
|
|
30
|
+
* it before a document is ever published.
|
|
31
|
+
*
|
|
32
|
+
* `strict` defaults to `true` — SchemaStore's gate. Each call builds its own
|
|
33
|
+
* ajv instance, so documents sharing an `$id` never collide.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ```ts
|
|
37
|
+
* import { InstanceValidator } from "@effected/schemastore";
|
|
38
|
+
* import { AjvInstanceValidator } from "@effected/schemastore-cli";
|
|
39
|
+
* import { Effect } from "effect";
|
|
40
|
+
*
|
|
41
|
+
* const program = Effect.gen(function* () {
|
|
42
|
+
* const validator = yield* InstanceValidator;
|
|
43
|
+
* return yield* validator.validate({ type: "object" }, { any: "payload" });
|
|
44
|
+
* });
|
|
45
|
+
*
|
|
46
|
+
* Effect.runPromise(Effect.provide(program, AjvInstanceValidator.layer));
|
|
47
|
+
* // => []
|
|
48
|
+
* ```
|
|
49
|
+
*
|
|
50
|
+
* @public
|
|
51
|
+
*/
|
|
52
|
+
export declare class AjvInstanceValidator {
|
|
53
|
+
private constructor();
|
|
54
|
+
/** The engine, as an `InstanceValidator` layer. */
|
|
55
|
+
static readonly layer: Layer.Layer<InstanceValidator>;
|
|
56
|
+
}
|
|
57
|
+
//#endregion
|
|
3
58
|
//#region src/AjvValidator.d.ts
|
|
4
59
|
/**
|
|
5
60
|
* The shipped `SchemaValidator` implementation: ajv strict mode over the
|
|
@@ -23,13 +78,16 @@ import { Layer } from "effect";
|
|
|
23
78
|
* carrying a dot or a space. The error channel stays reserved for the engine
|
|
24
79
|
* failing as a mechanism (`SchemaValidatorError`).
|
|
25
80
|
*
|
|
81
|
+
* The ajv setup is `internal/ajv.ts`'s `makeAjv`, shared with
|
|
82
|
+
* `AjvInstanceValidator` so a document this gate admits always compiles in
|
|
83
|
+
* the instance engine too — the two verdicts cannot drift.
|
|
84
|
+
*
|
|
26
85
|
* `strict` defaults to `true` — SchemaStore's gate. Each call builds its own
|
|
27
86
|
* ajv instance, so documents sharing an `$id` never collide. Registering the
|
|
28
87
|
* declared families keeps the engine's verdict consistent with
|
|
29
88
|
* `DocumentLint`'s through the same predicate, so the two cannot drift; the
|
|
30
89
|
* plugin's `formatMaximum` / `formatMinimum` limit keywords are deliberately
|
|
31
90
|
* NOT registered, because the lint answers those as unknown keywords.
|
|
32
|
-
*
|
|
33
91
|
* @example
|
|
34
92
|
* ```ts
|
|
35
93
|
* import { SchemaValidator } from "@effected/schemastore";
|
package/index.js
CHANGED
package/internal/ajv.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { KeywordFamilies } from "@effected/schemastore";
|
|
2
|
+
import { Ajv } from "ajv";
|
|
3
|
+
import ajvFormats from "ajv-formats";
|
|
4
|
+
|
|
5
|
+
//#region src/internal/ajv.ts
|
|
6
|
+
const addFormats = ajvFormats.default;
|
|
7
|
+
const MAX_KEYWORD_WALK_DEPTH = 256;
|
|
8
|
+
const collectDeclaredKeywords = (node, into, depth) => {
|
|
9
|
+
if (depth >= MAX_KEYWORD_WALK_DEPTH || typeof node !== "object" || node === null) return;
|
|
10
|
+
if (Array.isArray(node)) {
|
|
11
|
+
for (const element of node) collectDeclaredKeywords(element, into, depth + 1);
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
for (const [key, value] of Object.entries(node)) {
|
|
15
|
+
if (KeywordFamilies.isDeclared(key)) {
|
|
16
|
+
into.add(key);
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
collectDeclaredKeywords(value, into, depth + 1);
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Build the shared strict-mode ajv instance over a document: the standard
|
|
24
|
+
* `ajv-formats` vocabulary registered (`keywords: false` is load-bearing —
|
|
25
|
+
* the plugin's default ALSO registers `formatMaximum` / `formatMinimum` and
|
|
26
|
+
* their exclusive variants, which `DocumentLint` answers as unknown
|
|
27
|
+
* keywords, and registering them would drift the two verdicts apart), plus
|
|
28
|
+
* every declared `KeywordFamilies` keyword the document carries.
|
|
29
|
+
*
|
|
30
|
+
* THROWS when ajv itself refuses the setup — most often a declared keyword
|
|
31
|
+
* whose NAME ajv's own grammar (`/^[a-z_$][a-z0-9_$:-]*$/i`) rejects, such
|
|
32
|
+
* as an `x-ai-*` key carrying a dot or a space. Each caller decides what
|
|
33
|
+
* that throw means for its subject: a finding for `AjvValidator` (the
|
|
34
|
+
* document IS the subject there), an engine failure for
|
|
35
|
+
* `AjvInstanceValidator` (there the subject is the instance, and a document
|
|
36
|
+
* that will not compile yields no verdict about it).
|
|
37
|
+
*/
|
|
38
|
+
const makeAjv = (document, strict) => {
|
|
39
|
+
const ajv = new Ajv({
|
|
40
|
+
strict,
|
|
41
|
+
allErrors: true
|
|
42
|
+
});
|
|
43
|
+
addFormats(ajv, { keywords: false });
|
|
44
|
+
const declared = /* @__PURE__ */ new Set();
|
|
45
|
+
collectDeclaredKeywords(document, declared, 0);
|
|
46
|
+
for (const keyword of declared) ajv.addKeyword({ keyword });
|
|
47
|
+
return ajv;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
//#endregion
|
|
51
|
+
export { makeAjv };
|
package/main.js
CHANGED
|
@@ -10,7 +10,7 @@ const render = (error) => CliError.isCliError(error) && error._tag === "ShowHelp
|
|
|
10
10
|
const main = () => {
|
|
11
11
|
const run = program(process.argv.slice(2), {
|
|
12
12
|
cwd: process.cwd(),
|
|
13
|
-
version: "0.
|
|
13
|
+
version: "0.19.0"
|
|
14
14
|
}).pipe(Effect.provide(NodeServices.layer), CliRuntime.reportFailures({
|
|
15
15
|
exitCode: 3,
|
|
16
16
|
render
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effected/schemastore-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "The schemastore command: build and check SchemaStore-shaped JSON Schema documents from a schemastore.config.ts, with a per-schema published flag and a drift policy.",
|
|
6
6
|
"keywords": [
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"jiti": "^2.6.0"
|
|
51
51
|
},
|
|
52
52
|
"peerDependencies": {
|
|
53
|
-
"@effected/schemastore": "0.
|
|
53
|
+
"@effected/schemastore": "0.19.0",
|
|
54
54
|
"effect": "^4.0.0"
|
|
55
55
|
},
|
|
56
56
|
"engines": {
|