@effected/schemastore-cli 0.16.1 → 0.18.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 { isSchemastoreConfig } from "@effected/schemastore";
1
+ import { SchemaVersioning, isSchemastoreConfig } from "@effected/schemastore";
2
2
  import { Effect, FileSystem, Path, Predicate, Schema } from "effect";
3
3
  import { createJiti } from "jiti";
4
4
 
@@ -19,14 +19,18 @@ 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, `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.
22
+ * value, `name` is not a simple file base name, `outputDir`/`catalogDir` is
23
+ * not a string, a `schemas` element is not resolved-schema-shaped (or its
24
+ * `target`, `catalog`, or a `frozen` entry is not shaped), two outputs
25
+ * resolve to one absolute path, a document resolves into `catalogDir`, or
26
+ * `catalogDir` resolves to `outputDir` or to the merged catalog's path.
25
27
  *
26
28
  * @public
27
29
  */
28
30
  var ConfigLoadError = class extends Schema.TaggedError()("ConfigLoadError", {
31
+ /** The config file that failed to load. */
29
32
  path: Schema.String,
33
+ /** Why it failed; the first line is the message, any remainder is detail such as a stack. */
30
34
  reason: Schema.String
31
35
  }) {
32
36
  get message() {
@@ -38,8 +42,9 @@ const describeCause = (cause) => cause instanceof Error ? cause.stack ?? cause.m
38
42
  const isTargetShaped = (target) => Predicate.isObject(target) && Schema.isSchema(target.schema) && typeof target.$id === "string" && typeof target.path === "string" && typeof target.published === "boolean";
39
43
  const isCatalogEntryShaped = (catalog) => Predicate.isObject(catalog) && typeof catalog.name === "string" && typeof catalog.description === "string" && Array.isArray(catalog.fileMatch) && typeof catalog.url === "string";
40
44
  const describeMalformed = (config) => {
45
+ if (typeof config.name !== "string" || !SchemaVersioning.isSimpleName(config.name)) return "name is not a simple file base name";
41
46
  if (typeof config.outputDir !== "string") return "outputDir is not a string";
42
- if (typeof config.catalogPath !== "string") return "catalogPath is not a string";
47
+ if (typeof config.catalogDir !== "string") return "catalogDir is not a string";
43
48
  if (!Array.isArray(config.schemas)) return "schemas is not an array";
44
49
  for (const [index, schema] of config.schemas.entries()) {
45
50
  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)`;
@@ -48,12 +53,21 @@ const describeMalformed = (config) => {
48
53
  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
54
  }
50
55
  };
51
- const describeDuplicatePath = (config) => {
56
+ const describeClashingPath = (config, path) => {
57
+ const documents = config.schemas.flatMap((schema) => [schema.target.path, ...schema.frozen.map((f) => f.path)]);
58
+ const catalogDir = path.normalize(config.catalogDir);
59
+ if (catalogDir === path.normalize(ConfigLoader.mergedCatalogPath(config, path))) return `catalogDir "${config.catalogDir}" must not be the merged catalog's path (catalog.json in its parent)`;
60
+ if (catalogDir === path.normalize(config.outputDir)) return `catalogDir "${config.catalogDir}" must not be outputDir: every *.json file in it is read as a catalog slice`;
61
+ for (const document of documents) if (path.normalize(path.dirname(document)) === catalogDir) return `output path "${document}" sits in catalogDir "${config.catalogDir}", which holds only catalog slices`;
52
62
  const seen = /* @__PURE__ */ new Set();
53
- const paths = [...config.schemas.flatMap((schema) => [schema.target.path, ...schema.frozen.map((f) => f.path)]), config.catalogPath];
54
- for (const p of paths) {
55
- if (seen.has(p)) return `output path "${p}" is declared twice after resolution`;
56
- seen.add(p);
63
+ for (const p of [
64
+ ...documents,
65
+ ConfigLoader.slicePath(config, path),
66
+ ConfigLoader.mergedCatalogPath(config, path)
67
+ ]) {
68
+ const normalized = path.normalize(p);
69
+ if (seen.has(normalized)) return `output path "${p}" is declared twice after resolution`;
70
+ seen.add(normalized);
57
71
  }
58
72
  };
59
73
  /**
@@ -98,8 +112,20 @@ var ConfigLoader = class ConfigLoader {
98
112
  dir = parent;
99
113
  }
100
114
  });
115
+ /** This config's own catalog slice: `<catalogDir>/<name>.json`. */
116
+ static slicePath(config, path) {
117
+ return path.join(config.catalogDir, `${config.name}.json`);
118
+ }
119
+ /**
120
+ * The merged catalog every config sharing `catalogDir` maintains:
121
+ * `catalog.json` in `catalogDir`'s parent (`<outputDir>/catalog.json` under
122
+ * the default `catalogDir`).
123
+ */
124
+ static mergedCatalogPath(config, path) {
125
+ return path.join(path.dirname(config.catalogDir), "catalog.json");
126
+ }
101
127
  /**
102
- * Resolve every relative `path` in the config (`outputDir`, `catalogPath`,
128
+ * Resolve every relative `path` in the config (`outputDir`, `catalogDir`,
103
129
  * each schema's current target, and every frozen predecessor) against
104
130
  * `directory`; absolute paths are left alone. `defineConfig` already
105
131
  * prefixes `outputDir` onto every target/frozen `path`, so resolving them
@@ -112,7 +138,7 @@ var ConfigLoader = class ConfigLoader {
112
138
  return {
113
139
  ...config,
114
140
  outputDir: absolute(config.outputDir),
115
- catalogPath: absolute(config.catalogPath),
141
+ catalogDir: absolute(config.catalogDir),
116
142
  schemas: config.schemas.map((schema) => ({
117
143
  ...schema,
118
144
  target: {
@@ -155,7 +181,7 @@ var ConfigLoader = class ConfigLoader {
155
181
  }));
156
182
  const directory = path.dirname(configPath);
157
183
  const config = yield* ConfigLoader.resolvePaths(exported, directory);
158
- const duplicate = describeDuplicatePath(config);
184
+ const duplicate = describeClashingPath(config, path);
159
185
  if (duplicate !== void 0) return yield* Effect.fail(new ConfigLoadError({
160
186
  path: configPath,
161
187
  reason: duplicate
package/README.md CHANGED
@@ -7,10 +7,10 @@
7
7
 
8
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.
9
9
 
10
- > **Pre-release.** This package is part of the `@effected/*` kit, in pre-`1.0.0`
11
- > development against a single pinned Effect v4 prerelease. Packages graduate to
12
- > `1.0.0` once Effect `4.0.0` ships. To hold your own `effect` versions at
13
- > exactly the ones the kit is built and tested against, install
10
+ > **Pre-`1.0.0`.** This package is part of the `@effected/*` kit, built on stable
11
+ > Effect v4 (`effect` `^4.0.0`) and still in `0.x` development. Stable Effect
12
+ > makes a kit `1.0.0` possible, not automatic. To keep your `effect` and
13
+ > `@effect/*` versions on the line the kit is built and tested against, install
14
14
  > [`@effected/pnpm-plugin-effect`](https://www.npmjs.com/package/@effected/pnpm-plugin-effect).
15
15
  >
16
16
  > **Stability: unstable.** This package's API surface is not yet considered
@@ -60,6 +60,8 @@ import { defineConfig } from "@effected/schemastore";
60
60
  import { OutputSchemaIdentity, ReleaseOutput } from "./src/schema/output.js";
61
61
 
62
62
  export default defineConfig({
63
+ // This config's identity: its catalog slice is catalogs/<name>.json.
64
+ name: "silk-release-action",
63
65
  // Relative paths resolve against this file's directory.
64
66
  outputDir: "schemas",
65
67
  schemas: {
@@ -79,10 +81,11 @@ export default defineConfig({
79
81
 
80
82
  `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
83
 
82
- A schema published to SchemaStore itself uses `HostedSchema.schemastore` and declares its catalog entry; the command assembles every entry into one `catalog.json`:
84
+ A schema published to SchemaStore itself uses `HostedSchema.schemastore` and declares its catalog entry; the command assembles every entry into the config's catalog slice and the merged `catalog.json`:
83
85
 
84
86
  ```ts
85
87
  export default defineConfig({
88
+ name: "okfit",
86
89
  outputDir: "schemas",
87
90
  schemas: {
88
91
  okfit: {
@@ -102,7 +105,8 @@ export default defineConfig({
102
105
  - `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
106
  - `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
107
  - `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`).
108
+ - `name` (required, top-level) is the config's identity: a simple file base name, distinct among configs sharing a `catalogDir` — case-insensitively, since on a case-insensitive volume `docs` and `Docs` are one slice file that each build overwrites. When `<name>.json` is absent from the listing but the volume resolves it to a file differing only in case (a case-insensitive volume), that file is claimed as the config's slice and flagged on its report line; on a case-sensitive volume the file is another slice, so a case-only rename leftover blocks the merge until it is deleted, like any rename leftover.
109
+ - `catalog` is required under SchemaStore hosting and optional under a custom host. Every entry the config declares lands in ONE slice file, `<catalogDir>/<name>.json` (`catalogDir` defaults to `<outputDir>/catalogs`), and the command maintains the merged `catalog.json` in `catalogDir`'s parent — `<outputDir>/catalog.json` by default — as the union of every slice there, sorted by `url`. Several configs can therefore share one `outputDir`: each rewrites only its own slice, and whichever builds last writes the identical merged file, so `check` is green for all of them. `catalogDir` holds slices only: `defineConfig` rejects one that is `outputDir`, that is the merged catalog's own path, or that a derived document sits in. Every config that shares a merged catalog must share the same `catalogDir` — sibling directories merging into one `catalog.json` overwrite each other, and no single config can see it.
106
110
  - 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
111
 
108
112
  ## Commands
@@ -113,10 +117,10 @@ schemastore check [config] [--drift=strict|semantic|allow] [--on-drift=error|war
113
117
  ```
114
118
 
115
119
  - 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.
120
+ - `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 config's catalog slice when any entry declares one, and the merged catalog over every slice.
121
+ - `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 slice left behind after the config's last `catalog` block was removed is reported `orphaned` and fails `check` the same way, but `build` never deletes it — and the merged catalog keeps advertising its entries until it is gone: delete the file by hand, or restore a `catalog` block; a merged `catalog.json` with no slice left is orphaned the same way. 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. A catalog URL advertised by two slices, or a slice that cannot be read or is not a catalog entry array (an undeclared key included), blocks the merged catalog: both commands fail (exit `1`) naming the URL and its slices or the invalid slice, and the merged file is left as it is until the configs or slices are fixed.
118
122
  - `--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.
123
+ - `--format=json` emits one JSON document on stdout (per-schema outcome and effective tolerance, the catalog slice and merged-catalog outcomes, 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
124
 
121
125
  ## The engine, as a library export
122
126
 
@@ -140,8 +144,8 @@ Findings come back as values; the error channel carries `SchemaValidatorError` o
140
144
  | code | meaning |
141
145
  | ---- | -------------------------------------------------------------------------- |
142
146
  | 0 | success, including drift under `onDrift: warn` |
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 |
147
+ | 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, a merged catalog blocked by a URL two slices advertise or an invalid slice, or — for `check` — anything `build` would write or an output nothing claims (an orphaned catalog slice or merged catalog, or an orphaned document at a sibling shape of a derived path) |
148
+ | 2 | config not found, failed to load, failed `defineConfig` validation, or a `catalogDir` that is a file or cannot be listed (checked before anything is written) |
145
149
  | 3 | infrastructure failure |
146
150
  | 64 | usage error |
147
151
 
package/Report.js CHANGED
@@ -17,16 +17,57 @@ const schemaLines = (schema, report) => {
17
17
  default: return schema.outcome;
18
18
  }
19
19
  };
20
- const catalogLine = (entry) => {
21
- switch (entry.outcome) {
22
- case "written": return `written catalog ${entry.path} (${entry.entries} entries)`;
23
- case "unchanged": return `unchanged catalog ${entry.path} (${entry.entries} entries)`;
24
- case "would-write": return `would write catalog ${entry.path} (${entry.entries} entries)`;
25
- case "held": return `held catalog ${entry.path} (${entry.entries} entries)`;
26
- case "orphaned": return `orphaned catalog ${entry.path} (no schema declares a catalog)`;
27
- default: return entry.outcome;
20
+ const sliceOwner = (slicePath) => (slicePath.split(/[\\/]/).pop() ?? slicePath).replace(/\.json$/, "");
21
+ const sliceCounts = (slice) => slice.caseFoldedMatch !== void 0 ? `(${slice.entries} entries; claimed ${slice.caseFoldedMatch} by case-folded match for "${sliceOwner(slice.path)}")` : `(${slice.entries} entries)`;
22
+ const sliceLine = (slice) => {
23
+ switch (slice.outcome) {
24
+ case "written": return `written catalog slice ${slice.path} ${sliceCounts(slice)}`;
25
+ case "unchanged": return `unchanged catalog slice ${slice.path} ${sliceCounts(slice)}`;
26
+ case "would-write": return `would write catalog slice ${slice.path} ${sliceCounts(slice)}`;
27
+ case "held": return `held catalog slice ${slice.path} ${sliceCounts(slice)}`;
28
+ case "orphaned": return `orphaned catalog slice ${slice.path} (no schema declares a catalog; the merged catalog keeps advertising its entries until it is deleted — delete it by hand; build never will)`;
29
+ default: return slice.outcome;
28
30
  }
29
31
  };
32
+ const mergedLines = (merged) => {
33
+ const counts = `(${merged.entries} entries from ${merged.slices.length} slice(s))`;
34
+ switch (merged.outcome) {
35
+ case "written": return [`written catalog ${merged.path} ${counts}`];
36
+ case "unchanged": return [`unchanged catalog ${merged.path} ${counts}`];
37
+ case "would-write": return [`would write catalog ${merged.path} ${counts}`];
38
+ case "held": return [`held catalog ${merged.path} ${counts}`];
39
+ case "orphaned": return [`orphaned catalog ${merged.path} (no catalog slice remains — delete it by hand; build never will)`];
40
+ case "blocked": return [
41
+ `CATALOG BLOCKED ${merged.path} (not written: fix the slices below)`,
42
+ ...merged.conflicts.map((conflict) => ` url ${conflict.url} advertised by ${conflict.slices.join(", ")}`),
43
+ ...merged.invalid.map(({ path, reason }) => ` slice ${path} is invalid: ${reason}`)
44
+ ];
45
+ default: return merged.outcome;
46
+ }
47
+ };
48
+ const catalogLines = (catalog) => [...catalog.slice !== void 0 ? [sliceLine(catalog.slice)] : [], ...catalog.merged !== void 0 ? mergedLines(catalog.merged) : []];
49
+ const catalogJson = (catalog) => ({
50
+ ...catalog.slice !== void 0 ? { slice: {
51
+ path: catalog.slice.path,
52
+ entries: catalog.slice.entries,
53
+ outcome: catalog.slice.outcome,
54
+ ...catalog.slice.caseFoldedMatch !== void 0 ? { caseFoldedMatch: catalog.slice.caseFoldedMatch } : {}
55
+ } } : {},
56
+ ...catalog.merged !== void 0 ? { merged: {
57
+ path: catalog.merged.path,
58
+ entries: catalog.merged.entries,
59
+ outcome: catalog.merged.outcome,
60
+ slices: catalog.merged.slices,
61
+ ...catalog.merged.conflicts.length > 0 ? { conflicts: catalog.merged.conflicts.map(({ url, slices }) => ({
62
+ url,
63
+ slices
64
+ })) } : {},
65
+ ...catalog.merged.invalid.length > 0 ? { invalid: catalog.merged.invalid.map(({ path, reason }) => ({
66
+ path,
67
+ reason
68
+ })) } : {}
69
+ } } : {}
70
+ });
30
71
  const orphanedLine = (orphan) => `orphaned document ${orphan} (no target, frozen version, or catalog entry claims it — delete it by hand; build never will)`;
31
72
  const driftClause = (report) => `drift ${report.policy !== void 0 ? `${report.policy} (flag)` : "per schema (config)"}, on-drift ${report.onDrift}`;
32
73
  const summaryLine = (report) => {
@@ -37,7 +78,8 @@ const summaryLine = (report) => {
37
78
  return `${report.schemas.length} schema(s): ${written} written, ${unchanged} unchanged, ${drift} drift, ${gateFailed} gate failed — ${driftClause(report)}`;
38
79
  };
39
80
  const warningLine = (schema, report) => `warning: DRIFT ${schema.change}${publishedClause(schema)} ${report.mode === "build" ? "written" : "would write"} under --on-drift=warn — ${schema.path}`;
40
- const tableRow = (columns) => `| ${columns.join(" | ")} |`;
81
+ const tableCell = (cell) => cell.replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
82
+ const tableRow = (columns) => `| ${columns.map(tableCell).join(" | ")} |`;
41
83
  /**
42
84
  * Renders a {@link RunReport} for a terminal, a JSON consumer or a GitHub
43
85
  * step summary.
@@ -58,7 +100,7 @@ var Report = class {
58
100
  static human(report) {
59
101
  const lines = [];
60
102
  for (const schema of report.schemas) lines.push(...schemaLines(schema, report));
61
- if (report.catalog !== void 0) lines.push(catalogLine(report.catalog));
103
+ if (report.catalog !== void 0) lines.push(...catalogLines(report.catalog));
62
104
  for (const orphan of report.orphaned ?? []) lines.push(orphanedLine(orphan));
63
105
  lines.push(summaryLine(report));
64
106
  return lines;
@@ -101,11 +143,7 @@ var Report = class {
101
143
  message: finding.message
102
144
  }))
103
145
  })),
104
- ...report.catalog !== void 0 ? { catalog: {
105
- path: report.catalog.path,
106
- entries: report.catalog.entries,
107
- outcome: report.catalog.outcome
108
- } } : {},
146
+ ...report.catalog !== void 0 ? { catalog: catalogJson(report.catalog) } : {},
109
147
  ...report.orphaned !== void 0 ? { orphaned: report.orphaned } : {},
110
148
  drifted: report.drifted,
111
149
  gateFailed: report.gateFailed,
@@ -139,19 +177,31 @@ var Report = class {
139
177
  schema.change,
140
178
  schema.outcome
141
179
  ]));
142
- if (report.catalog !== void 0) lines.push("", tableRow([
143
- "catalog",
144
- "entries",
145
- "outcome"
146
- ]), tableRow([
147
- "---",
148
- "---",
149
- "---"
150
- ]), tableRow([
151
- report.catalog.path,
152
- String(report.catalog.entries),
153
- report.catalog.outcome
154
- ]));
180
+ if (report.catalog !== void 0) {
181
+ const { slice, merged } = report.catalog;
182
+ lines.push("", tableRow([
183
+ "catalog",
184
+ "file",
185
+ "entries",
186
+ "outcome"
187
+ ]), tableRow([
188
+ "---",
189
+ "---",
190
+ "---",
191
+ "---"
192
+ ]), ...slice !== void 0 ? [tableRow([
193
+ "slice",
194
+ slice.path,
195
+ String(slice.entries),
196
+ slice.caseFoldedMatch !== void 0 ? `${slice.outcome} (case-folded match: ${slice.caseFoldedMatch})` : slice.outcome
197
+ ])] : [], ...merged !== void 0 ? [tableRow([
198
+ "merged",
199
+ merged.path,
200
+ String(merged.entries),
201
+ merged.outcome
202
+ ])] : []);
203
+ if (merged !== void 0 && (merged.conflicts.length > 0 || merged.invalid.length > 0)) lines.push("", tableRow(["catalog problem", "slices"]), tableRow(["---", "---"]), ...merged.conflicts.map((conflict) => tableRow([`url ${conflict.url}`, conflict.slices.join(", ")])), ...merged.invalid.map(({ path, reason }) => tableRow([`invalid: ${reason}`, path])));
204
+ }
155
205
  if (report.orphaned !== void 0) lines.push("", tableRow(["orphaned document", "claimed by"]), tableRow(["---", "---"]), ...report.orphaned.map((orphan) => tableRow([orphan, "nothing — delete by hand"])));
156
206
  lines.push("");
157
207
  if (report.gateFailed) {
package/Runner.js CHANGED
@@ -1,5 +1,6 @@
1
+ import { ConfigLoader } from "./ConfigLoader.js";
1
2
  import { CanonicalJson, CatalogEntry, DriftPolicy, SchemaPipeline, SchemaVersioning } from "@effected/schemastore";
2
- import { Effect, FileSystem, Option, Path, Schema } from "effect";
3
+ import { Effect, FileSystem, Option, Path, Result, Schema } from "effect";
3
4
 
4
5
  //#region src/Runner.ts
5
6
  /**
@@ -65,6 +66,32 @@ mismatched: Schema.Array(Schema.Struct({
65
66
  return `${this.mismatched.length} frozen version(s) on disk do not carry their derived $id; nothing was written.\n${lines.join("\n")}`;
66
67
  }
67
68
  };
69
+ /**
70
+ * `catalogDir` exists but cannot be listed — it is a file, or the listing
71
+ * failed (a permission failure, say). Every `*.json` file in `catalogDir` is
72
+ * a catalog slice, so without the listing there is no merged catalog to
73
+ * compute. Raised by {@link Runner.run} before anything is generated or
74
+ * written; a config problem, not a run outcome.
75
+ *
76
+ * @public
77
+ */
78
+ var CatalogDirError = class extends Schema.TaggedError()("CatalogDirError", {
79
+ /** The `catalogDir` that could not be listed. */
80
+ path: Schema.String,
81
+ /** Why, in words: `not a directory` for a file, `permission denied`, or the platform's own description. */
82
+ reason: Schema.String
83
+ }) {
84
+ get message() {
85
+ return `catalogDir ${this.path} cannot be listed (${this.reason}); it must be a directory holding only catalog slices, or not exist yet. Nothing was written.`;
86
+ }
87
+ };
88
+ const listingFailureReason = (error) => {
89
+ switch (error.reason._tag) {
90
+ case "BadResource": return "not a directory";
91
+ case "PermissionDenied": return "permission denied";
92
+ default: return error.reason.description ?? error.reason._tag;
93
+ }
94
+ };
68
95
  const pipelineOptions = { contractChanges: "allow" };
69
96
  const orNone = (read) => read.pipe(Effect.map(Option.some), Effect.catchIf((error) => error.reason._tag === "NotFound", () => Effect.succeed(Option.none())));
70
97
  const pendingOutcome = (wouldWrite, refused) => !wouldWrite ? "unchanged" : refused ? "held" : "would-write";
@@ -88,6 +115,141 @@ const parsesEqual = (existing, text) => {
88
115
  return false;
89
116
  }
90
117
  };
118
+ const CatalogEntries = Schema.Array(CatalogEntry);
119
+ const decodeSlice = (text) => {
120
+ let parsed;
121
+ try {
122
+ parsed = JSON.parse(text);
123
+ } catch {
124
+ return Result.fail("not JSON");
125
+ }
126
+ return Schema.decodeUnknownResult(CatalogEntries)(parsed, {
127
+ onExcessProperty: "error",
128
+ errors: "all"
129
+ }).pipe(Result.mapError((error) => error.message.replace(/\n\s+at /g, " at ").replace(/\n/g, "; ")));
130
+ };
131
+ const byCodeUnit = (a, b) => a < b ? -1 : a > b ? 1 : 0;
132
+ const syncFile = Effect.fn("Runner.syncFile")(function* (file, text, writing, refused) {
133
+ const fs = yield* FileSystem.FileSystem;
134
+ const path = yield* Path.Path;
135
+ const existing = yield* orNone(fs.readFileString(file));
136
+ const same = Option.isSome(existing) && parsesEqual(existing.value, text);
137
+ const outcome = writing && !same ? "written" : pendingOutcome(!same, refused);
138
+ if (outcome === "written") {
139
+ yield* fs.makeDirectory(path.dirname(file), { recursive: true });
140
+ yield* fs.writeFileString(file, text);
141
+ }
142
+ return outcome;
143
+ });
144
+ const encodeEntries = (entries) => CanonicalJson.serialize(entries.map((entry) => Schema.encodeSync(CatalogEntry)(entry)));
145
+ const syncCatalog = Effect.fn("Runner.syncCatalog")(function* (config, names, writing, refused) {
146
+ const fs = yield* FileSystem.FileSystem;
147
+ const path = yield* Path.Path;
148
+ const slicePath = path.normalize(ConfigLoader.slicePath(config, path));
149
+ const mergedPath = path.normalize(ConfigLoader.mergedCatalogPath(config, path));
150
+ const own = config.schemas.flatMap((schema) => schema.catalog !== void 0 ? [schema.catalog] : []);
151
+ const exact = `${config.name}.json`;
152
+ const folded = names.filter((name) => name.toLowerCase() === exact.toLowerCase());
153
+ const ownOnDisk = names.includes(exact) ? exact : own.length > 0 && folded.length === 1 && Result.isSuccess(yield* Effect.result(fs.stat(slicePath))) ? folded[0] : void 0;
154
+ const caseFolded = own.length > 0 && ownOnDisk !== void 0 && ownOnDisk !== exact ? { caseFoldedMatch: path.normalize(path.join(config.catalogDir, ownOnDisk)) } : {};
155
+ let slice;
156
+ if (own.length === 0) {
157
+ if (Option.isSome(yield* orNone(fs.stat(slicePath)))) slice = {
158
+ path: slicePath,
159
+ entries: 0,
160
+ outcome: "orphaned"
161
+ };
162
+ } else {
163
+ const outcome = yield* syncFile(slicePath, yield* encodeEntries(own), writing, refused);
164
+ slice = {
165
+ path: slicePath,
166
+ entries: own.length,
167
+ outcome,
168
+ ...caseFolded
169
+ };
170
+ }
171
+ const sources = [];
172
+ const invalid = [];
173
+ for (const name of [...names].sort(byCodeUnit)) {
174
+ const file = path.normalize(path.join(config.catalogDir, name));
175
+ if (!name.endsWith(".json") || name === ownOnDisk && own.length > 0) continue;
176
+ const info = yield* Effect.result(fs.stat(file));
177
+ if (Result.isFailure(info)) {
178
+ if (info.failure.reason._tag !== "NotFound") invalid.push({
179
+ path: file,
180
+ reason: `unreadable: ${info.failure.reason._tag}`
181
+ });
182
+ else if (Result.isSuccess(yield* Effect.result(fs.readLink(file)))) invalid.push({
183
+ path: file,
184
+ reason: "unreadable: a dangling symlink"
185
+ });
186
+ continue;
187
+ }
188
+ if (info.success.type !== "File") continue;
189
+ const text = yield* Effect.result(fs.readFileString(file));
190
+ if (Result.isFailure(text)) {
191
+ if (text.failure.reason._tag !== "NotFound") invalid.push({
192
+ path: file,
193
+ reason: `unreadable: ${text.failure.reason._tag}`
194
+ });
195
+ continue;
196
+ }
197
+ const decoded = decodeSlice(text.success);
198
+ if (Result.isFailure(decoded)) invalid.push({
199
+ path: file,
200
+ reason: decoded.failure
201
+ });
202
+ else sources.push({
203
+ slice: file,
204
+ entries: decoded.success
205
+ });
206
+ }
207
+ if (own.length > 0) sources.push({
208
+ slice: slicePath,
209
+ entries: own
210
+ });
211
+ sources.sort((a, b) => byCodeUnit(a.slice, b.slice));
212
+ let merged;
213
+ if (sources.length === 0 && invalid.length === 0) {
214
+ if (Option.isSome(yield* orNone(fs.stat(mergedPath)))) merged = {
215
+ path: mergedPath,
216
+ entries: 0,
217
+ outcome: "orphaned",
218
+ slices: [],
219
+ conflicts: [],
220
+ invalid: []
221
+ };
222
+ } else {
223
+ const claims = /* @__PURE__ */ new Map();
224
+ const union = [];
225
+ for (const source of sources) for (const entry of source.entries) {
226
+ const claimants = claims.get(entry.url);
227
+ if (claimants === void 0) {
228
+ claims.set(entry.url, [source.slice]);
229
+ union.push(entry);
230
+ } else claimants.push(source.slice);
231
+ }
232
+ const conflicts = [...claims].filter(([, slices]) => slices.length > 1).map(([url, slices]) => ({
233
+ url,
234
+ slices: [...slices].sort(byCodeUnit)
235
+ })).sort((a, b) => byCodeUnit(a.url, b.url));
236
+ union.sort((a, b) => byCodeUnit(a.url, b.url));
237
+ const outcome = conflicts.length > 0 || invalid.length > 0 ? "blocked" : yield* syncFile(mergedPath, yield* encodeEntries(union), writing, refused);
238
+ merged = {
239
+ path: mergedPath,
240
+ entries: union.length,
241
+ outcome,
242
+ slices: sources.map((source) => source.slice),
243
+ conflicts,
244
+ invalid
245
+ };
246
+ }
247
+ const report = {
248
+ ...slice !== void 0 ? { slice } : {},
249
+ ...merged !== void 0 ? { merged } : {}
250
+ };
251
+ return slice === void 0 && merged === void 0 ? void 0 : report;
252
+ });
91
253
  /**
92
254
  * The shared `build` / `check` walk: verify every advertised frozen version
93
255
  * exists, classify every current target through {@link DriftPolicy} over
@@ -116,16 +278,42 @@ const parsesEqual = (existing, text) => {
116
278
  * both modes, since `check` reports what `build` would do under the same
117
279
  * flags. Both modes share one `SchemaFile`; the single `writing` predicate
118
280
  * (`mode === "build" && !refused`) gates every write, schemas and the
119
- * catalog file alike. Under `onDrift: "warn"` drifting schemas are written
281
+ * catalog files alike. Under `onDrift: "warn"` drifting schemas are written
120
282
  * and keep their `"drift"` verdict for the renderer to shout about.
121
283
  *
122
- * **Every catalog entry the config declares lands in ONE file** at
123
- * `config.catalogPath` — never one file per schema — serialized canonically
124
- * and compared by parsed content against the file on disk, written only
125
- * when different and only when the run is writing. When no schema declares
126
- * one, nothing is written; a file still at `catalogPath` is reported
127
- * `orphaned` (stale under `check`) and left in place, and the report omits
128
- * `catalog` entirely only when there is no such file either.
284
+ * **Every catalog entry the config declares lands in ONE slice file**,
285
+ * `<catalogDir>/<name>.json` — a bare SchemaStore catalog entry array,
286
+ * never one file per schema — serialized canonically, compared by parsed
287
+ * content against the file on disk, and rewritten wholesale only when
288
+ * different and only when the run is writing. When no schema declares one,
289
+ * no slice is written; a slice still on disk is reported `orphaned` (stale
290
+ * under `check`) and left in place — and still merged, so the merged
291
+ * catalog keeps advertising its entries until it is deleted by hand.
292
+ *
293
+ * **The merged catalog is the union of every slice in `catalogDir`**,
294
+ * written to `catalog.json` in `catalogDir`'s parent, sorted by entry
295
+ * `url`, and compared and written by the same rule. When the running
296
+ * config declares entries, its slice is replaced by the entries it
297
+ * computes now, so `check` compares against what a build would produce;
298
+ * when it declares none, its on-disk slice is merged as-is. The merge is
299
+ * therefore a pure function of disk plus the non-empty fresh entries, and
300
+ * whichever config sharing the directory builds last writes the identical
301
+ * file. A URL advertised by two
302
+ * slices, or a slice that is not JSON or not a catalog entry array, is
303
+ * reported and blocks the merged write (`blocked`) rather than being
304
+ * silently merged or dropped. With no slice file left in `catalogDir` and
305
+ * no entry declared, a merged file still on disk is `orphaned`.
306
+ * `catalogDir` is the one directory the Runner lists: every `*.json` file
307
+ * in it is a slice by construction. It is listed before anything is
308
+ * generated; one that is a file or cannot be listed fails typed with
309
+ * {@link CatalogDirError}, nothing written. The running config's own slice
310
+ * on disk is an exact `<name>.json` match, or — only when there is none,
311
+ * exactly one listed file case-folds to it, and the volume itself resolves
312
+ * the exact path to that file (a `stat` that succeeds, i.e. a
313
+ * case-insensitive volume) — that one file, reported as
314
+ * {@link CatalogSliceReport.caseFoldedMatch}. On a case-sensitive volume the
315
+ * variant is another slice: a case-only rename leftover blocks the merge on
316
+ * the first build like any other rename leftover.
129
317
  *
130
318
  * **A moved path leaves an orphan the derivation cannot see**: an
131
319
  * `appendVersion` flip or a `layout` change renames a document's derived
@@ -135,7 +323,7 @@ const parsesEqual = (existing, text) => {
135
323
  * name and label (`<name>.json`, `<name>-<v>.json`, `<v>/<name>.json`,
136
324
  * `<v>/<name>-<v>.json`), so both modes probe the sibling shapes of every
137
325
  * 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
326
+ * and that no target, frozen version, catalog slice, or merged catalog claims in
139
327
  * {@link RunReport.orphaned}. Nothing else on disk is looked at: an
140
328
  * `outputDir` shared with another config, a deploy folder, or the
141
329
  * repository root holds documents this config cannot tell from its own
@@ -182,6 +370,12 @@ var Runner = class {
182
370
  }
183
371
  if (missing.length > 0) return yield* Effect.fail(new FrozenVersionMissingError({ missing }));
184
372
  if (mismatched.length > 0) return yield* Effect.fail(new FrozenVersionIdMismatchError({ mismatched }));
373
+ const listing = yield* Effect.result(fs.readDirectory(config.catalogDir));
374
+ if (Result.isFailure(listing) && listing.failure.reason._tag !== "NotFound") return yield* Effect.fail(new CatalogDirError({
375
+ path: config.catalogDir,
376
+ reason: listingFailureReason(listing.failure)
377
+ }));
378
+ const catalogNames = Result.isSuccess(listing) ? listing.success : [];
185
379
  const targets = config.schemas.map((schema) => schema.target);
186
380
  const checks = yield* SchemaPipeline.check(targets, pipelineOptions);
187
381
  const gateFailed = checks.some((check) => check.blocked);
@@ -225,31 +419,8 @@ var Runner = class {
225
419
  ...nextVersion !== void 0 ? { nextVersion } : {}
226
420
  };
227
421
  });
228
- const entries = config.schemas.flatMap((schema) => schema.catalog !== void 0 ? [schema.catalog] : []);
229
- let catalog;
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 {
238
- const text = yield* CanonicalJson.serialize(entries.map((entry) => Schema.encodeSync(CatalogEntry)(entry)));
239
- const existing = yield* orNone(fs.readFileString(config.catalogPath));
240
- const same = Option.isSome(existing) && parsesEqual(existing.value, text);
241
- const outcome = writing && !same ? "written" : pendingOutcome(!same, refused);
242
- if (outcome === "written") {
243
- yield* fs.makeDirectory(path.dirname(config.catalogPath), { recursive: true });
244
- yield* fs.writeFileString(config.catalogPath, text);
245
- }
246
- catalog = {
247
- path: config.catalogPath,
248
- entries: entries.length,
249
- outcome
250
- };
251
- }
252
- const claimed = /* @__PURE__ */ new Set([path.normalize(config.catalogPath)]);
422
+ const catalog = yield* syncCatalog(config, catalogNames, writing, refused);
423
+ const claimed = /* @__PURE__ */ new Set([path.normalize(ConfigLoader.slicePath(config, path)), path.normalize(ConfigLoader.mergedCatalogPath(config, path))]);
253
424
  for (const schema of config.schemas) {
254
425
  claimed.add(path.normalize(schema.target.path));
255
426
  for (const frozen of schema.frozen) claimed.add(path.normalize(frozen.path));
@@ -279,10 +450,10 @@ var Runner = class {
279
450
  ...orphaned.length > 0 ? { orphaned } : {},
280
451
  drifted,
281
452
  gateFailed,
282
- wrote: schemas.some((s) => s.outcome === "written") || catalog?.outcome === "written"
453
+ wrote: schemas.some((s) => s.outcome === "written") || catalog?.slice?.outcome === "written" || catalog?.merged?.outcome === "written"
283
454
  };
284
455
  });
285
456
  };
286
457
 
287
458
  //#endregion
288
- export { FrozenVersionIdMismatchError, FrozenVersionMissingError, Runner };
459
+ export { CatalogDirError, FrozenVersionIdMismatchError, FrozenVersionMissingError, Runner };
@@ -9,7 +9,7 @@ import { Command } from "effect/cli";
9
9
  *
10
10
  * @public
11
11
  */
12
- const makeBuildCommand = (deps) => Command.make("build", commandFlags, (input) => execute("build", input, deps)).pipe(Command.withDescription("Generate, gate and write every schema and catalog entry the config declares"));
12
+ const makeBuildCommand = (deps) => Command.make("build", commandFlags, (input) => execute("build", input, deps)).pipe(Command.withDescription("Generate, gate and write every schema the config declares, its catalog slice, and the merged catalog"));
13
13
 
14
14
  //#endregion
15
15
  export { makeBuildCommand };
@@ -7,13 +7,13 @@ import { Command } from "effect/cli";
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
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
10
+ * claims (an orphaned catalog slice or merged catalog, or a document left behind at a sibling
11
11
  * shape of a derived path), which `build` never deletes: remove them by
12
12
  * hand.
13
13
  *
14
14
  * @public
15
15
  */
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"));
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 or the merged catalog is blocked, and write nothing"));
17
17
 
18
18
  //#endregion
19
19
  export { makeCheckCommand };
package/cli/execute.js CHANGED
@@ -45,10 +45,35 @@ var GateError = class extends Schema.TaggedError()("GateError", { count: Schema.
45
45
  }
46
46
  };
47
47
  /**
48
+ * The merged catalog could not be assembled: a catalog URL is advertised by
49
+ * more than one slice, or a slice in `catalogDir` is not a catalog entry
50
+ * array. Nothing is merged silently, so the merged catalog was left as it
51
+ * is. Exit `1` under both `build` and `check`: only an edit to the slices
52
+ * or the configs clears it.
53
+ *
54
+ * @public
55
+ */
56
+ var CatalogMergeError = class extends Schema.TaggedError()("CatalogMergeError", {
57
+ path: Schema.String,
58
+ conflicts: Schema.Array(Schema.Struct({
59
+ url: Schema.String,
60
+ slices: Schema.Array(Schema.String)
61
+ })),
62
+ invalid: Schema.Array(Schema.Struct({
63
+ path: Schema.String,
64
+ reason: Schema.String
65
+ }))
66
+ }) {
67
+ get message() {
68
+ const lines = [...this.conflicts.map((conflict) => ` url ${conflict.url} is advertised by ${conflict.slices.join(", ")}`), ...this.invalid.map(({ path, reason }) => ` ${path} is invalid: ${reason}`)];
69
+ return `The merged catalog ${this.path} was not written.\n${lines.join("\n")}\nGive each catalog URL to exactly one config, and fix or delete every invalid slice.`;
70
+ }
71
+ };
72
+ /**
48
73
  * `check` found committed documents that differ from what the config
49
74
  * 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
75
+ * nothing claims (an orphaned catalog slice or merged catalog, an orphaned
76
+ * document), which `build` never deletes. `check` is the CI drift gate, so a stale tree
52
77
  * fails it. Exit `1`. `count` is every finding; `orphaned` the part of it
53
78
  * a build cannot clear, so the message names both remedies.
54
79
  *
@@ -99,9 +124,11 @@ const emit = Effect.fn("schemastore.emit")(function* (report, format) {
99
124
  * `64` — a usage error, not a run outcome. Otherwise loads the config,
100
125
  * applies the flag overrides, runs the shared walk, emits the report in the
101
126
  * requested format, appends the step summary, and fails typed —
102
- * `GateError`, then `DriftError`, then (for `check` only) `StaleError`,
103
- * each carrying exit `1` — when the report says the run refused to write
104
- * or, under `check`, that a build would write. `SchemaFile` is built here
127
+ * `GateError`, then `DriftError`, then `CatalogMergeError`, then (for
128
+ * `check` only) `StaleError`, each carrying exit `1` — when the report says
129
+ * the run refused to write, the merged catalog was blocked by a URL
130
+ * conflict or an invalid slice, or, under `check`, that a build would
131
+ * write. `SchemaFile` is built here
105
132
  * over the environment's `FileSystem`; the validator is `deps.validator` or
106
133
  * the real engine.
107
134
  *
@@ -122,7 +149,8 @@ const execute = Effect.fn("schemastore.execute")(function* (mode, input, deps) {
122
149
  ...drift
123
150
  }).pipe(Effect.provide(SchemaFile.layer), Effect.provide(deps.validator ?? AjvValidator.layer), Effect.catchTags({
124
151
  FrozenVersionMissingError: (error) => Effect.fail(CliRuntime.reported(error, 1)),
125
- FrozenVersionIdMismatchError: (error) => Effect.fail(CliRuntime.reported(error, 1))
152
+ FrozenVersionIdMismatchError: (error) => Effect.fail(CliRuntime.reported(error, 1)),
153
+ CatalogDirError: (error) => Effect.fail(CliRuntime.reported(error, 2))
126
154
  }));
127
155
  yield* emit(report, input.format);
128
156
  yield* StepSummary.append(Report.markdown(report));
@@ -139,9 +167,16 @@ const execute = Effect.fn("schemastore.execute")(function* (mode, input, deps) {
139
167
  }));
140
168
  return yield* Effect.fail(CliRuntime.reported(new DriftError({ drifted }), 1));
141
169
  }
170
+ const merged = report.catalog?.merged;
171
+ if (merged?.outcome === "blocked") return yield* Effect.fail(CliRuntime.reported(new CatalogMergeError({
172
+ path: merged.path,
173
+ conflicts: merged.conflicts,
174
+ invalid: merged.invalid
175
+ }), 1));
142
176
  if (mode === "check") {
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;
177
+ const catalogFiles = [report.catalog?.slice?.outcome, merged?.outcome];
178
+ const orphaned = catalogFiles.filter((outcome) => outcome === "orphaned").length + (report.orphaned?.length ?? 0);
179
+ const count = report.schemas.filter((schema) => schema.outcome === "would-write").length + catalogFiles.filter((outcome) => outcome === "would-write").length + orphaned;
145
180
  if (count > 0) return yield* Effect.fail(CliRuntime.reported(new StaleError({
146
181
  count,
147
182
  ...orphaned > 0 ? { orphaned } : {}
@@ -150,4 +185,4 @@ const execute = Effect.fn("schemastore.execute")(function* (mode, input, deps) {
150
185
  });
151
186
 
152
187
  //#endregion
153
- export { ConflictingFlagsError, DriftError, GateError, StaleError, execute };
188
+ export { CatalogMergeError, ConflictingFlagsError, DriftError, GateError, StaleError, execute };
package/cli/program.js CHANGED
@@ -29,7 +29,8 @@ const trimLoadError = Effect.fn("schemastore.trimLoadError")(function* (error) {
29
29
  * `ShowHelp` is `64` with parse errors and `0` without (help itself was
30
30
  * already rendered); `ConfigNotFoundError` / `ConfigLoadError` are `2`;
31
31
  * `ConflictingFlagsError` arrives already marked `64`; `DriftError` /
32
- * `GateError` / `StaleError` arrive already marked `1`.
32
+ * `GateError` / `CatalogMergeError` / `StaleError` arrive already marked
33
+ * `1`.
33
34
  *
34
35
  * @public
35
36
  */
package/main.js CHANGED
@@ -6,16 +6,11 @@ import { CliRuntime } from "@effected/cli";
6
6
  import { CliError } from "effect/cli";
7
7
 
8
8
  //#region src/main.ts
9
- /**
10
- * The assembled schemastore CLI program.
11
- *
12
- * @packageDocumentation
13
- */
14
9
  const render = (error) => CliError.isCliError(error) && error._tag === "ShowHelp" ? [] : [error instanceof Error ? error.message : String(error)];
15
10
  const main = () => {
16
11
  const run = program(process.argv.slice(2), {
17
12
  cwd: process.cwd(),
18
- version: "0.16.1"
13
+ version: "0.18.0"
19
14
  }).pipe(Effect.provide(NodeServices.layer), CliRuntime.reportFailures({
20
15
  exitCode: 3,
21
16
  render
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effected/schemastore-cli",
3
- "version": "0.16.1",
3
+ "version": "0.18.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,15 +40,18 @@
40
40
  "schemastore": "bin/schemastore.js"
41
41
  },
42
42
  "dependencies": {
43
- "@effect/platform-node": "4.0.0-rc.118",
44
- "@effected/cli": "^0.10.0",
43
+ "@effect/platform-node": "^4.0.0",
44
+ "@effected/cli": "^0.11.0",
45
+ "@effected/env": "^0.1.0",
46
+ "@effected/glob": "^0.10.0",
47
+ "@effected/walker": "^0.15.0",
45
48
  "ajv": "^8.20.0",
46
49
  "ajv-formats": "^3.0.1",
47
50
  "jiti": "^2.6.0"
48
51
  },
49
52
  "peerDependencies": {
50
- "@effected/schemastore": "0.16.1",
51
- "effect": "4.0.0-rc.118"
53
+ "@effected/schemastore": "0.18.0",
54
+ "effect": "^4.0.0"
52
55
  },
53
56
  "engines": {
54
57
  "node": ">=24.11.0"