@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.
@@ -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
  [![Node.js %3E%3D24.11.0](https://img.shields.io/badge/Node.js-%3E%3D24.11.0-5fa04e.svg)](https://nodejs.org/)
6
6
  [![TypeScript 7.0](https://img.shields.io/badge/TypeScript-7.0-3178c6.svg)](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 command-line companion to [`@effected/schemastore`](https://www.npmjs.com/package/@effected/schemastore), which owns the pipeline; this package ships the plumbing every consumer used to write by hand — flag parsing, the contract gate, the drift test — once, as a `bin`.
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
- `@effected/schemastore` and `@effected/schemastore-cli` release together at one version; install them at the same version.
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
- npm install --save-dev @effected/schemastore-cli @effected/schemastore effect
27
+ pnpm add @effected/schemastore effect
28
+ pnpm add -D @effected/schemastore-cli
30
29
  ```
31
30
 
32
- ```bash
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
- Create `schemastore.config.ts` (also `.mts`, `.js`, `.mjs`). The CLI finds it by walking upward from the working directory, or takes its path as a positional argument. Relative `path` values resolve against the config file's directory.
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 { OkfitConfig } from "./src/config-schema.js";
60
+ import { OutputSchemaIdentity, ReleaseOutput } from "./src/schema/output.js";
43
61
 
44
62
  export default defineConfig({
45
- outputDir: "schemas",
46
- baseUrl: "schemastore",
47
- schemas: {
48
- okfit: {
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
- "scripts": {
71
- "schema:build": "schemastore build",
72
- "schema:check": "schemastore check"
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 advertised frozen version is checked for existence — a schema that advertises a label with nothing on disk fails with `FrozenVersionMissingError` (exit `1`) and nothing is written.
85
- - `build` generates every schema, runs the gates (structural lints and ajv strict mode), applies the drift policy, and writes what passes — content-compared, so unchanged files are untouched — along with the single `catalog.json` every declared catalog entry lands in.
86
- - `check` is the identical walk with no writes: it reports what `build` would do under the same flags and exits under the same conditions. It is the CI gate, so it also fails (exit `1`) whenever a build would write anything — a committed schema or catalog file that differs from what the config generates, or is missing, is stale; run `schemastore build` and commit the result.
87
- - `--drift` and `--on-drift` override the config's `drift` block for one run; `--force` is sugar for `--drift=allow` and nothing else — combined with an explicit non-`allow` `--drift` it is a usage error (exit 64), not a precedence question; `--force --drift=allow` is accepted.
88
- - `--format=json` emits one JSON document on stdout (config path, per-schema outcome and effective drift tolerance, the single catalog entry's outcome, and `drift: { onDrift, policy? }` — `policy` present only when a flag forced one tolerance over every schema's own); human text moves to stderr.
89
- - When `GITHUB_STEP_SUMMARY` is set, both commands append a markdown summary table.
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
- An unpublished schema is never drift: a contract change at a pinned but unpublished version rewrites the file in place.
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` (the error lists one line per drifting schema: `$id`, change, current and next version), a gate failure, a missing frozen version (`FrozenVersionMissingError`), or — for `check` — any document `build` would write |
99
- | 2 | config not found, failed to load, or failed `SchemastoreConfig` validation |
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. The report omits
70
- * `catalog` entirely when no schema declared one.
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") missing.push({
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 > 0) {
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 { CliRuntime } from "@effected/cli";
6
+ import { SchemaFile } from "@effected/schemastore";
6
7
  import { Console, Effect, Option, Schema } from "effect";
7
- import { SchemaFile, SchemaValidator } from "@effected/schemastore";
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 ?? SchemaValidator.layer), Effect.catchTag("FrozenVersionMissingError", (error) => Effect.fail(CliRuntime.reported(error, 1))));
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
@@ -0,0 +1,3 @@
1
+ import { AjvValidator } from "./AjvValidator.js";
2
+
3
+ export { AjvValidator };
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.11.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.11.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.11.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
+ }