@effected/schemastore-cli 0.10.0 → 0.11.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/ConfigLoader.js CHANGED
@@ -1,4 +1,4 @@
1
- import { Effect, FileSystem, Path, Schema } from "effect";
1
+ import { Effect, FileSystem, Path, Predicate, Schema } from "effect";
2
2
  import { isSchemastoreConfig } from "@effected/schemastore";
3
3
  import { createJiti } from "jiti";
4
4
 
@@ -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, a `schemas` element is not `SchemaTarget`-shaped, or two outputs
23
- * resolve to one absolute path.
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 describeMalformedTarget = (schemas) => {
38
- if (!Array.isArray(schemas)) return "schemas is not an array";
39
- for (const [index, target] of schemas.entries()) {
40
- const record = typeof target === "object" && target !== null ? target : void 0;
41
- if (!(record !== void 0 && Schema.isSchema(record.schema) && typeof record.$id === "string" && typeof record.path === "string" && typeof record.published === "boolean")) return `schemas[${index}] is not a SchemaTarget (missing schema/$id/path/published)`;
42
- }
43
- };
44
- const describeMalformedCatalog = (catalog) => {
45
- if (!Array.isArray(catalog)) return "catalog is not an array";
46
- for (const [index, entry] of catalog.entries()) {
47
- const record = typeof entry === "object" && entry !== null ? entry : void 0;
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.url !== "string") return `schemas[${index}].frozen[${j}] is not a frozen version (missing version/path/url)`;
50
49
  }
51
50
  };
52
51
  const describeDuplicatePath = (config) => {
53
52
  const seen = /* @__PURE__ */ new Set();
54
- const paths = [...config.schemas.map((target) => target.path), ...config.catalog.map((c) => c.config.path)];
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 (schema targets and catalog
104
- * entries) against `directory`; absolute paths are left alone. The result
105
- * keeps the `defineConfig` brand.
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
- schemas: config.schemas.map((target) => ({
113
- ...target,
114
- path: absolute(target.path)
115
- })),
116
- catalog: config.catalog.map((c) => ({
117
- ...c,
118
- config: {
119
- ...c.config,
120
- path: absolute(c.config.path)
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 = describeMalformedTarget(exported.schemas);
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
@@ -38,37 +38,30 @@ pnpm add -D @effected/schemastore-cli @effected/schemastore effect
38
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.
39
39
 
40
40
  ```ts
41
- import { defineConfig, SchemaTarget } from "@effected/schemastore";
42
- import { ReleaseOutput, SCHEMA_URL } from "./src/schema/release-output.js";
41
+ import { defineConfig } from "@effected/schemastore";
42
+ import { OkfitConfig } from "./src/config-schema.js";
43
43
 
44
44
  export default defineConfig({
45
- schemas: [
46
- SchemaTarget.make({
47
- schema: ReleaseOutput,
48
- $id: SCHEMA_URL,
49
- name: "silk-release-action",
50
- version: "5.0.0",
51
- path: "schemas/silk-release-action-5.0.0.json",
45
+ outputDir: "schemas",
46
+ baseUrl: "schemastore",
47
+ schemas: {
48
+ okfit: {
49
+ schema: OkfitConfig,
50
+ versions: ["1.0"],
52
51
  published: true,
53
- jsonSchema: { onExcessProperty: "error" },
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",
52
+ catalog: { description: "okfit configuration", fileMatch: ["okfit.toml", ".okfit.toml"] },
63
53
  },
64
- ],
65
- drift: { policy: "semantic", onDrift: "error" },
54
+ },
66
55
  });
67
56
  ```
68
57
 
69
- - `schemas` at least one `SchemaTarget`; `published` (default `false`) marks a version other people depend on.
70
- - `catalog` — zero or more SchemaStore catalog entries; `versions` is derived from every versioned schema of that name. The entry's `url` and each `versions` value are derived as `<baseUrl>/<name>-<version>.json`, so every schema's `path` must sit directly under the directory `baseUrl` names and its `$id` must be that exact URL the CLI does not yet cross-check them.
71
- - `drift` — the default policy for published schemas (`strict`, `semantic` or `allow`) and what drift means (`error` or `warn`). Defaults to `{ policy: "semantic", onDrift: "error" }`.
58
+ A second label is appended to `versions` only once the first is published
59
+ and its file already exists on disksee "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.
72
65
 
73
66
  Then add two scripts:
74
67
 
@@ -88,10 +81,11 @@ schemastore build [config] [--drift=strict|semantic|allow] [--on-drift=error|war
88
81
  schemastore check [config] [--drift=strict|semantic|allow] [--on-drift=error|warn] [--force] [--format=human|json]
89
82
  ```
90
83
 
91
- - `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 each catalog entry.
92
- - `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 entry that differs from what the config generates, or is missing, is stale; run `schemastore build` and commit the result.
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.
93
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.
94
- - `--format=json` emits one JSON document on stdout (config path, per-schema outcome, per-catalog-entry outcome, effective drift policy and its source); human text moves to stderr.
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.
95
89
  - When `GITHUB_STEP_SUMMARY` is set, both commands append a markdown summary table.
96
90
 
97
91
  An unpublished schema is never drift: a contract change at a pinned but unpublished version rewrites the file in place.
@@ -101,7 +95,7 @@ An unpublished schema is never drift: a contract change at a pinned but unpublis
101
95
  | code | meaning |
102
96
  | ---- | -------------------------------------------------------------------------- |
103
97
  | 0 | success, including drift under `onDrift: warn` |
104
- | 1 | drift under `onDrift: error` (the error lists one line per drifting schema: `$id`, change, current and next version), a gate failure, or — for `check` — any document `build` would write |
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 |
105
99
  | 2 | config not found, failed to load, or failed `SchemastoreConfig` validation |
106
100
  | 3 | infrastructure failure |
107
101
  | 64 | usage error |
package/Report.js CHANGED
@@ -3,32 +3,36 @@ 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)`;
23
26
  default: return entry.outcome;
24
27
  }
25
28
  };
29
+ const driftClause = (report) => `drift ${report.policy !== void 0 ? `${report.policy} (flag)` : "per schema (config)"}, on-drift ${report.onDrift}`;
26
30
  const summaryLine = (report) => {
27
31
  const written = report.schemas.filter((schema) => schema.outcome === "written").length;
28
32
  const unchanged = report.schemas.filter((schema) => schema.outcome === "unchanged").length;
29
33
  const drift = report.schemas.filter((schema) => schema.verdict === "drift").length;
30
34
  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 — drift policy ${report.drift.policy}/${report.drift.onDrift} (${report.drift.source})`;
35
+ return `${report.schemas.length} schema(s): ${written} written, ${unchanged} unchanged, ${drift} drift, ${gateFailed} gate failed — ${driftClause(report)}`;
32
36
  };
33
37
  const warningLine = (schema, report) => `warning: DRIFT ${schema.change}${publishedClause(schema)} ${report.mode === "build" ? "written" : "would write"} under --on-drift=warn — ${schema.path}`;
34
38
  const tableRow = (columns) => `| ${columns.join(" | ")} |`;
@@ -52,7 +56,7 @@ var Report = class {
52
56
  static human(report) {
53
57
  const lines = [];
54
58
  for (const schema of report.schemas) lines.push(...schemaLines(schema, report));
55
- for (const entry of report.catalog) lines.push(catalogLine(entry));
59
+ if (report.catalog !== void 0) lines.push(catalogLine(report.catalog));
56
60
  lines.push(summaryLine(report));
57
61
  return lines;
58
62
  }
@@ -62,7 +66,7 @@ var Report = class {
62
66
  * would be) written, so there is no drift to warn about.
63
67
  */
64
68
  static warnings(report) {
65
- if (report.drift.onDrift !== "warn" || report.gateFailed) return [];
69
+ if (report.onDrift !== "warn" || report.gateFailed) return [];
66
70
  return report.schemas.filter((schema) => schema.verdict === "drift").map((schema) => warningLine(schema, report));
67
71
  }
68
72
  /** One JSON document, stable key order. */
@@ -71,20 +75,21 @@ var Report = class {
71
75
  mode: report.mode,
72
76
  configPath: report.configPath,
73
77
  drift: {
74
- policy: report.drift.policy,
75
- onDrift: report.drift.onDrift,
76
- source: report.drift.source
78
+ onDrift: report.onDrift,
79
+ ...report.policy !== void 0 ? { policy: report.policy } : {}
77
80
  },
78
81
  schemas: report.schemas.map((schema) => ({
79
82
  $id: schema.$id,
80
83
  path: schema.path,
81
- ...schema.name !== void 0 ? { name: schema.name } : {},
84
+ name: schema.name,
82
85
  ...schema.version !== void 0 ? { version: schema.version } : {},
83
86
  published: schema.published,
84
87
  change: schema.change,
85
88
  verdict: schema.verdict,
89
+ policy: schema.policy,
86
90
  outcome: schema.outcome,
87
91
  ...schema.nextVersion !== void 0 ? { nextVersion: schema.nextVersion } : {},
92
+ ...schema.frozen.length > 0 ? { frozen: schema.frozen } : {},
88
93
  findings: schema.findings.map((finding) => ({
89
94
  source: finding.source,
90
95
  severity: finding.severity,
@@ -93,11 +98,11 @@ var Report = class {
93
98
  message: finding.message
94
99
  }))
95
100
  })),
96
- catalog: report.catalog.map((entry) => ({
97
- name: entry.name,
98
- path: entry.path,
99
- outcome: entry.outcome
100
- })),
101
+ ...report.catalog !== void 0 ? { catalog: {
102
+ path: report.catalog.path,
103
+ entries: report.catalog.entries,
104
+ outcome: report.catalog.outcome
105
+ } } : {},
101
106
  drifted: report.drifted,
102
107
  gateFailed: report.gateFailed,
103
108
  wrote: report.wrote
@@ -110,6 +115,7 @@ var Report = class {
110
115
  lines.push(tableRow([
111
116
  "schema",
112
117
  "version",
118
+ "frozen",
113
119
  "published",
114
120
  "change",
115
121
  "outcome"
@@ -118,26 +124,37 @@ var Report = class {
118
124
  "---",
119
125
  "---",
120
126
  "---",
127
+ "---",
121
128
  "---"
122
129
  ]));
123
130
  for (const schema of report.schemas) lines.push(tableRow([
124
- schema.name ?? schema.$id,
131
+ schema.name,
125
132
  schema.version ?? "",
133
+ schema.frozen.join(", "),
126
134
  schema.published ? "yes" : "no",
127
135
  schema.change,
128
136
  schema.outcome
129
137
  ]));
130
- if (report.catalog.length > 0) {
131
- lines.push("", tableRow(["catalog", "outcome"]), tableRow(["---", "---"]));
132
- for (const entry of report.catalog) lines.push(tableRow([entry.name, entry.outcome]));
133
- }
138
+ if (report.catalog !== void 0) lines.push("", tableRow([
139
+ "catalog",
140
+ "entries",
141
+ "outcome"
142
+ ]), tableRow([
143
+ "---",
144
+ "---",
145
+ "---"
146
+ ]), tableRow([
147
+ report.catalog.path,
148
+ String(report.catalog.entries),
149
+ report.catalog.outcome
150
+ ]));
134
151
  lines.push("");
135
152
  if (report.gateFailed) {
136
153
  const failed = report.schemas.filter((schema) => schema.outcome === "gate-failed").length;
137
154
  lines.push(`**Gate:** ${failed} schema(s) failed`);
138
155
  } else if (report.drifted) {
139
156
  const drifted = report.schemas.filter((schema) => schema.verdict === "drift").length;
140
- lines.push(`**Drift:** ${drifted} schema(s) drifted under ${report.drift.policy}/${report.drift.onDrift}`);
157
+ lines.push(`**Drift:** ${drifted} schema(s) drifted ${driftClause(report)}`);
141
158
  } else lines.push("**Drift:** none");
142
159
  lines.push("");
143
160
  return lines.join("\n");
package/Runner.js CHANGED
@@ -2,8 +2,33 @@ import { Effect, FileSystem, Option, Path, Schema } from "effect";
2
2
  import { CanonicalJson, CatalogEntry, DriftPolicy, SchemaPipeline, SchemaVersioning } from "@effected/schemastore";
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
+ };
5
29
  const pipelineOptions = { contractChanges: "allow" };
6
- const catalogText = (target) => CanonicalJson.serialize(Schema.encodeSync(CatalogEntry)(target.entry));
30
+ const orNone = (read) => read.pipe(Effect.map(Option.some), Effect.catchIf((error) => error.reason._tag === "NotFound", () => Effect.succeed(Option.none())));
31
+ const pendingOutcome = (wouldWrite, refused) => !wouldWrite ? "unchanged" : refused ? "held" : "would-write";
7
32
  const parsesEqual = (existing, text) => {
8
33
  try {
9
34
  return CanonicalJson.equals(JSON.parse(existing), JSON.parse(text));
@@ -12,25 +37,37 @@ const parsesEqual = (existing, text) => {
12
37
  }
13
38
  };
14
39
  /**
15
- * The shared `build` / `check` walk: classify every schema through
16
- * {@link DriftPolicy} over `SchemaPipeline.check`, then write through
17
- * `SchemaPipeline.run` only when nothing is refused.
40
+ * The shared `build` / `check` walk: verify every advertised frozen version
41
+ * exists, classify every current target through {@link DriftPolicy} over
42
+ * `SchemaPipeline.check`, then write through `SchemaPipeline.run` only when
43
+ * nothing is refused.
18
44
  *
19
45
  * @remarks
20
- * A build writes NOTHING when any schema fails its gate, or when any schema
21
- * drifts under `onDrift: "error"` a partial write would leave a
22
- * repository half-bumped. Every otherwise-writable schema then reports
23
- * `held`, so a reader sees why a clean schema was not written — in both
24
- * modes, since `check` reports what `build` would do under the same
25
- * flags. Both modes share one `SchemaFile`; the single `writing`
26
- * predicate (`mode === "build" && !refused`) gates every write, schemas
27
- * and catalog entries alike. Under `onDrift: "warn"` drifting schemas are
28
- * written and keep their `"drift"` verdict for the renderer to shout
29
- * about.
46
+ * **The frozen check runs first, before anything is generated.** Every
47
+ * schema's {@link ResolvedSchema.frozen} versions are walked, and every miss
48
+ * is reported at once: a build fails typed with
49
+ * {@link FrozenVersionMissingError} listing every label with no file on disk
50
+ * nothing is written a catalog that points a label at a 404 is a worse
51
+ * failure than an early refusal.
30
52
  *
31
- * Catalog entries follow the schemas: serialized canonically, compared by
32
- * parsed content against the file on disk, written only when different and
33
- * only when the run is writing.
53
+ * **Drift is classified per schema, under that schema's own
54
+ * {@link ResolvedSchema.drift} tolerance unless `options.policy` is set,
55
+ * in which case it overrides every schema's own for this run** (the `--drift`
56
+ * / `--force` flags). A build writes NOTHING when any schema fails its gate,
57
+ * or when any schema drifts under `onDrift: "error"` — a partial write would
58
+ * leave a repository half-bumped. Every otherwise-writable schema then
59
+ * reports `held`, so a reader sees why a clean schema was not written — in
60
+ * both modes, since `check` reports what `build` would do under the same
61
+ * flags. Both modes share one `SchemaFile`; the single `writing` predicate
62
+ * (`mode === "build" && !refused`) gates every write, schemas and the
63
+ * catalog file alike. Under `onDrift: "warn"` drifting schemas are written
64
+ * and keep their `"drift"` verdict for the renderer to shout about.
65
+ *
66
+ * **Every catalog entry the config declares lands in ONE file** at
67
+ * `config.catalogPath` — never one file per schema — serialized canonically
68
+ * 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.
34
71
  *
35
72
  * @public
36
73
  */
@@ -39,79 +76,89 @@ var Runner = class {
39
76
  static run = Effect.fn("Runner.run")(function* (config, options) {
40
77
  const fs = yield* FileSystem.FileSystem;
41
78
  const path = yield* Path.Path;
42
- const checks = yield* SchemaPipeline.check(config.schemas, pipelineOptions);
79
+ const missing = [];
80
+ for (const schema of config.schemas) for (const frozen of schema.frozen) {
81
+ const info = yield* orNone(fs.stat(frozen.path));
82
+ if (Option.isNone(info) || info.value.type !== "File") missing.push({
83
+ name: schema.name,
84
+ version: frozen.version,
85
+ path: frozen.path
86
+ });
87
+ }
88
+ if (missing.length > 0) return yield* Effect.fail(new FrozenVersionMissingError({ missing }));
89
+ const targets = config.schemas.map((schema) => schema.target);
90
+ const checks = yield* SchemaPipeline.check(targets, pipelineOptions);
43
91
  const gateFailed = checks.some((check) => check.blocked);
44
92
  const classified = checks.map((check, i) => {
45
- const target = config.schemas[i];
93
+ const schema = config.schemas[i];
94
+ const target = schema.target;
95
+ const policy = options.policy ?? schema.drift;
46
96
  return {
97
+ schema,
47
98
  target,
48
99
  check,
49
100
  verdict: DriftPolicy.classify({
50
101
  published: target.published,
51
102
  change: check.change
52
- }, options.drift.policy),
103
+ }, policy),
104
+ policy,
53
105
  nextVersion: target.version !== void 0 && check.change === "contract" && SchemaVersioning.isPinned(target.version) ? SchemaVersioning.next(target.version, "contract") : void 0
54
106
  };
55
107
  });
56
108
  const drifted = classified.some((entry) => entry.verdict === "drift");
57
- const refused = gateFailed || drifted && options.drift.onDrift === "error";
109
+ const refused = gateFailed || drifted && options.onDrift === "error";
58
110
  const writing = options.mode === "build" && !refused;
59
- const written = writing ? yield* SchemaPipeline.run(config.schemas, pipelineOptions).pipe(Effect.catchTags({
111
+ const written = writing ? yield* SchemaPipeline.run(targets, pipelineOptions).pipe(Effect.catchTags({
60
112
  SchemaGateError: (error) => Effect.die(error),
61
113
  SchemaContractChangeError: (error) => Effect.die(error)
62
114
  })) : 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" : refused && check.wouldWrite ? "held" : check.wouldWrite ? "would-write" : "unchanged";
115
+ const schemas = classified.map(({ schema, target, check, verdict, policy, nextVersion }, i) => {
116
+ const outcome = check.blocked ? "gate-failed" : written !== void 0 ? written[i].outcome : verdict === "drift" ? "drift" : pendingOutcome(check.wouldWrite, refused);
65
117
  return {
66
118
  $id: target.$id,
67
119
  path: target.path,
120
+ name: schema.name,
68
121
  published: target.published,
69
122
  change: check.change,
70
123
  verdict,
124
+ policy,
71
125
  outcome,
72
126
  findings: check.findings,
73
- ...target.name !== void 0 ? { name: target.name } : {},
127
+ frozen: schema.frozen.map((frozen) => frozen.version),
74
128
  ...target.version !== void 0 ? { version: target.version } : {},
75
129
  ...nextVersion !== void 0 ? { nextVersion } : {}
76
130
  };
77
131
  });
78
- const catalog = [];
79
- for (const entry of config.catalog) {
80
- const { name, path: file } = entry.config;
81
- const text = yield* catalogText(entry);
82
- const existing = yield* fs.readFileString(file).pipe(Effect.map(Option.some), Effect.catchIf((error) => error.reason._tag === "NotFound", () => Effect.succeed(Option.none())));
83
- if (Option.isSome(existing) && parsesEqual(existing.value, text)) catalog.push({
84
- name,
85
- path: file,
86
- outcome: "unchanged"
87
- });
88
- else if (!writing) catalog.push({
89
- name,
90
- path: file,
91
- outcome: refused ? "held" : "would-write"
92
- });
93
- else {
94
- yield* fs.makeDirectory(path.dirname(file), { recursive: true });
95
- yield* fs.writeFileString(file, text);
96
- catalog.push({
97
- name,
98
- path: file,
99
- outcome: "written"
100
- });
132
+ const entries = config.schemas.flatMap((schema) => schema.catalog !== void 0 ? [schema.catalog] : []);
133
+ let catalog;
134
+ if (entries.length > 0) {
135
+ const text = yield* CanonicalJson.serialize(entries.map((entry) => Schema.encodeSync(CatalogEntry)(entry)));
136
+ const existing = yield* orNone(fs.readFileString(config.catalogPath));
137
+ const same = Option.isSome(existing) && parsesEqual(existing.value, text);
138
+ const outcome = writing && !same ? "written" : pendingOutcome(!same, refused);
139
+ if (outcome === "written") {
140
+ yield* fs.makeDirectory(path.dirname(config.catalogPath), { recursive: true });
141
+ yield* fs.writeFileString(config.catalogPath, text);
101
142
  }
143
+ catalog = {
144
+ path: config.catalogPath,
145
+ entries: entries.length,
146
+ outcome
147
+ };
102
148
  }
103
149
  return {
104
150
  mode: options.mode,
105
151
  configPath: options.configPath,
106
- drift: options.drift,
152
+ onDrift: options.onDrift,
153
+ ...options.policy !== void 0 ? { policy: options.policy } : {},
107
154
  schemas,
108
- catalog,
155
+ ...catalog !== void 0 ? { catalog } : {},
109
156
  drifted,
110
157
  gateFailed,
111
- wrote: schemas.some((s) => s.outcome === "written") || catalog.some((c) => c.outcome === "written")
158
+ wrote: schemas.some((s) => s.outcome === "written") || catalog?.outcome === "written"
112
159
  };
113
160
  });
114
161
  };
115
162
 
116
163
  //#endregion
117
- export { Runner };
164
+ export { FrozenVersionMissingError, Runner };
package/cli/execute.js CHANGED
@@ -67,11 +67,11 @@ var ConflictingFlagsError = class extends Schema.TaggedError()("ConflictingFlags
67
67
  return `--force conflicts with --drift=${this.policy}: --force means --drift=allow`;
68
68
  }
69
69
  };
70
- const effectiveDrift = (configured, input) => {
70
+ const effectiveDrift = (config, input) => {
71
+ const forced = input.force ? "allow" : Option.getOrUndefined(input.drift);
71
72
  return {
72
- policy: input.force ? "allow" : Option.getOrElse(input.drift, () => configured.policy),
73
- onDrift: Option.getOrElse(input.onDrift, () => configured.onDrift),
74
- source: input.force || Option.isSome(input.drift) || Option.isSome(input.onDrift) ? "flag" : "config"
73
+ onDrift: Option.getOrElse(input.onDrift, () => config.onDrift),
74
+ ...forced !== void 0 ? { policy: forced } : {}
75
75
  };
76
76
  };
77
77
  const emit = Effect.fn("schemastore.emit")(function* (report, format) {
@@ -105,13 +105,13 @@ const execute = Effect.fn("schemastore.execute")(function* (mode, input, deps) {
105
105
  ...Option.isSome(input.config) ? { explicit: input.config.value } : {},
106
106
  ...deps.importModule !== void 0 ? { importModule: deps.importModule } : {}
107
107
  });
108
- const drift = effectiveDrift(loaded.config.drift, input);
108
+ const drift = effectiveDrift(loaded.config, input);
109
109
  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
110
  const report = yield* Runner.run(loaded.config, {
111
111
  mode,
112
112
  configPath: loaded.path,
113
- drift
114
- }).pipe(Effect.provide(SchemaFile.layer), Effect.provide(deps.validator ?? SchemaValidator.layer));
113
+ ...drift
114
+ }).pipe(Effect.provide(SchemaFile.layer), Effect.provide(deps.validator ?? SchemaValidator.layer), Effect.catchTag("FrozenVersionMissingError", (error) => Effect.fail(CliRuntime.reported(error, 1))));
115
115
  yield* emit(report, input.format);
116
116
  yield* StepSummary.append(Report.markdown(report));
117
117
  if (report.gateFailed) {
@@ -128,7 +128,7 @@ const execute = Effect.fn("schemastore.execute")(function* (mode, input, deps) {
128
128
  return yield* Effect.fail(CliRuntime.reported(new DriftError({ drifted }), 1));
129
129
  }
130
130
  if (mode === "check") {
131
- const count = report.schemas.filter((schema) => schema.outcome === "would-write").length + report.catalog.filter((entry) => entry.outcome === "would-write").length;
131
+ const count = report.schemas.filter((schema) => schema.outcome === "would-write").length + (report.catalog?.outcome === "would-write" ? 1 : 0);
132
132
  if (count > 0) return yield* Effect.fail(CliRuntime.reported(new StaleError({ count }), 1));
133
133
  }
134
134
  });
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 the config's drift.policy"), Flag.optional);
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 drift.onDrift"), Flag.optional);
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/main.js CHANGED
@@ -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.10.0"
18
+ version: "0.11.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.10.0",
3
+ "version": "0.11.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": [
@@ -40,7 +40,7 @@
40
40
  "jiti": "^2.6.0"
41
41
  },
42
42
  "peerDependencies": {
43
- "@effected/schemastore": "0.10.0",
43
+ "@effected/schemastore": "0.11.0",
44
44
  "effect": "4.0.0-rc.115"
45
45
  },
46
46
  "engines": {