@effected/schemastore-cli 0.10.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 +35 -36
- package/README.md +88 -49
- package/Report.js +46 -28
- package/Runner.js +187 -53
- package/cli/execute.js +14 -10
- package/cli/flags.js +2 -2
- 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, 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
|
|
@@ -19,8 +19,9 @@ searched: Schema.Array(Schema.String) }) {
|
|
|
19
19
|
/**
|
|
20
20
|
* The config file exists but could not be turned into a `SchemastoreConfig`:
|
|
21
21
|
* the module threw on import, its default export is not a `defineConfig(...)`
|
|
22
|
-
* value,
|
|
23
|
-
*
|
|
22
|
+
* value, `outputDir`/`catalogPath` is not a string, a `schemas` element is
|
|
23
|
+
* not resolved-schema-shaped (or its `target`, `catalog`, or a `frozen`
|
|
24
|
+
* entry is not shaped), or two outputs resolve to one absolute path.
|
|
24
25
|
*
|
|
25
26
|
* @public
|
|
26
27
|
*/
|
|
@@ -34,24 +35,22 @@ var ConfigLoadError = class extends Schema.TaggedError()("ConfigLoadError", {
|
|
|
34
35
|
};
|
|
35
36
|
const jitiImport = (path) => createJiti(path, { interopDefault: true }).import(path);
|
|
36
37
|
const describeCause = (cause) => cause instanceof Error ? cause.stack ?? cause.message : String(cause);
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
const config = record !== void 0 && typeof record.config === "object" && record.config !== null ? record.config : void 0;
|
|
49
|
-
if (config === void 0 || typeof config.path !== "string") return `catalog[${index}] is not a catalog entry (missing config.path)`;
|
|
38
|
+
const isTargetShaped = (target) => Predicate.isObject(target) && Schema.isSchema(target.schema) && typeof target.$id === "string" && typeof target.path === "string" && typeof target.published === "boolean";
|
|
39
|
+
const isCatalogEntryShaped = (catalog) => Predicate.isObject(catalog) && typeof catalog.name === "string" && typeof catalog.description === "string" && Array.isArray(catalog.fileMatch) && typeof catalog.url === "string";
|
|
40
|
+
const describeMalformed = (config) => {
|
|
41
|
+
if (typeof config.outputDir !== "string") return "outputDir is not a string";
|
|
42
|
+
if (typeof config.catalogPath !== "string") return "catalogPath is not a string";
|
|
43
|
+
if (!Array.isArray(config.schemas)) return "schemas is not an array";
|
|
44
|
+
for (const [index, schema] of config.schemas.entries()) {
|
|
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
|
+
if (!isTargetShaped(schema.target)) return `schemas[${index}].target is not a SchemaTarget (missing schema/$id/path/published)`;
|
|
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.$id !== "string" || typeof frozen.url !== "string") return `schemas[${index}].frozen[${j}] is not a frozen version (missing version/path/$id/url)`;
|
|
50
49
|
}
|
|
51
50
|
};
|
|
52
51
|
const describeDuplicatePath = (config) => {
|
|
53
52
|
const seen = /* @__PURE__ */ new Set();
|
|
54
|
-
const paths = [...config.schemas.
|
|
53
|
+
const paths = [...config.schemas.flatMap((schema) => [schema.target.path, ...schema.frozen.map((f) => f.path)]), config.catalogPath];
|
|
55
54
|
for (const p of paths) {
|
|
56
55
|
if (seen.has(p)) return `output path "${p}" is declared twice after resolution`;
|
|
57
56
|
seen.add(p);
|
|
@@ -100,25 +99,30 @@ var ConfigLoader = class ConfigLoader {
|
|
|
100
99
|
}
|
|
101
100
|
});
|
|
102
101
|
/**
|
|
103
|
-
* Resolve every relative `path` in the config (
|
|
104
|
-
*
|
|
105
|
-
*
|
|
102
|
+
* Resolve every relative `path` in the config (`outputDir`, `catalogPath`,
|
|
103
|
+
* each schema's current target, and every frozen predecessor) against
|
|
104
|
+
* `directory`; absolute paths are left alone. `defineConfig` already
|
|
105
|
+
* prefixes `outputDir` onto every target/frozen `path`, so resolving them
|
|
106
|
+
* against the config directory equals resolving against the resolved
|
|
107
|
+
* `outputDir`. The result keeps the `defineConfig` brand.
|
|
106
108
|
*/
|
|
107
109
|
static resolvePaths = Effect.fn("ConfigLoader.resolvePaths")(function* (config, directory) {
|
|
108
110
|
const path = yield* Path.Path;
|
|
109
111
|
const absolute = (p) => path.isAbsolute(p) ? p : path.resolve(directory, p);
|
|
110
112
|
return {
|
|
111
113
|
...config,
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
114
|
+
outputDir: absolute(config.outputDir),
|
|
115
|
+
catalogPath: absolute(config.catalogPath),
|
|
116
|
+
schemas: config.schemas.map((schema) => ({
|
|
117
|
+
...schema,
|
|
118
|
+
target: {
|
|
119
|
+
...schema.target,
|
|
120
|
+
path: absolute(schema.target.path)
|
|
121
|
+
},
|
|
122
|
+
frozen: schema.frozen.map((f) => ({
|
|
123
|
+
...f,
|
|
124
|
+
path: absolute(f.path)
|
|
125
|
+
}))
|
|
122
126
|
}))
|
|
123
127
|
};
|
|
124
128
|
});
|
|
@@ -144,16 +148,11 @@ var ConfigLoader = class ConfigLoader {
|
|
|
144
148
|
path: configPath,
|
|
145
149
|
reason: "default export is not a defineConfig(...) value from @effected/schemastore"
|
|
146
150
|
}));
|
|
147
|
-
const malformed =
|
|
151
|
+
const malformed = describeMalformed(exported);
|
|
148
152
|
if (malformed !== void 0) return yield* Effect.fail(new ConfigLoadError({
|
|
149
153
|
path: configPath,
|
|
150
154
|
reason: malformed
|
|
151
155
|
}));
|
|
152
|
-
const malformedCatalog = describeMalformedCatalog(exported.catalog);
|
|
153
|
-
if (malformedCatalog !== void 0) return yield* Effect.fail(new ConfigLoadError({
|
|
154
|
-
path: configPath,
|
|
155
|
-
reason: malformedCatalog
|
|
156
|
-
}));
|
|
157
156
|
const directory = path.dirname(configPath);
|
|
158
157
|
const config = yield* ConfigLoader.resolvePaths(exported, directory);
|
|
159
158
|
const duplicate = 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,64 +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:
|
|
39
36
|
|
|
40
37
|
```ts
|
|
41
|
-
|
|
42
|
-
import {
|
|
38
|
+
// src/schema/output.ts
|
|
39
|
+
import { HostedSchema } from "@effected/schemastore";
|
|
40
|
+
import { Schema } from "effect";
|
|
43
41
|
|
|
44
|
-
export
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
],
|
|
56
|
-
catalog: [
|
|
57
|
-
{
|
|
58
|
-
name: "silk-release-action",
|
|
59
|
-
description: "Structured output of the silk-release GitHub Action",
|
|
60
|
-
fileMatch: ["silk-release-output.json"],
|
|
61
|
-
baseUrl: "https://raw.githubusercontent.com/savvy-web/silk-release-action/main/schemas",
|
|
62
|
-
path: "schemas/catalog-entry.json",
|
|
63
|
-
},
|
|
64
|
-
],
|
|
65
|
-
drift: { policy: "semantic", onDrift: "error" },
|
|
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"]),
|
|
66
54
|
});
|
|
67
55
|
```
|
|
68
56
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
57
|
+
```ts
|
|
58
|
+
// schemastore.config.ts (also .mts, .js, .mjs; found by walking upward, or passed as the positional argument)
|
|
59
|
+
import { defineConfig } from "@effected/schemastore";
|
|
60
|
+
import { OutputSchemaIdentity, ReleaseOutput } from "./src/schema/output.js";
|
|
72
61
|
|
|
73
|
-
|
|
62
|
+
export default defineConfig({
|
|
63
|
+
// Relative paths resolve against this file's directory.
|
|
64
|
+
outputDir: "schemas",
|
|
65
|
+
schemas: {
|
|
66
|
+
[OutputSchemaIdentity.name]: { schema: ReleaseOutput, hosted: OutputSchemaIdentity },
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
```
|
|
74
70
|
|
|
75
71
|
```json
|
|
76
72
|
{
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
73
|
+
"scripts": {
|
|
74
|
+
"schema:build": "schemastore build",
|
|
75
|
+
"schema:check": "schemastore check"
|
|
76
|
+
}
|
|
81
77
|
}
|
|
82
78
|
```
|
|
83
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
|
+
|
|
84
108
|
## Commands
|
|
85
109
|
|
|
86
110
|
```text
|
|
@@ -88,21 +112,36 @@ schemastore build [config] [--drift=strict|semantic|allow] [--on-drift=error|war
|
|
|
88
112
|
schemastore check [config] [--drift=strict|semantic|allow] [--on-drift=error|warn] [--force] [--format=human|json]
|
|
89
113
|
```
|
|
90
114
|
|
|
91
|
-
-
|
|
92
|
-
- `
|
|
93
|
-
-
|
|
94
|
-
- `--
|
|
95
|
-
- When `GITHUB_STEP_SUMMARY` is set, both commands append a markdown
|
|
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
|
+
```
|
|
96
135
|
|
|
97
|
-
|
|
136
|
+
Findings come back as values; the error channel carries `SchemaValidatorError` only when the engine fails as a mechanism.
|
|
98
137
|
|
|
99
138
|
## Exit codes
|
|
100
139
|
|
|
101
140
|
| code | meaning |
|
|
102
141
|
| ---- | -------------------------------------------------------------------------- |
|
|
103
142
|
| 0 | success, including drift under `onDrift: warn` |
|
|
104
|
-
| 1 | drift under `onDrift: error` (
|
|
105
|
-
| 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 |
|
|
106
145
|
| 3 | infrastructure failure |
|
|
107
146
|
| 64 | usage error |
|
|
108
147
|
|
package/Report.js
CHANGED
|
@@ -3,32 +3,37 @@ const findingLine = (finding) => ` ${finding.label} at "${finding.path}": ${fin
|
|
|
3
3
|
const blockingCount = (findings) => findings.filter((finding) => finding.severity === "warning").length;
|
|
4
4
|
const suggestionClause = (schema) => schema.nextVersion !== void 0 && schema.nextVersion !== schema.version ? ` → suggest ${schema.nextVersion}` : "";
|
|
5
5
|
const publishedClause = (schema) => schema.version !== void 0 ? ` at published ${schema.version}` : "";
|
|
6
|
+
const policyClause = (schema, report) => report.policy === void 0 ? ` [policy ${schema.policy}]` : "";
|
|
7
|
+
const frozenClause = (schema) => schema.frozen.length > 0 ? ` (frozen: ${schema.frozen.join(", ")})` : "";
|
|
6
8
|
const schemaLines = (schema, report) => {
|
|
9
|
+
const suffix = `${policyClause(schema, report)}${frozenClause(schema)}`;
|
|
7
10
|
switch (schema.outcome) {
|
|
8
|
-
case "written": return [`written (${schema.change}) ${schema.path}`];
|
|
9
|
-
case "unchanged": return [`unchanged ${schema.path}`];
|
|
10
|
-
case "would-write": return [`would write (${schema.change}) ${schema.path}`];
|
|
11
|
-
case "drift": return [`DRIFT ${schema.change}${publishedClause(schema)}${suggestionClause(schema)} — ${schema.path}`];
|
|
12
|
-
case "held": return [`held (${report.gateFailed ? "gate failed elsewhere" : "drift elsewhere"}) ${schema.path}`];
|
|
13
|
-
case "gate-failed": return [`GATE FAILED ${schema.path} (${blockingCount(schema.findings)} blocking finding(s))`, ...schema.findings.map(findingLine)];
|
|
11
|
+
case "written": return [`written (${schema.change}) ${schema.path}${suffix}`];
|
|
12
|
+
case "unchanged": return [`unchanged ${schema.path}${suffix}`];
|
|
13
|
+
case "would-write": return [`would write (${schema.change}) ${schema.path}${suffix}`];
|
|
14
|
+
case "drift": return [`DRIFT ${schema.change}${publishedClause(schema)}${suggestionClause(schema)} — ${schema.path}${suffix}`];
|
|
15
|
+
case "held": return [`held (${report.gateFailed ? "gate failed elsewhere" : "drift elsewhere"}) ${schema.path}${suffix}`];
|
|
16
|
+
case "gate-failed": return [`GATE FAILED ${schema.path}${suffix} (${blockingCount(schema.findings)} blocking finding(s))`, ...schema.findings.map(findingLine)];
|
|
14
17
|
default: return schema.outcome;
|
|
15
18
|
}
|
|
16
19
|
};
|
|
17
20
|
const catalogLine = (entry) => {
|
|
18
21
|
switch (entry.outcome) {
|
|
19
|
-
case "written": return `written catalog ${entry.path}`;
|
|
20
|
-
case "unchanged": return `unchanged catalog ${entry.path}`;
|
|
21
|
-
case "would-write": return `would write catalog ${entry.path}`;
|
|
22
|
-
case "held": return `held catalog ${entry.path}`;
|
|
22
|
+
case "written": return `written catalog ${entry.path} (${entry.entries} entries)`;
|
|
23
|
+
case "unchanged": return `unchanged catalog ${entry.path} (${entry.entries} entries)`;
|
|
24
|
+
case "would-write": return `would write catalog ${entry.path} (${entry.entries} entries)`;
|
|
25
|
+
case "held": return `held catalog ${entry.path} (${entry.entries} entries)`;
|
|
26
|
+
case "orphaned": return `orphaned catalog ${entry.path} (no schema declares a catalog)`;
|
|
23
27
|
default: return entry.outcome;
|
|
24
28
|
}
|
|
25
29
|
};
|
|
30
|
+
const driftClause = (report) => `drift ${report.policy !== void 0 ? `${report.policy} (flag)` : "per schema (config)"}, on-drift ${report.onDrift}`;
|
|
26
31
|
const summaryLine = (report) => {
|
|
27
32
|
const written = report.schemas.filter((schema) => schema.outcome === "written").length;
|
|
28
33
|
const unchanged = report.schemas.filter((schema) => schema.outcome === "unchanged").length;
|
|
29
34
|
const drift = report.schemas.filter((schema) => schema.verdict === "drift").length;
|
|
30
35
|
const gateFailed = report.schemas.filter((schema) => schema.outcome === "gate-failed").length;
|
|
31
|
-
return `${report.schemas.length} schema(s): ${written} written, ${unchanged} unchanged, ${drift} drift, ${gateFailed} gate failed —
|
|
36
|
+
return `${report.schemas.length} schema(s): ${written} written, ${unchanged} unchanged, ${drift} drift, ${gateFailed} gate failed — ${driftClause(report)}`;
|
|
32
37
|
};
|
|
33
38
|
const warningLine = (schema, report) => `warning: DRIFT ${schema.change}${publishedClause(schema)} ${report.mode === "build" ? "written" : "would write"} under --on-drift=warn — ${schema.path}`;
|
|
34
39
|
const tableRow = (columns) => `| ${columns.join(" | ")} |`;
|
|
@@ -52,7 +57,7 @@ var Report = class {
|
|
|
52
57
|
static human(report) {
|
|
53
58
|
const lines = [];
|
|
54
59
|
for (const schema of report.schemas) lines.push(...schemaLines(schema, report));
|
|
55
|
-
|
|
60
|
+
if (report.catalog !== void 0) lines.push(catalogLine(report.catalog));
|
|
56
61
|
lines.push(summaryLine(report));
|
|
57
62
|
return lines;
|
|
58
63
|
}
|
|
@@ -62,7 +67,7 @@ var Report = class {
|
|
|
62
67
|
* would be) written, so there is no drift to warn about.
|
|
63
68
|
*/
|
|
64
69
|
static warnings(report) {
|
|
65
|
-
if (report.
|
|
70
|
+
if (report.onDrift !== "warn" || report.gateFailed) return [];
|
|
66
71
|
return report.schemas.filter((schema) => schema.verdict === "drift").map((schema) => warningLine(schema, report));
|
|
67
72
|
}
|
|
68
73
|
/** One JSON document, stable key order. */
|
|
@@ -71,20 +76,21 @@ var Report = class {
|
|
|
71
76
|
mode: report.mode,
|
|
72
77
|
configPath: report.configPath,
|
|
73
78
|
drift: {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
source: report.drift.source
|
|
79
|
+
onDrift: report.onDrift,
|
|
80
|
+
...report.policy !== void 0 ? { policy: report.policy } : {}
|
|
77
81
|
},
|
|
78
82
|
schemas: report.schemas.map((schema) => ({
|
|
79
83
|
$id: schema.$id,
|
|
80
84
|
path: schema.path,
|
|
81
|
-
|
|
85
|
+
name: schema.name,
|
|
82
86
|
...schema.version !== void 0 ? { version: schema.version } : {},
|
|
83
87
|
published: schema.published,
|
|
84
88
|
change: schema.change,
|
|
85
89
|
verdict: schema.verdict,
|
|
90
|
+
policy: schema.policy,
|
|
86
91
|
outcome: schema.outcome,
|
|
87
92
|
...schema.nextVersion !== void 0 ? { nextVersion: schema.nextVersion } : {},
|
|
93
|
+
...schema.frozen.length > 0 ? { frozen: schema.frozen } : {},
|
|
88
94
|
findings: schema.findings.map((finding) => ({
|
|
89
95
|
source: finding.source,
|
|
90
96
|
severity: finding.severity,
|
|
@@ -93,11 +99,11 @@ var Report = class {
|
|
|
93
99
|
message: finding.message
|
|
94
100
|
}))
|
|
95
101
|
})),
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
outcome:
|
|
100
|
-
}
|
|
102
|
+
...report.catalog !== void 0 ? { catalog: {
|
|
103
|
+
path: report.catalog.path,
|
|
104
|
+
entries: report.catalog.entries,
|
|
105
|
+
outcome: report.catalog.outcome
|
|
106
|
+
} } : {},
|
|
101
107
|
drifted: report.drifted,
|
|
102
108
|
gateFailed: report.gateFailed,
|
|
103
109
|
wrote: report.wrote
|
|
@@ -110,6 +116,7 @@ var Report = class {
|
|
|
110
116
|
lines.push(tableRow([
|
|
111
117
|
"schema",
|
|
112
118
|
"version",
|
|
119
|
+
"frozen",
|
|
113
120
|
"published",
|
|
114
121
|
"change",
|
|
115
122
|
"outcome"
|
|
@@ -118,26 +125,37 @@ var Report = class {
|
|
|
118
125
|
"---",
|
|
119
126
|
"---",
|
|
120
127
|
"---",
|
|
128
|
+
"---",
|
|
121
129
|
"---"
|
|
122
130
|
]));
|
|
123
131
|
for (const schema of report.schemas) lines.push(tableRow([
|
|
124
|
-
schema.name
|
|
132
|
+
schema.name,
|
|
125
133
|
schema.version ?? "",
|
|
134
|
+
schema.frozen.join(", "),
|
|
126
135
|
schema.published ? "yes" : "no",
|
|
127
136
|
schema.change,
|
|
128
137
|
schema.outcome
|
|
129
138
|
]));
|
|
130
|
-
if (report.catalog
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
139
|
+
if (report.catalog !== void 0) lines.push("", tableRow([
|
|
140
|
+
"catalog",
|
|
141
|
+
"entries",
|
|
142
|
+
"outcome"
|
|
143
|
+
]), tableRow([
|
|
144
|
+
"---",
|
|
145
|
+
"---",
|
|
146
|
+
"---"
|
|
147
|
+
]), tableRow([
|
|
148
|
+
report.catalog.path,
|
|
149
|
+
String(report.catalog.entries),
|
|
150
|
+
report.catalog.outcome
|
|
151
|
+
]));
|
|
134
152
|
lines.push("");
|
|
135
153
|
if (report.gateFailed) {
|
|
136
154
|
const failed = report.schemas.filter((schema) => schema.outcome === "gate-failed").length;
|
|
137
155
|
lines.push(`**Gate:** ${failed} schema(s) failed`);
|
|
138
156
|
} else if (report.drifted) {
|
|
139
157
|
const drifted = report.schemas.filter((schema) => schema.verdict === "drift").length;
|
|
140
|
-
lines.push(`**Drift:** ${drifted} schema(s) drifted
|
|
158
|
+
lines.push(`**Drift:** ${drifted} schema(s) drifted — ${driftClause(report)}`);
|
|
141
159
|
} else lines.push("**Drift:** none");
|
|
142
160
|
lines.push("");
|
|
143
161
|
return lines.join("\n");
|
package/Runner.js
CHANGED
|
@@ -1,9 +1,86 @@
|
|
|
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
|
+
/**
|
|
6
|
+
* Indicates that one or more schemas advertise a version label (via
|
|
7
|
+
* {@link ResolvedSchema.frozen}) whose file is missing on disk. Raised by
|
|
8
|
+
* {@link Runner.run} before anything is generated — a build must never
|
|
9
|
+
* publish a catalog pointing a frozen label at a 404. Every miss is
|
|
10
|
+
* collected and reported at once, not just the first.
|
|
11
|
+
*
|
|
12
|
+
* @public
|
|
13
|
+
*/
|
|
14
|
+
var FrozenVersionMissingError = class extends Schema.TaggedError()("FrozenVersionMissingError", {
|
|
15
|
+
/** One entry per schema/version whose frozen file is missing or not a file. */
|
|
16
|
+
missing: Schema.Array(Schema.Struct({
|
|
17
|
+
/** The schema's key in the config. */
|
|
18
|
+
name: Schema.String,
|
|
19
|
+
/** The missing frozen version label. */
|
|
20
|
+
version: Schema.String,
|
|
21
|
+
/** The path that does not exist. */
|
|
22
|
+
path: Schema.String
|
|
23
|
+
})) }) {
|
|
24
|
+
get message() {
|
|
25
|
+
const lines = this.missing.map((entry) => ` schema "${entry.name}" version ${entry.version}: ${entry.path}`);
|
|
26
|
+
return `${this.missing.length} frozen version(s) advertised but not on disk; nothing was written.\n${lines.join("\n")}`;
|
|
27
|
+
}
|
|
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
|
+
};
|
|
5
68
|
const pipelineOptions = { contractChanges: "allow" };
|
|
6
|
-
const
|
|
69
|
+
const orNone = (read) => read.pipe(Effect.map(Option.some), Effect.catchIf((error) => error.reason._tag === "NotFound", () => Effect.succeed(Option.none())));
|
|
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
|
+
};
|
|
7
84
|
const parsesEqual = (existing, text) => {
|
|
8
85
|
try {
|
|
9
86
|
return CanonicalJson.equals(JSON.parse(existing), JSON.parse(text));
|
|
@@ -12,25 +89,43 @@ const parsesEqual = (existing, text) => {
|
|
|
12
89
|
}
|
|
13
90
|
};
|
|
14
91
|
/**
|
|
15
|
-
* The shared `build` / `check` walk:
|
|
16
|
-
* {@link DriftPolicy} over
|
|
17
|
-
* `SchemaPipeline.run` only when
|
|
92
|
+
* The shared `build` / `check` walk: verify every advertised frozen version
|
|
93
|
+
* exists, classify every current target through {@link DriftPolicy} over
|
|
94
|
+
* `SchemaPipeline.check`, then write through `SchemaPipeline.run` only when
|
|
95
|
+
* nothing is refused.
|
|
18
96
|
*
|
|
19
97
|
* @remarks
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
98
|
+
* **The frozen check runs first, before anything is generated.** Every
|
|
99
|
+
* schema's {@link ResolvedSchema.frozen} versions are walked, and every miss
|
|
100
|
+
* is reported at once: a build fails typed with
|
|
101
|
+
* {@link FrozenVersionMissingError} listing every label with no file on disk
|
|
102
|
+
* — nothing is written — a catalog that points a label at a 404 is a worse
|
|
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.
|
|
30
108
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
109
|
+
* **Drift is classified per schema, under that schema's own
|
|
110
|
+
* {@link ResolvedSchema.drift} tolerance — unless `options.policy` is set,
|
|
111
|
+
* in which case it overrides every schema's own for this run** (the `--drift`
|
|
112
|
+
* / `--force` flags). A build writes NOTHING when any schema fails its gate,
|
|
113
|
+
* or when any schema drifts under `onDrift: "error"` — a partial write would
|
|
114
|
+
* leave a repository half-bumped. Every otherwise-writable schema then
|
|
115
|
+
* reports `held`, so a reader sees why a clean schema was not written — in
|
|
116
|
+
* both modes, since `check` reports what `build` would do under the same
|
|
117
|
+
* flags. Both modes share one `SchemaFile`; the single `writing` predicate
|
|
118
|
+
* (`mode === "build" && !refused`) gates every write, schemas and the
|
|
119
|
+
* catalog file alike. Under `onDrift: "warn"` drifting schemas are written
|
|
120
|
+
* and keep their `"drift"` verdict for the renderer to shout about.
|
|
121
|
+
*
|
|
122
|
+
* **Every catalog entry the config declares lands in ONE file** at
|
|
123
|
+
* `config.catalogPath` — never one file per schema — serialized canonically
|
|
124
|
+
* and compared by parsed content against the file on disk, written only
|
|
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.
|
|
34
129
|
*
|
|
35
130
|
* @public
|
|
36
131
|
*/
|
|
@@ -39,79 +134,118 @@ var Runner = class {
|
|
|
39
134
|
static run = Effect.fn("Runner.run")(function* (config, options) {
|
|
40
135
|
const fs = yield* FileSystem.FileSystem;
|
|
41
136
|
const path = yield* Path.Path;
|
|
42
|
-
const
|
|
137
|
+
const missing = [];
|
|
138
|
+
const mismatched = [];
|
|
139
|
+
for (const schema of config.schemas) for (const frozen of schema.frozen) {
|
|
140
|
+
const info = yield* orNone(fs.stat(frozen.path));
|
|
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({
|
|
159
|
+
name: schema.name,
|
|
160
|
+
version: frozen.version,
|
|
161
|
+
path: frozen.path,
|
|
162
|
+
expected: frozen.$id,
|
|
163
|
+
actual: declared.$id,
|
|
164
|
+
reason: "mismatch"
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
if (missing.length > 0) return yield* Effect.fail(new FrozenVersionMissingError({ missing }));
|
|
168
|
+
if (mismatched.length > 0) return yield* Effect.fail(new FrozenVersionIdMismatchError({ mismatched }));
|
|
169
|
+
const targets = config.schemas.map((schema) => schema.target);
|
|
170
|
+
const checks = yield* SchemaPipeline.check(targets, pipelineOptions);
|
|
43
171
|
const gateFailed = checks.some((check) => check.blocked);
|
|
44
172
|
const classified = checks.map((check, i) => {
|
|
45
|
-
const
|
|
173
|
+
const schema = config.schemas[i];
|
|
174
|
+
const target = schema.target;
|
|
175
|
+
const policy = options.policy ?? schema.drift;
|
|
46
176
|
return {
|
|
177
|
+
schema,
|
|
47
178
|
target,
|
|
48
179
|
check,
|
|
49
180
|
verdict: DriftPolicy.classify({
|
|
50
181
|
published: target.published,
|
|
51
182
|
change: check.change
|
|
52
|
-
},
|
|
183
|
+
}, policy),
|
|
184
|
+
policy,
|
|
53
185
|
nextVersion: target.version !== void 0 && check.change === "contract" && SchemaVersioning.isPinned(target.version) ? SchemaVersioning.next(target.version, "contract") : void 0
|
|
54
186
|
};
|
|
55
187
|
});
|
|
56
188
|
const drifted = classified.some((entry) => entry.verdict === "drift");
|
|
57
|
-
const refused = gateFailed || drifted && options.
|
|
189
|
+
const refused = gateFailed || drifted && options.onDrift === "error";
|
|
58
190
|
const writing = options.mode === "build" && !refused;
|
|
59
|
-
const written = writing ? yield* SchemaPipeline.run(
|
|
191
|
+
const written = writing ? yield* SchemaPipeline.run(targets, pipelineOptions).pipe(Effect.catchTags({
|
|
60
192
|
SchemaGateError: (error) => Effect.die(error),
|
|
61
193
|
SchemaContractChangeError: (error) => Effect.die(error)
|
|
62
194
|
})) : void 0;
|
|
63
|
-
const schemas = classified.map(({ target, check, verdict, nextVersion }, i) => {
|
|
64
|
-
const outcome = check.blocked ? "gate-failed" : written !== void 0 ? written[i].outcome : verdict === "drift" ? "drift" :
|
|
195
|
+
const schemas = classified.map(({ schema, target, check, verdict, policy, nextVersion }, i) => {
|
|
196
|
+
const outcome = check.blocked ? "gate-failed" : written !== void 0 ? written[i].outcome : verdict === "drift" ? "drift" : pendingOutcome(check.wouldWrite, refused);
|
|
65
197
|
return {
|
|
66
198
|
$id: target.$id,
|
|
67
199
|
path: target.path,
|
|
200
|
+
name: schema.name,
|
|
68
201
|
published: target.published,
|
|
69
202
|
change: check.change,
|
|
70
203
|
verdict,
|
|
204
|
+
policy,
|
|
71
205
|
outcome,
|
|
72
206
|
findings: check.findings,
|
|
73
|
-
|
|
207
|
+
frozen: schema.frozen.map((frozen) => frozen.version),
|
|
74
208
|
...target.version !== void 0 ? { version: target.version } : {},
|
|
75
209
|
...nextVersion !== void 0 ? { nextVersion } : {}
|
|
76
210
|
};
|
|
77
211
|
});
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
yield* fs.
|
|
95
|
-
yield* fs.writeFileString(file, text);
|
|
96
|
-
catalog.push({
|
|
97
|
-
name,
|
|
98
|
-
path: file,
|
|
99
|
-
outcome: "written"
|
|
100
|
-
});
|
|
212
|
+
const entries = config.schemas.flatMap((schema) => schema.catalog !== void 0 ? [schema.catalog] : []);
|
|
213
|
+
let catalog;
|
|
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 {
|
|
222
|
+
const text = yield* CanonicalJson.serialize(entries.map((entry) => Schema.encodeSync(CatalogEntry)(entry)));
|
|
223
|
+
const existing = yield* orNone(fs.readFileString(config.catalogPath));
|
|
224
|
+
const same = Option.isSome(existing) && parsesEqual(existing.value, text);
|
|
225
|
+
const outcome = writing && !same ? "written" : pendingOutcome(!same, refused);
|
|
226
|
+
if (outcome === "written") {
|
|
227
|
+
yield* fs.makeDirectory(path.dirname(config.catalogPath), { recursive: true });
|
|
228
|
+
yield* fs.writeFileString(config.catalogPath, text);
|
|
101
229
|
}
|
|
230
|
+
catalog = {
|
|
231
|
+
path: config.catalogPath,
|
|
232
|
+
entries: entries.length,
|
|
233
|
+
outcome
|
|
234
|
+
};
|
|
102
235
|
}
|
|
103
236
|
return {
|
|
104
237
|
mode: options.mode,
|
|
105
238
|
configPath: options.configPath,
|
|
106
|
-
|
|
239
|
+
onDrift: options.onDrift,
|
|
240
|
+
...options.policy !== void 0 ? { policy: options.policy } : {},
|
|
107
241
|
schemas,
|
|
108
|
-
catalog,
|
|
242
|
+
...catalog !== void 0 ? { catalog } : {},
|
|
109
243
|
drifted,
|
|
110
244
|
gateFailed,
|
|
111
|
-
wrote: schemas.some((s) => s.outcome === "written") || catalog
|
|
245
|
+
wrote: schemas.some((s) => s.outcome === "written") || catalog?.outcome === "written"
|
|
112
246
|
};
|
|
113
247
|
});
|
|
114
248
|
};
|
|
115
249
|
|
|
116
250
|
//#endregion
|
|
117
|
-
export { 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({
|
|
@@ -67,11 +68,11 @@ var ConflictingFlagsError = class extends Schema.TaggedError()("ConflictingFlags
|
|
|
67
68
|
return `--force conflicts with --drift=${this.policy}: --force means --drift=allow`;
|
|
68
69
|
}
|
|
69
70
|
};
|
|
70
|
-
const effectiveDrift = (
|
|
71
|
+
const effectiveDrift = (config, input) => {
|
|
72
|
+
const forced = input.force ? "allow" : Option.getOrUndefined(input.drift);
|
|
71
73
|
return {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
source: input.force || Option.isSome(input.drift) || Option.isSome(input.onDrift) ? "flag" : "config"
|
|
74
|
+
onDrift: Option.getOrElse(input.onDrift, () => config.onDrift),
|
|
75
|
+
...forced !== void 0 ? { policy: forced } : {}
|
|
75
76
|
};
|
|
76
77
|
};
|
|
77
78
|
const emit = Effect.fn("schemastore.emit")(function* (report, format) {
|
|
@@ -105,13 +106,16 @@ const execute = Effect.fn("schemastore.execute")(function* (mode, input, deps) {
|
|
|
105
106
|
...Option.isSome(input.config) ? { explicit: input.config.value } : {},
|
|
106
107
|
...deps.importModule !== void 0 ? { importModule: deps.importModule } : {}
|
|
107
108
|
});
|
|
108
|
-
const drift = effectiveDrift(loaded.config
|
|
109
|
+
const drift = effectiveDrift(loaded.config, input);
|
|
109
110
|
if (input.force) yield* Effect.logWarning(`--force: drift policy is allow for this run; a published document ${mode === "check" ? "would be" : "may be"} rewritten in place, which breaks every consumer pinned to its URL.`);
|
|
110
111
|
const report = yield* Runner.run(loaded.config, {
|
|
111
112
|
mode,
|
|
112
113
|
configPath: loaded.path,
|
|
113
|
-
drift
|
|
114
|
-
}).pipe(Effect.provide(SchemaFile.layer), Effect.provide(deps.validator ??
|
|
114
|
+
...drift
|
|
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
|
|
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/flags.js
CHANGED
|
@@ -17,13 +17,13 @@ const driftFlag = Flag.Literals("drift", [
|
|
|
17
17
|
"strict",
|
|
18
18
|
"semantic",
|
|
19
19
|
"allow"
|
|
20
|
-
]).pipe(Flag.withDescription("Drift tolerance for published schemas; overrides
|
|
20
|
+
]).pipe(Flag.withDescription("Drift tolerance for published schemas; overrides every schema's drift"), Flag.optional);
|
|
21
21
|
/**
|
|
22
22
|
* `--on-drift`: what drift does, overriding the config.
|
|
23
23
|
*
|
|
24
24
|
* @public
|
|
25
25
|
*/
|
|
26
|
-
const onDriftFlag = Flag.Literals("on-drift", ["error", "warn"]).pipe(Flag.withDescription("What drift does: refuse every write (error) or write and warn; overrides
|
|
26
|
+
const onDriftFlag = Flag.Literals("on-drift", ["error", "warn"]).pipe(Flag.withDescription("What drift does: refuse every write (error) or write and warn; overrides the config's onDrift"), Flag.optional);
|
|
27
27
|
/**
|
|
28
28
|
* `--force`: shorthand for `--drift=allow`.
|
|
29
29
|
*
|
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
|
+
}
|