@effected/schemastore-cli 0.11.0 → 0.12.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/AjvValidator.js +103 -0
- package/ConfigLoader.js +2 -2
- package/README.md +87 -42
- package/Report.js +1 -0
- package/Runner.js +95 -8
- package/cli/execute.js +8 -4
- package/cli/program.js +1 -1
- package/index.d.ts +56 -0
- package/index.js +3 -0
- package/main.js +2 -2
- package/package.json +9 -2
- package/tsdoc-metadata.json +11 -0
package/AjvValidator.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { KeywordFamilies, SchemaValidator, SchemaValidatorError, ValidationFinding } from "@effected/schemastore";
|
|
2
|
+
import { Ajv } from "ajv";
|
|
3
|
+
import ajvFormats from "ajv-formats";
|
|
4
|
+
import { Effect, Layer } from "effect";
|
|
5
|
+
|
|
6
|
+
//#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
|
+
const findingFromAjvError = (error) => ValidationFinding.make({
|
|
24
|
+
path: error.instancePath,
|
|
25
|
+
message: error.message ?? "schema is not valid",
|
|
26
|
+
keyword: error.keyword
|
|
27
|
+
});
|
|
28
|
+
/**
|
|
29
|
+
* The shipped `SchemaValidator` implementation: ajv strict mode over the
|
|
30
|
+
* Draft-07 meta-schema, with every declared `KeywordFamilies` keyword and
|
|
31
|
+
* the standard `ajv-formats` vocabulary registered.
|
|
32
|
+
*
|
|
33
|
+
* @remarks
|
|
34
|
+
* Lives in the CLI, not the library, so `ajv` is a cost only the command
|
|
35
|
+
* pays: `@effected/schemastore` owns the `SchemaValidator` contract
|
|
36
|
+
* and its doubles, an application that imports it at runtime never pulls an
|
|
37
|
+
* engine, and the `schemastore` command composes this layer at its edge. It
|
|
38
|
+
* is exported for a program that drives `SchemaPipeline` itself and wants
|
|
39
|
+
* the same verdict the command gives.
|
|
40
|
+
*
|
|
41
|
+
* `validate` checks the document against the Draft-07 meta-schema and then
|
|
42
|
+
* compiles it, reporting BOTH as `ValidationFinding` values: meta-schema
|
|
43
|
+
* failures keep ajv's structured `instancePath` and `keyword`, while a
|
|
44
|
+
* rejection ajv raises by *throwing* becomes a root-pathed finding — both a
|
|
45
|
+
* strict-mode compile failure and a declared keyword whose NAME ajv's own
|
|
46
|
+
* grammar (`/^[a-z_$][a-z0-9_$:-]*$/i`) refuses, such as an `x-ai-*` key
|
|
47
|
+
* carrying a dot or a space. The error channel stays reserved for the engine
|
|
48
|
+
* failing as a mechanism (`SchemaValidatorError`).
|
|
49
|
+
*
|
|
50
|
+
* `strict` defaults to `true` — SchemaStore's gate. Each call builds its own
|
|
51
|
+
* ajv instance, so documents sharing an `$id` never collide. Registering the
|
|
52
|
+
* declared families keeps the engine's verdict consistent with
|
|
53
|
+
* `DocumentLint`'s through the same predicate, so the two cannot drift; the
|
|
54
|
+
* plugin's `formatMaximum` / `formatMinimum` limit keywords are deliberately
|
|
55
|
+
* NOT registered, because the lint answers those as unknown keywords.
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* ```ts
|
|
59
|
+
* import { SchemaValidator } from "@effected/schemastore";
|
|
60
|
+
* import { AjvValidator } from "@effected/schemastore-cli";
|
|
61
|
+
* import { Effect } from "effect";
|
|
62
|
+
*
|
|
63
|
+
* const program = Effect.gen(function* () {
|
|
64
|
+
* const validator = yield* SchemaValidator;
|
|
65
|
+
* return yield* validator.validate({ type: "object" });
|
|
66
|
+
* });
|
|
67
|
+
*
|
|
68
|
+
* Effect.runPromise(Effect.provide(program, AjvValidator.layer));
|
|
69
|
+
* // => []
|
|
70
|
+
* ```
|
|
71
|
+
*
|
|
72
|
+
* @public
|
|
73
|
+
*/
|
|
74
|
+
var AjvValidator = class {
|
|
75
|
+
constructor() {}
|
|
76
|
+
/** The engine, as a `SchemaValidator` layer. */
|
|
77
|
+
static layer = Layer.succeed(SchemaValidator, { validate: (document, options) => Effect.try({
|
|
78
|
+
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
|
+
try {
|
|
87
|
+
for (const keyword of declared) ajv.addKeyword({ keyword });
|
|
88
|
+
if (!ajv.validateSchema(document)) return (ajv.errors ?? []).map(findingFromAjvError);
|
|
89
|
+
ajv.compile(document);
|
|
90
|
+
} catch (cause) {
|
|
91
|
+
return [ValidationFinding.make({
|
|
92
|
+
path: "",
|
|
93
|
+
message: cause instanceof Error ? cause.message : String(cause)
|
|
94
|
+
})];
|
|
95
|
+
}
|
|
96
|
+
return [];
|
|
97
|
+
},
|
|
98
|
+
catch: (cause) => SchemaValidatorError.make({ cause })
|
|
99
|
+
}) });
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
//#endregion
|
|
103
|
+
export { AjvValidator };
|
package/ConfigLoader.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Effect, FileSystem, Path, Predicate, Schema } from "effect";
|
|
2
1
|
import { isSchemastoreConfig } from "@effected/schemastore";
|
|
2
|
+
import { Effect, FileSystem, Path, Predicate, Schema } from "effect";
|
|
3
3
|
import { createJiti } from "jiti";
|
|
4
4
|
|
|
5
5
|
//#region src/ConfigLoader.ts
|
|
@@ -45,7 +45,7 @@ const describeMalformed = (config) => {
|
|
|
45
45
|
if (!Predicate.isObject(schema) || typeof schema.name !== "string" || !Array.isArray(schema.frozen) || typeof schema.drift !== "string") return `schemas[${index}] is not a resolved schema (missing name/target/frozen/drift)`;
|
|
46
46
|
if (!isTargetShaped(schema.target)) return `schemas[${index}].target is not a SchemaTarget (missing schema/$id/path/published)`;
|
|
47
47
|
if (schema.catalog !== void 0 && !isCatalogEntryShaped(schema.catalog)) return `schemas[${index}].catalog is not a catalog entry (missing name/description/fileMatch/url)`;
|
|
48
|
-
for (const [j, frozen] of schema.frozen.entries()) if (!Predicate.isObject(frozen) || typeof frozen.version !== "string" || typeof frozen.path !== "string" || typeof frozen.url !== "string") return `schemas[${index}].frozen[${j}] is not a frozen version (missing version/path/url)`;
|
|
48
|
+
for (const [j, frozen] of schema.frozen.entries()) if (!Predicate.isObject(frozen) || typeof frozen.version !== "string" || typeof frozen.path !== "string" || typeof frozen.$id !== "string" || typeof frozen.url !== "string") return `schemas[${index}].frozen[${j}] is not a frozen version (missing version/path/$id/url)`;
|
|
49
49
|
}
|
|
50
50
|
};
|
|
51
51
|
const describeDuplicatePath = (config) => {
|
package/README.md
CHANGED
|
@@ -5,9 +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`. It is the
|
|
9
|
-
|
|
10
|
-
It is not a library: nothing is importable from it. Every type a config file needs comes from `@effected/schemastore`, which the CLI declares as a peer so your config and the pipeline share one `effect` and one `@effected/schemastore` instance.
|
|
8
|
+
The `schemastore` command: build and check SchemaStore-shaped JSON Schema documents from a `schemastore.config.ts`. 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 engine lives: `AjvValidator`, ajv in strict mode, composed by the command and exported for a program that drives the pipeline itself, so the library stays free of ajv.
|
|
11
9
|
|
|
12
10
|
> **Pre-release.** This package is part of the `@effected/*` kit, in pre-`1.0.0`
|
|
13
11
|
> development against a single pinned Effect v4 prerelease. Packages graduate to
|
|
@@ -23,57 +21,90 @@ It is not a library: nothing is importable from it. Every type a config file nee
|
|
|
23
21
|
|
|
24
22
|
## Install
|
|
25
23
|
|
|
26
|
-
|
|
24
|
+
The two packages release together at one version. The library is a regular dependency (the application reads its schema's identity from it at runtime); the command is a devDependency.
|
|
27
25
|
|
|
28
26
|
```bash
|
|
29
|
-
|
|
27
|
+
pnpm add @effected/schemastore effect
|
|
28
|
+
pnpm add -D @effected/schemastore-cli
|
|
30
29
|
```
|
|
31
30
|
|
|
32
|
-
|
|
33
|
-
pnpm add -D @effected/schemastore-cli @effected/schemastore effect
|
|
34
|
-
```
|
|
31
|
+
`effect` and `@effected/schemastore` are peers of the command, so your config and the pipeline share one instance of each.
|
|
35
32
|
|
|
36
33
|
## Configure
|
|
37
34
|
|
|
38
|
-
|
|
35
|
+
Declare each schema's hosted identity once, in the application, next to the schema — the application writes `$schema` from it, and the config hands the same value to `defineConfig` so nothing is spelled twice:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
// src/schema/output.ts
|
|
39
|
+
import { HostedSchema } from "@effected/schemastore";
|
|
40
|
+
import { Schema } from "effect";
|
|
41
|
+
|
|
42
|
+
export const OUTPUT_SCHEMA_VERSION = "5.2";
|
|
43
|
+
|
|
44
|
+
export const OutputSchemaIdentity = HostedSchema.github({
|
|
45
|
+
repo: "savvy-web/silk-release-action",
|
|
46
|
+
path: "schemas",
|
|
47
|
+
name: "silk-release-action.output",
|
|
48
|
+
versions: [OUTPUT_SCHEMA_VERSION],
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export const ReleaseOutput = Schema.Struct({
|
|
52
|
+
$schema: Schema.Literal(OutputSchemaIdentity.$id),
|
|
53
|
+
status: Schema.Literals(["released", "skipped"]),
|
|
54
|
+
});
|
|
55
|
+
```
|
|
39
56
|
|
|
40
57
|
```ts
|
|
58
|
+
// schemastore.config.ts (also .mts, .js, .mjs; found by walking upward, or passed as the positional argument)
|
|
41
59
|
import { defineConfig } from "@effected/schemastore";
|
|
42
|
-
import {
|
|
60
|
+
import { OutputSchemaIdentity, ReleaseOutput } from "./src/schema/output.js";
|
|
43
61
|
|
|
44
62
|
export default defineConfig({
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
schema: OkfitConfig,
|
|
50
|
-
versions: ["1.0"],
|
|
51
|
-
published: true,
|
|
52
|
-
catalog: { description: "okfit configuration", fileMatch: ["okfit.toml", ".okfit.toml"] },
|
|
63
|
+
// Relative paths resolve against this file's directory.
|
|
64
|
+
outputDir: "schemas",
|
|
65
|
+
schemas: {
|
|
66
|
+
[OutputSchemaIdentity.name]: { schema: ReleaseOutput, hosted: OutputSchemaIdentity },
|
|
53
67
|
},
|
|
54
|
-
},
|
|
55
68
|
});
|
|
56
69
|
```
|
|
57
70
|
|
|
58
|
-
A second label is appended to `versions` only once the first is published
|
|
59
|
-
and its file already exists on disk — see "The lifecycle" in the
|
|
60
|
-
`building-schemastore-schemas` skill's `drift-and-versioning.md` reference.
|
|
61
|
-
A first-run config should declare a single label; naming an extra one before
|
|
62
|
-
its file exists fails the build with `FrozenVersionMissingError`.
|
|
63
|
-
|
|
64
|
-
`schemas` is keyed by file base name — the key IS the schema's `name`, and `$id`, the write `path` and every catalog URL derive from it, `outputDir` and `baseUrl`; there is no `$id` override. `versions` lists every label the catalog advertises; `current` (default: the newest) is the one generated at `path`/`$id`, and every other label becomes a **frozen** file the CLI verifies still exists on disk but never regenerates — advertising a frozen label with nothing on disk fails the build before anything is written. `published` (default `false`) marks a version other people already depend on. `baseUrl: "schemastore"` expands `$id` to `https://json.schemastore.org/…` and the catalog URL to `https://www.schemastore.org/…`; any other value is one `https://` base for both. `outputDir` and `onDrift` are top-level only; `baseUrl` and `drift` are top-level defaults an entry may override. `catalog` is required under `baseUrl: "schemastore"` and optional under a custom host. Every schema's declared `catalog` entry lands in ONE file at `catalogPath` (default `<outputDir>/catalog.json`) — never one file per schema.
|
|
65
|
-
|
|
66
|
-
Then add two scripts:
|
|
67
|
-
|
|
68
71
|
```json
|
|
69
72
|
{
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
73
|
+
"scripts": {
|
|
74
|
+
"schema:build": "schemastore build",
|
|
75
|
+
"schema:check": "schemastore check"
|
|
76
|
+
}
|
|
74
77
|
}
|
|
75
78
|
```
|
|
76
79
|
|
|
80
|
+
`schema:build` writes `schemas/5.2/silk-release-action.output-5.2.json`; `schema:check` in CI fails whenever a build would write anything. Bumping the version is one constant, and once a label has shipped it stays in `versions` as a frozen file the command verifies but never regenerates.
|
|
81
|
+
|
|
82
|
+
A schema published to SchemaStore itself uses `HostedSchema.schemastore` and declares its catalog entry; the command assembles every entry into one `catalog.json`:
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
export default defineConfig({
|
|
86
|
+
outputDir: "schemas",
|
|
87
|
+
schemas: {
|
|
88
|
+
okfit: {
|
|
89
|
+
schema: OkfitConfig,
|
|
90
|
+
hosted: HostedSchema.schemastore({ name: "okfit", versions: ["1.0"] }),
|
|
91
|
+
published: true,
|
|
92
|
+
catalog: { description: "okfit configuration", fileMatch: ["okfit.toml", ".okfit.toml"] },
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### The entry contract
|
|
99
|
+
|
|
100
|
+
- The key IS the schema's `name`; `$id`, the write `path` and every catalog URL derive from it and the identity. There is no `$id` override. With `hosted`, the key must equal `hosted.name` and `baseUrl` / `versions` / `current` / `layout` must not be spelled beside it; without `hosted`, spell those fields (and a top-level `baseUrl` default) and the command builds the identity for you.
|
|
101
|
+
- `versions` lists every label the catalog advertises; `current` (default: the newest) is the one generated at `path` / `$id`. Every other label is **frozen**: it must exist on disk and declare the `$id` the config derives for it, or the build fails before anything is written (`FrozenVersionMissingError`, `FrozenVersionIdMismatchError`). A `repo` / `branch` / `path` / `baseUrl` change is therefore a re-publish event for every frozen label. Declare a single label on a first run; append the next only once the first has shipped.
|
|
102
|
+
- `appendVersion` (default `true`) keeps SchemaStore's `-<version>` file suffix; `false` lets the version directory name the file alone (`schemas/6.0/output.json`) and requires the `"versioned"` layout. Like `baseUrl`, it belongs to `hosted` when that is given, and flipping it is a re-publish event for every frozen label.
|
|
103
|
+
- `published` (default `false`) marks a version other people already depend on: an unpublished schema regenerates in place through any change, a published one is held to the drift policy.
|
|
104
|
+
- `drift` (`strict` / `semantic` / `allow`, default `semantic`) and `onDrift` (`error` / `warn`) are top-level defaults; `drift`, `published`, `jsonSchema` and `rootAnnotations` may be set per entry. Objects are emitted closed (`additionalProperties: false`); `jsonSchema: { onExcessProperty: "ignore" }` reopens one document.
|
|
105
|
+
- `catalog` is required under SchemaStore hosting and optional under a custom host; every entry lands in ONE file at `catalogPath` (default `<outputDir>/catalog.json`).
|
|
106
|
+
- A typo'd key anywhere in the config is named and rejected, never ignored, and every issue on an entry is reported at once.
|
|
107
|
+
|
|
77
108
|
## Commands
|
|
78
109
|
|
|
79
110
|
```text
|
|
@@ -81,22 +112,36 @@ schemastore build [config] [--drift=strict|semantic|allow] [--on-drift=error|war
|
|
|
81
112
|
schemastore check [config] [--drift=strict|semantic|allow] [--on-drift=error|warn] [--force] [--format=human|json]
|
|
82
113
|
```
|
|
83
114
|
|
|
84
|
-
- Before anything is generated, every
|
|
85
|
-
- `build` generates every schema, runs the gates (structural
|
|
86
|
-
- `check` is the identical walk with no writes: it reports what `build` would do under the same flags and exits
|
|
87
|
-
- `--drift` and `--on-drift` override the config
|
|
88
|
-
- `--format=json` emits one JSON document on stdout (
|
|
89
|
-
|
|
115
|
+
- Before anything is generated, every frozen label is verified — present on disk, and self-identified by the derived `$id`.
|
|
116
|
+
- `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 catalog file when any entry declares one.
|
|
117
|
+
- `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.json` left behind after the last `catalog` block was removed is reported `orphaned` and fails `check` the same way, but `build` never deletes it: delete the file by hand, or restore a `catalog` block.
|
|
118
|
+
- `--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).
|
|
119
|
+
- `--format=json` emits one JSON document on stdout (per-schema outcome and effective tolerance, the catalog outcome, `drift: { onDrift, policy? }`); human text moves to stderr. When `GITHUB_STEP_SUMMARY` is set, both commands append a markdown table.
|
|
120
|
+
|
|
121
|
+
## The engine, as a library export
|
|
122
|
+
|
|
123
|
+
The command validates 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. The same layer is this package's one export, for a program composing the pipeline directly:
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
import { SchemaFile, SchemaPipeline } from "@effected/schemastore";
|
|
127
|
+
import { AjvValidator } from "@effected/schemastore-cli";
|
|
128
|
+
import { NodeServices } from "@effect/platform-node";
|
|
129
|
+
import { Effect, Layer } from "effect";
|
|
130
|
+
|
|
131
|
+
const AppLayer = Layer.mergeAll(SchemaFile.layer, AjvValidator.layer).pipe(Layer.provide(NodeServices.layer));
|
|
132
|
+
|
|
133
|
+
const program = SchemaPipeline.run(targets).pipe(Effect.provide(AppLayer));
|
|
134
|
+
```
|
|
90
135
|
|
|
91
|
-
|
|
136
|
+
Findings come back as values; the error channel carries `SchemaValidatorError` only when the engine fails as a mechanism.
|
|
92
137
|
|
|
93
138
|
## Exit codes
|
|
94
139
|
|
|
95
140
|
| code | meaning |
|
|
96
141
|
| ---- | -------------------------------------------------------------------------- |
|
|
97
142
|
| 0 | success, including drift under `onDrift: warn` |
|
|
98
|
-
| 1 | drift under `onDrift: error` (
|
|
99
|
-
| 2 | config not found, failed to load, or failed `
|
|
143
|
+
| 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, or — for `check` — anything `build` would write |
|
|
144
|
+
| 2 | config not found, failed to load, or failed `defineConfig` validation |
|
|
100
145
|
| 3 | infrastructure failure |
|
|
101
146
|
| 64 | usage error |
|
|
102
147
|
|
package/Report.js
CHANGED
|
@@ -23,6 +23,7 @@ const catalogLine = (entry) => {
|
|
|
23
23
|
case "unchanged": return `unchanged catalog ${entry.path} (${entry.entries} entries)`;
|
|
24
24
|
case "would-write": return `would write catalog ${entry.path} (${entry.entries} entries)`;
|
|
25
25
|
case "held": return `held catalog ${entry.path} (${entry.entries} entries)`;
|
|
26
|
+
case "orphaned": return `orphaned catalog ${entry.path} (no schema declares a catalog)`;
|
|
26
27
|
default: return entry.outcome;
|
|
27
28
|
}
|
|
28
29
|
};
|
package/Runner.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Effect, FileSystem, Option, Path, Schema } from "effect";
|
|
2
1
|
import { CanonicalJson, CatalogEntry, DriftPolicy, SchemaPipeline, SchemaVersioning } from "@effected/schemastore";
|
|
2
|
+
import { Effect, FileSystem, Option, Path, Schema } from "effect";
|
|
3
3
|
|
|
4
4
|
//#region src/Runner.ts
|
|
5
5
|
/**
|
|
@@ -26,9 +26,61 @@ missing: Schema.Array(Schema.Struct({
|
|
|
26
26
|
return `${this.missing.length} frozen version(s) advertised but not on disk; nothing was written.\n${lines.join("\n")}`;
|
|
27
27
|
}
|
|
28
28
|
};
|
|
29
|
+
/**
|
|
30
|
+
* Indicates that one or more frozen files exist but do not declare the
|
|
31
|
+
* `$id` their {@link ResolvedSchema.frozen} entry derives — the file is
|
|
32
|
+
* absent an `$id`, declares a different one, or does not parse. Raised by
|
|
33
|
+
* {@link Runner.run} before anything is generated: a frozen document is
|
|
34
|
+
* the one file the derivation does not own, so it is the one place `$id`
|
|
35
|
+
* and the advertised URL can still disagree (a `baseUrl` change leaves
|
|
36
|
+
* every frozen file carrying the old host). Every mismatch is collected.
|
|
37
|
+
*
|
|
38
|
+
* @public
|
|
39
|
+
*/
|
|
40
|
+
var FrozenVersionIdMismatchError = class extends Schema.TaggedError()("FrozenVersionIdMismatchError", {
|
|
41
|
+
/** One entry per frozen file whose `$id` is not the derived one. */
|
|
42
|
+
mismatched: Schema.Array(Schema.Struct({
|
|
43
|
+
/** The schema's key in the config. */
|
|
44
|
+
name: Schema.String,
|
|
45
|
+
/** The frozen version label. */
|
|
46
|
+
version: Schema.String,
|
|
47
|
+
/** The frozen file. */
|
|
48
|
+
path: Schema.String,
|
|
49
|
+
/** The `$id` the config derives for this label. */
|
|
50
|
+
expected: Schema.String,
|
|
51
|
+
/** The `$id` the file declares; absent when it declares none or does not parse. */
|
|
52
|
+
actual: Schema.optionalKey(Schema.String),
|
|
53
|
+
/** Why the file fails: a different `$id`, no `$id` at all, or text that is not JSON. */
|
|
54
|
+
reason: Schema.Literals([
|
|
55
|
+
"mismatch",
|
|
56
|
+
"absent",
|
|
57
|
+
"unparseable"
|
|
58
|
+
])
|
|
59
|
+
})) }) {
|
|
60
|
+
get message() {
|
|
61
|
+
const lines = this.mismatched.map((entry) => {
|
|
62
|
+
const detail = entry.reason === "mismatch" ? `declares $id ${entry.actual}, expected ${entry.expected}` : entry.reason === "absent" ? `declares no $id, expected ${entry.expected}` : `does not parse as JSON, expected $id ${entry.expected}`;
|
|
63
|
+
return ` schema "${entry.name}" version ${entry.version}: ${entry.path} ${detail}`;
|
|
64
|
+
});
|
|
65
|
+
return `${this.mismatched.length} frozen version(s) on disk do not carry their derived $id; nothing was written.\n${lines.join("\n")}`;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
29
68
|
const pipelineOptions = { contractChanges: "allow" };
|
|
30
69
|
const orNone = (read) => read.pipe(Effect.map(Option.some), Effect.catchIf((error) => error.reason._tag === "NotFound", () => Effect.succeed(Option.none())));
|
|
31
70
|
const pendingOutcome = (wouldWrite, refused) => !wouldWrite ? "unchanged" : refused ? "held" : "would-write";
|
|
71
|
+
const declaredId = (text) => {
|
|
72
|
+
let parsed;
|
|
73
|
+
try {
|
|
74
|
+
parsed = JSON.parse(text);
|
|
75
|
+
} catch {
|
|
76
|
+
return { reason: "unparseable" };
|
|
77
|
+
}
|
|
78
|
+
const $id = typeof parsed === "object" && parsed !== null ? parsed.$id : void 0;
|
|
79
|
+
return typeof $id === "string" ? {
|
|
80
|
+
reason: "ok",
|
|
81
|
+
$id
|
|
82
|
+
} : { reason: "absent" };
|
|
83
|
+
};
|
|
32
84
|
const parsesEqual = (existing, text) => {
|
|
33
85
|
try {
|
|
34
86
|
return CanonicalJson.equals(JSON.parse(existing), JSON.parse(text));
|
|
@@ -48,7 +100,11 @@ const parsesEqual = (existing, text) => {
|
|
|
48
100
|
* is reported at once: a build fails typed with
|
|
49
101
|
* {@link FrozenVersionMissingError} listing every label with no file on disk
|
|
50
102
|
* — nothing is written — a catalog that points a label at a 404 is a worse
|
|
51
|
-
* failure than an early refusal.
|
|
103
|
+
* failure than an early refusal. A file that is there is read, and must
|
|
104
|
+
* declare the `$id` its entry derives, else the run fails typed with
|
|
105
|
+
* {@link FrozenVersionIdMismatchError} (a different `$id`, none, or text
|
|
106
|
+
* that is not JSON) — a `baseUrl` change is a re-publish event for every
|
|
107
|
+
* frozen label, not a silent re-advertisement.
|
|
52
108
|
*
|
|
53
109
|
* **Drift is classified per schema, under that schema's own
|
|
54
110
|
* {@link ResolvedSchema.drift} tolerance — unless `options.policy` is set,
|
|
@@ -66,8 +122,10 @@ const parsesEqual = (existing, text) => {
|
|
|
66
122
|
* **Every catalog entry the config declares lands in ONE file** at
|
|
67
123
|
* `config.catalogPath` — never one file per schema — serialized canonically
|
|
68
124
|
* and compared by parsed content against the file on disk, written only
|
|
69
|
-
* when different and only when the run is writing.
|
|
70
|
-
*
|
|
125
|
+
* when different and only when the run is writing. When no schema declares
|
|
126
|
+
* one, nothing is written; a file still at `catalogPath` is reported
|
|
127
|
+
* `orphaned` (stale under `check`) and left in place, and the report omits
|
|
128
|
+
* `catalog` entirely only when there is no such file either.
|
|
71
129
|
*
|
|
72
130
|
* @public
|
|
73
131
|
*/
|
|
@@ -77,15 +135,37 @@ var Runner = class {
|
|
|
77
135
|
const fs = yield* FileSystem.FileSystem;
|
|
78
136
|
const path = yield* Path.Path;
|
|
79
137
|
const missing = [];
|
|
138
|
+
const mismatched = [];
|
|
80
139
|
for (const schema of config.schemas) for (const frozen of schema.frozen) {
|
|
81
140
|
const info = yield* orNone(fs.stat(frozen.path));
|
|
82
|
-
if (Option.isNone(info) || info.value.type !== "File")
|
|
141
|
+
if (Option.isNone(info) || info.value.type !== "File") {
|
|
142
|
+
missing.push({
|
|
143
|
+
name: schema.name,
|
|
144
|
+
version: frozen.version,
|
|
145
|
+
path: frozen.path
|
|
146
|
+
});
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const text = yield* fs.readFileString(frozen.path);
|
|
150
|
+
const declared = declaredId(text);
|
|
151
|
+
if (declared.reason !== "ok") mismatched.push({
|
|
152
|
+
name: schema.name,
|
|
153
|
+
version: frozen.version,
|
|
154
|
+
path: frozen.path,
|
|
155
|
+
expected: frozen.$id,
|
|
156
|
+
reason: declared.reason
|
|
157
|
+
});
|
|
158
|
+
else if (declared.$id !== frozen.$id) mismatched.push({
|
|
83
159
|
name: schema.name,
|
|
84
160
|
version: frozen.version,
|
|
85
|
-
path: frozen.path
|
|
161
|
+
path: frozen.path,
|
|
162
|
+
expected: frozen.$id,
|
|
163
|
+
actual: declared.$id,
|
|
164
|
+
reason: "mismatch"
|
|
86
165
|
});
|
|
87
166
|
}
|
|
88
167
|
if (missing.length > 0) return yield* Effect.fail(new FrozenVersionMissingError({ missing }));
|
|
168
|
+
if (mismatched.length > 0) return yield* Effect.fail(new FrozenVersionIdMismatchError({ mismatched }));
|
|
89
169
|
const targets = config.schemas.map((schema) => schema.target);
|
|
90
170
|
const checks = yield* SchemaPipeline.check(targets, pipelineOptions);
|
|
91
171
|
const gateFailed = checks.some((check) => check.blocked);
|
|
@@ -131,7 +211,14 @@ var Runner = class {
|
|
|
131
211
|
});
|
|
132
212
|
const entries = config.schemas.flatMap((schema) => schema.catalog !== void 0 ? [schema.catalog] : []);
|
|
133
213
|
let catalog;
|
|
134
|
-
if (entries.length
|
|
214
|
+
if (entries.length === 0) {
|
|
215
|
+
const info = yield* orNone(fs.stat(config.catalogPath));
|
|
216
|
+
if (Option.isSome(info)) catalog = {
|
|
217
|
+
path: config.catalogPath,
|
|
218
|
+
entries: 0,
|
|
219
|
+
outcome: "orphaned"
|
|
220
|
+
};
|
|
221
|
+
} else {
|
|
135
222
|
const text = yield* CanonicalJson.serialize(entries.map((entry) => Schema.encodeSync(CatalogEntry)(entry)));
|
|
136
223
|
const existing = yield* orNone(fs.readFileString(config.catalogPath));
|
|
137
224
|
const same = Option.isSome(existing) && parsesEqual(existing.value, text);
|
|
@@ -161,4 +248,4 @@ var Runner = class {
|
|
|
161
248
|
};
|
|
162
249
|
|
|
163
250
|
//#endregion
|
|
164
|
-
export { FrozenVersionMissingError, Runner };
|
|
251
|
+
export { FrozenVersionIdMismatchError, FrozenVersionMissingError, Runner };
|
package/cli/execute.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import { AjvValidator } from "../AjvValidator.js";
|
|
1
2
|
import { ConfigLoader } from "../ConfigLoader.js";
|
|
2
3
|
import { Report } from "../Report.js";
|
|
3
4
|
import { Runner } from "../Runner.js";
|
|
4
5
|
import { StepSummary } from "../StepSummary.js";
|
|
5
|
-
import {
|
|
6
|
+
import { SchemaFile } from "@effected/schemastore";
|
|
6
7
|
import { Console, Effect, Option, Schema } from "effect";
|
|
7
|
-
import {
|
|
8
|
+
import { CliRuntime } from "@effected/cli";
|
|
8
9
|
|
|
9
10
|
//#region src/cli/execute.ts
|
|
10
11
|
const DriftedSchema = Schema.Struct({
|
|
@@ -111,7 +112,10 @@ const execute = Effect.fn("schemastore.execute")(function* (mode, input, deps) {
|
|
|
111
112
|
mode,
|
|
112
113
|
configPath: loaded.path,
|
|
113
114
|
...drift
|
|
114
|
-
}).pipe(Effect.provide(SchemaFile.layer), Effect.provide(deps.validator ??
|
|
115
|
+
}).pipe(Effect.provide(SchemaFile.layer), Effect.provide(deps.validator ?? AjvValidator.layer), Effect.catchTags({
|
|
116
|
+
FrozenVersionMissingError: (error) => Effect.fail(CliRuntime.reported(error, 1)),
|
|
117
|
+
FrozenVersionIdMismatchError: (error) => Effect.fail(CliRuntime.reported(error, 1))
|
|
118
|
+
}));
|
|
115
119
|
yield* emit(report, input.format);
|
|
116
120
|
yield* StepSummary.append(Report.markdown(report));
|
|
117
121
|
if (report.gateFailed) {
|
|
@@ -128,7 +132,7 @@ const execute = Effect.fn("schemastore.execute")(function* (mode, input, deps) {
|
|
|
128
132
|
return yield* Effect.fail(CliRuntime.reported(new DriftError({ drifted }), 1));
|
|
129
133
|
}
|
|
130
134
|
if (mode === "check") {
|
|
131
|
-
const count = report.schemas.filter((schema) => schema.outcome === "would-write").length + (report.catalog?.outcome === "would-write" ? 1 : 0);
|
|
135
|
+
const count = report.schemas.filter((schema) => schema.outcome === "would-write").length + (report.catalog?.outcome === "would-write" || report.catalog?.outcome === "orphaned" ? 1 : 0);
|
|
132
136
|
if (count > 0) return yield* Effect.fail(CliRuntime.reported(new StaleError({ count }), 1));
|
|
133
137
|
}
|
|
134
138
|
});
|
package/cli/program.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ConfigLoadError } from "../ConfigLoader.js";
|
|
2
2
|
import { makeCommands } from "./root.js";
|
|
3
|
-
import { CliLogger, CliRuntime } from "@effected/cli";
|
|
4
3
|
import { Effect } from "effect";
|
|
4
|
+
import { CliLogger, CliRuntime } from "@effected/cli";
|
|
5
5
|
import { Command } from "effect/unstable/cli";
|
|
6
6
|
|
|
7
7
|
//#region src/cli/program.ts
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { SchemaValidator } from "@effected/schemastore";
|
|
2
|
+
import { Layer } from "effect";
|
|
3
|
+
//#region src/AjvValidator.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* The shipped `SchemaValidator` implementation: ajv strict mode over the
|
|
6
|
+
* Draft-07 meta-schema, with every declared `KeywordFamilies` keyword and
|
|
7
|
+
* the standard `ajv-formats` vocabulary registered.
|
|
8
|
+
*
|
|
9
|
+
* @remarks
|
|
10
|
+
* Lives in the CLI, not the library, so `ajv` is a cost only the command
|
|
11
|
+
* pays: `@effected/schemastore` owns the `SchemaValidator` contract
|
|
12
|
+
* and its doubles, an application that imports it at runtime never pulls an
|
|
13
|
+
* engine, and the `schemastore` command composes this layer at its edge. It
|
|
14
|
+
* is exported for a program that drives `SchemaPipeline` itself and wants
|
|
15
|
+
* the same verdict the command gives.
|
|
16
|
+
*
|
|
17
|
+
* `validate` checks the document against the Draft-07 meta-schema and then
|
|
18
|
+
* compiles it, reporting BOTH as `ValidationFinding` values: meta-schema
|
|
19
|
+
* failures keep ajv's structured `instancePath` and `keyword`, while a
|
|
20
|
+
* rejection ajv raises by *throwing* becomes a root-pathed finding — both a
|
|
21
|
+
* strict-mode compile failure and a declared keyword whose NAME ajv's own
|
|
22
|
+
* grammar (`/^[a-z_$][a-z0-9_$:-]*$/i`) refuses, such as an `x-ai-*` key
|
|
23
|
+
* carrying a dot or a space. The error channel stays reserved for the engine
|
|
24
|
+
* failing as a mechanism (`SchemaValidatorError`).
|
|
25
|
+
*
|
|
26
|
+
* `strict` defaults to `true` — SchemaStore's gate. Each call builds its own
|
|
27
|
+
* ajv instance, so documents sharing an `$id` never collide. Registering the
|
|
28
|
+
* declared families keeps the engine's verdict consistent with
|
|
29
|
+
* `DocumentLint`'s through the same predicate, so the two cannot drift; the
|
|
30
|
+
* plugin's `formatMaximum` / `formatMinimum` limit keywords are deliberately
|
|
31
|
+
* NOT registered, because the lint answers those as unknown keywords.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* ```ts
|
|
35
|
+
* import { SchemaValidator } from "@effected/schemastore";
|
|
36
|
+
* import { AjvValidator } from "@effected/schemastore-cli";
|
|
37
|
+
* import { Effect } from "effect";
|
|
38
|
+
*
|
|
39
|
+
* const program = Effect.gen(function* () {
|
|
40
|
+
* const validator = yield* SchemaValidator;
|
|
41
|
+
* return yield* validator.validate({ type: "object" });
|
|
42
|
+
* });
|
|
43
|
+
*
|
|
44
|
+
* Effect.runPromise(Effect.provide(program, AjvValidator.layer));
|
|
45
|
+
* // => []
|
|
46
|
+
* ```
|
|
47
|
+
*
|
|
48
|
+
* @public
|
|
49
|
+
*/
|
|
50
|
+
export declare class AjvValidator {
|
|
51
|
+
private constructor();
|
|
52
|
+
/** The engine, as a `SchemaValidator` layer. */
|
|
53
|
+
static readonly layer: Layer.Layer<SchemaValidator>;
|
|
54
|
+
}
|
|
55
|
+
//#endregion
|
|
56
|
+
//# sourceMappingURL=index.d.ts.map
|
package/index.js
ADDED
package/main.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { loggerLayer, program } from "./cli/program.js";
|
|
2
|
+
import { Effect } from "effect";
|
|
2
3
|
import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
|
|
3
4
|
import * as NodeServices from "@effect/platform-node/NodeServices";
|
|
4
5
|
import { CliRuntime } from "@effected/cli";
|
|
5
|
-
import { Effect } from "effect";
|
|
6
6
|
import { CliError } from "effect/unstable/cli";
|
|
7
7
|
|
|
8
8
|
//#region src/main.ts
|
|
@@ -15,7 +15,7 @@ const render = (error) => CliError.isCliError(error) && error._tag === "ShowHelp
|
|
|
15
15
|
const main = () => {
|
|
16
16
|
const run = program(process.argv.slice(2), {
|
|
17
17
|
cwd: process.cwd(),
|
|
18
|
-
version: "0.
|
|
18
|
+
version: "0.12.0"
|
|
19
19
|
}).pipe(Effect.provide(NodeServices.layer), CliRuntime.reportFailures({
|
|
20
20
|
exitCode: 3,
|
|
21
21
|
render
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effected/schemastore-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.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": [
|
|
@@ -29,6 +29,11 @@
|
|
|
29
29
|
"sideEffects": false,
|
|
30
30
|
"type": "module",
|
|
31
31
|
"exports": {
|
|
32
|
+
".": {
|
|
33
|
+
"types": "./index.d.ts",
|
|
34
|
+
"import": "./index.js",
|
|
35
|
+
"default": "./index.js"
|
|
36
|
+
},
|
|
32
37
|
"./package.json": "./package.json"
|
|
33
38
|
},
|
|
34
39
|
"bin": {
|
|
@@ -37,10 +42,12 @@
|
|
|
37
42
|
"dependencies": {
|
|
38
43
|
"@effect/platform-node": "4.0.0-rc.115",
|
|
39
44
|
"@effected/cli": "^0.5.1",
|
|
45
|
+
"ajv": "^8.20.0",
|
|
46
|
+
"ajv-formats": "^3.0.1",
|
|
40
47
|
"jiti": "^2.6.0"
|
|
41
48
|
},
|
|
42
49
|
"peerDependencies": {
|
|
43
|
-
"@effected/schemastore": "0.
|
|
50
|
+
"@effected/schemastore": "0.12.0",
|
|
44
51
|
"effect": "4.0.0-rc.115"
|
|
45
52
|
},
|
|
46
53
|
"engines": {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// This file is read by tools that parse documentation comments conforming to the TSDoc standard.
|
|
2
|
+
// It should be published with your NPM package. It should not be tracked by Git.
|
|
3
|
+
{
|
|
4
|
+
"tsdocVersion": "0.12",
|
|
5
|
+
"toolPackages": [
|
|
6
|
+
{
|
|
7
|
+
"packageName": "@microsoft/api-extractor",
|
|
8
|
+
"packageVersion": "7.59.1"
|
|
9
|
+
}
|
|
10
|
+
]
|
|
11
|
+
}
|