@effected/schemastore-cli 0.11.0 → 0.13.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. So is a document left behind under an old derived name — an `appendVersion` flip or a `layout` change moved its path, and nothing claims the old file any more: both commands probe the sibling shapes (`<name>.json`, `<name>-<v>.json`, `<v>/<name>.json`, `<v>/<name>-<v>.json`) of every label the config still declares and report each one that exists as an orphaned document, failed by `check`, never deleted by `build`. Nothing else in `outputDir` is looked at, so sharing it with another config, a deploy folder, or the repository root is safe (unless two configs derive the same schema name and version under different layouts into it); a `name` change or a dropped label leaves a file the command cannot know about — delete those by hand.
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, the `orphaned` document paths when any, `drift: { onDrift, policy? }`); human text moves to stderr. When `GITHUB_STEP_SUMMARY` is set, both commands append a markdown table.
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 or an output nothing claims (an orphaned catalog file, or an orphaned document at a sibling shape of a derived path) |
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,9 +23,11 @@ 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
  };
30
+ const orphanedLine = (orphan) => `orphaned document ${orphan} (no target, frozen version, or catalog entry claims it — delete it by hand; build never will)`;
29
31
  const driftClause = (report) => `drift ${report.policy !== void 0 ? `${report.policy} (flag)` : "per schema (config)"}, on-drift ${report.onDrift}`;
30
32
  const summaryLine = (report) => {
31
33
  const written = report.schemas.filter((schema) => schema.outcome === "written").length;
@@ -57,6 +59,7 @@ var Report = class {
57
59
  const lines = [];
58
60
  for (const schema of report.schemas) lines.push(...schemaLines(schema, report));
59
61
  if (report.catalog !== void 0) lines.push(catalogLine(report.catalog));
62
+ for (const orphan of report.orphaned ?? []) lines.push(orphanedLine(orphan));
60
63
  lines.push(summaryLine(report));
61
64
  return lines;
62
65
  }
@@ -103,6 +106,7 @@ var Report = class {
103
106
  entries: report.catalog.entries,
104
107
  outcome: report.catalog.outcome
105
108
  } } : {},
109
+ ...report.orphaned !== void 0 ? { orphaned: report.orphaned } : {},
106
110
  drifted: report.drifted,
107
111
  gateFailed: report.gateFailed,
108
112
  wrote: report.wrote
@@ -148,6 +152,7 @@ var Report = class {
148
152
  String(report.catalog.entries),
149
153
  report.catalog.outcome
150
154
  ]));
155
+ if (report.orphaned !== void 0) lines.push("", tableRow(["orphaned document", "claimed by"]), tableRow(["---", "---"]), ...report.orphaned.map((orphan) => tableRow([orphan, "nothing — delete by hand"])));
151
156
  lines.push("");
152
157
  if (report.gateFailed) {
153
158
  const failed = report.schemas.filter((schema) => schema.outcome === "gate-failed").length;
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,26 @@ 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.
129
+ *
130
+ * **A moved path leaves an orphan the derivation cannot see**: an
131
+ * `appendVersion` flip or a `layout` change renames a document's derived
132
+ * path, and the previously written file stays on disk under the old name —
133
+ * for a `published` label, its advertised URL keeps serving a stale
134
+ * document with no report. The derivation has exactly four shapes for a
135
+ * name and label (`<name>.json`, `<name>-<v>.json`, `<v>/<name>.json`,
136
+ * `<v>/<name>-<v>.json`), so both modes probe the sibling shapes of every
137
+ * label the config still knows and report each one that exists as a FILE
138
+ * and that no target, frozen version, or `catalogPath` claims in
139
+ * {@link RunReport.orphaned}. Nothing else on disk is looked at: an
140
+ * `outputDir` shared with another config, a deploy folder, or the
141
+ * repository root holds documents this config cannot tell from its own
142
+ * leftovers, so they are never reported. A `name` change is therefore not
143
+ * caught either — the old name is unknowable. Orphans are reported, never
144
+ * deleted: the CLI may not have written them.
71
145
  *
72
146
  * @public
73
147
  */
@@ -77,15 +151,37 @@ var Runner = class {
77
151
  const fs = yield* FileSystem.FileSystem;
78
152
  const path = yield* Path.Path;
79
153
  const missing = [];
154
+ const mismatched = [];
80
155
  for (const schema of config.schemas) for (const frozen of schema.frozen) {
81
156
  const info = yield* orNone(fs.stat(frozen.path));
82
- if (Option.isNone(info) || info.value.type !== "File") missing.push({
157
+ if (Option.isNone(info) || info.value.type !== "File") {
158
+ missing.push({
159
+ name: schema.name,
160
+ version: frozen.version,
161
+ path: frozen.path
162
+ });
163
+ continue;
164
+ }
165
+ const text = yield* fs.readFileString(frozen.path);
166
+ const declared = declaredId(text);
167
+ if (declared.reason !== "ok") mismatched.push({
168
+ name: schema.name,
169
+ version: frozen.version,
170
+ path: frozen.path,
171
+ expected: frozen.$id,
172
+ reason: declared.reason
173
+ });
174
+ else if (declared.$id !== frozen.$id) mismatched.push({
83
175
  name: schema.name,
84
176
  version: frozen.version,
85
- path: frozen.path
177
+ path: frozen.path,
178
+ expected: frozen.$id,
179
+ actual: declared.$id,
180
+ reason: "mismatch"
86
181
  });
87
182
  }
88
183
  if (missing.length > 0) return yield* Effect.fail(new FrozenVersionMissingError({ missing }));
184
+ if (mismatched.length > 0) return yield* Effect.fail(new FrozenVersionIdMismatchError({ mismatched }));
89
185
  const targets = config.schemas.map((schema) => schema.target);
90
186
  const checks = yield* SchemaPipeline.check(targets, pipelineOptions);
91
187
  const gateFailed = checks.some((check) => check.blocked);
@@ -131,7 +227,14 @@ var Runner = class {
131
227
  });
132
228
  const entries = config.schemas.flatMap((schema) => schema.catalog !== void 0 ? [schema.catalog] : []);
133
229
  let catalog;
134
- if (entries.length > 0) {
230
+ if (entries.length === 0) {
231
+ const info = yield* orNone(fs.stat(config.catalogPath));
232
+ if (Option.isSome(info)) catalog = {
233
+ path: config.catalogPath,
234
+ entries: 0,
235
+ outcome: "orphaned"
236
+ };
237
+ } else {
135
238
  const text = yield* CanonicalJson.serialize(entries.map((entry) => Schema.encodeSync(CatalogEntry)(entry)));
136
239
  const existing = yield* orNone(fs.readFileString(config.catalogPath));
137
240
  const same = Option.isSome(existing) && parsesEqual(existing.value, text);
@@ -146,6 +249,26 @@ var Runner = class {
146
249
  outcome
147
250
  };
148
251
  }
252
+ const claimed = /* @__PURE__ */ new Set([path.normalize(config.catalogPath)]);
253
+ for (const schema of config.schemas) {
254
+ claimed.add(path.normalize(schema.target.path));
255
+ for (const frozen of schema.frozen) claimed.add(path.normalize(frozen.path));
256
+ }
257
+ const orphaned = [];
258
+ for (const schema of config.schemas) {
259
+ const labels = [schema.target.version, ...schema.frozen.map((frozen) => frozen.version)];
260
+ const shapes = [SchemaVersioning.fileName(schema.name), ...labels.flatMap((version) => version === void 0 ? [] : [
261
+ SchemaVersioning.fileName(schema.name, version, "flat"),
262
+ SchemaVersioning.fileName(schema.name, version, "versioned"),
263
+ SchemaVersioning.fileName(schema.name, version, "versioned", false)
264
+ ])];
265
+ for (const shape of shapes) {
266
+ const file = path.normalize(path.join(config.outputDir, shape));
267
+ if (claimed.has(file)) continue;
268
+ const info = yield* orNone(fs.stat(file));
269
+ if (Option.isSome(info) && info.value.type === "File") orphaned.push(file);
270
+ }
271
+ }
149
272
  return {
150
273
  mode: options.mode,
151
274
  configPath: options.configPath,
@@ -153,6 +276,7 @@ var Runner = class {
153
276
  ...options.policy !== void 0 ? { policy: options.policy } : {},
154
277
  schemas,
155
278
  ...catalog !== void 0 ? { catalog } : {},
279
+ ...orphaned.length > 0 ? { orphaned } : {},
156
280
  drifted,
157
281
  gateFailed,
158
282
  wrote: schemas.some((s) => s.outcome === "written") || catalog?.outcome === "written"
@@ -161,4 +285,4 @@ var Runner = class {
161
285
  };
162
286
 
163
287
  //#endregion
164
- export { FrozenVersionMissingError, Runner };
288
+ export { FrozenVersionIdMismatchError, FrozenVersionMissingError, Runner };
@@ -6,11 +6,14 @@ import { Command } from "effect/unstable/cli";
6
6
  /**
7
7
  * `schemastore check`: the same walk as `build`, reported and never written.
8
8
  * The CI drift gate: it also fails when the committed documents are stale,
9
- * i.e. whenever `build` would write anything.
9
+ * i.e. whenever `build` would write anything — and on the outputs nothing
10
+ * claims (an orphaned catalog file, or a document left behind at a sibling
11
+ * shape of a derived path), which `build` never deletes: remove them by
12
+ * hand.
10
13
  *
11
14
  * @public
12
15
  */
13
- const makeCheckCommand = (deps) => Command.make("check", commandFlags, (input) => execute("check", input, deps)).pipe(Command.withDescription("Report what build would do, fail when it would write anything or refuse to, and write nothing"));
16
+ const makeCheckCommand = (deps) => Command.make("check", commandFlags, (input) => execute("check", input, deps)).pipe(Command.withDescription("Report what build would do, fail when it would write anything or refuse to, or when a catalog file or document is orphaned, and write nothing"));
14
17
 
15
18
  //#endregion
16
19
  export { makeCheckCommand };
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({
@@ -45,14 +46,22 @@ var GateError = class extends Schema.TaggedError()("GateError", { count: Schema.
45
46
  };
46
47
  /**
47
48
  * `check` found committed documents that differ from what the config
48
- * generates (or are missing), so a `build` would write. `check` is the CI
49
- * drift gate, so a stale tree fails it. Exit `1`.
49
+ * generates (or are missing), so a `build` would write — or outputs
50
+ * nothing claims (an orphaned catalog file, an orphaned document), which
51
+ * `build` never deletes. `check` is the CI drift gate, so a stale tree
52
+ * fails it. Exit `1`. `count` is every finding; `orphaned` the part of it
53
+ * a build cannot clear, so the message names both remedies.
50
54
  *
51
55
  * @public
52
56
  */
53
- var StaleError = class extends Schema.TaggedError()("StaleError", { count: Schema.Number }) {
57
+ var StaleError = class extends Schema.TaggedError()("StaleError", {
58
+ count: Schema.Number,
59
+ orphaned: Schema.optionalKey(Schema.Number)
60
+ }) {
54
61
  get message() {
55
- return `${this.count} document(s) are stale; run \`schemastore build\` and commit the result.`;
62
+ const orphaned = this.orphaned ?? 0;
63
+ const stale = this.count - orphaned;
64
+ return [...stale > 0 ? [`${stale} document(s) are stale; run \`schemastore build\` and commit the result.`] : [], ...orphaned > 0 ? [`${orphaned} orphaned output(s) must be deleted by hand; build never will.`] : []].join(" ");
56
65
  }
57
66
  };
58
67
  /**
@@ -111,7 +120,10 @@ const execute = Effect.fn("schemastore.execute")(function* (mode, input, deps) {
111
120
  mode,
112
121
  configPath: loaded.path,
113
122
  ...drift
114
- }).pipe(Effect.provide(SchemaFile.layer), Effect.provide(deps.validator ?? SchemaValidator.layer), Effect.catchTag("FrozenVersionMissingError", (error) => Effect.fail(CliRuntime.reported(error, 1))));
123
+ }).pipe(Effect.provide(SchemaFile.layer), Effect.provide(deps.validator ?? AjvValidator.layer), Effect.catchTags({
124
+ FrozenVersionMissingError: (error) => Effect.fail(CliRuntime.reported(error, 1)),
125
+ FrozenVersionIdMismatchError: (error) => Effect.fail(CliRuntime.reported(error, 1))
126
+ }));
115
127
  yield* emit(report, input.format);
116
128
  yield* StepSummary.append(Report.markdown(report));
117
129
  if (report.gateFailed) {
@@ -128,8 +140,12 @@ const execute = Effect.fn("schemastore.execute")(function* (mode, input, deps) {
128
140
  return yield* Effect.fail(CliRuntime.reported(new DriftError({ drifted }), 1));
129
141
  }
130
142
  if (mode === "check") {
131
- const count = report.schemas.filter((schema) => schema.outcome === "would-write").length + (report.catalog?.outcome === "would-write" ? 1 : 0);
132
- if (count > 0) return yield* Effect.fail(CliRuntime.reported(new StaleError({ count }), 1));
143
+ const orphaned = (report.catalog?.outcome === "orphaned" ? 1 : 0) + (report.orphaned?.length ?? 0);
144
+ const count = report.schemas.filter((schema) => schema.outcome === "would-write").length + (report.catalog?.outcome === "would-write" ? 1 : 0) + orphaned;
145
+ if (count > 0) return yield* Effect.fail(CliRuntime.reported(new StaleError({
146
+ count,
147
+ ...orphaned > 0 ? { orphaned } : {}
148
+ }), 1));
133
149
  }
134
150
  });
135
151
 
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.13.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.13.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.13.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
+ }