@effected/schemastore-cli 0.16.0 → 0.17.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 +37 -13
- package/README.md +11 -7
- package/Report.js +78 -28
- package/Runner.js +208 -37
- package/cli/commands/build.js +1 -1
- package/cli/commands/check.js +2 -2
- package/cli/execute.js +44 -9
- package/cli/program.js +2 -1
- package/main.js +1 -1
- package/package.json +2 -2
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,9 +19,11 @@ searched: Schema.Array(Schema.String) }) {
|
|
|
19
19
|
/**
|
|
20
20
|
* The config file exists but could not be turned into a `SchemastoreConfig`:
|
|
21
21
|
* the module threw on import, its default export is not a `defineConfig(...)`
|
|
22
|
-
* value, `
|
|
23
|
-
* not resolved-schema-shaped (or its
|
|
24
|
-
* entry is not shaped),
|
|
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
|
*/
|
|
@@ -38,8 +40,9 @@ const describeCause = (cause) => cause instanceof Error ? cause.stack ?? cause.m
|
|
|
38
40
|
const isTargetShaped = (target) => Predicate.isObject(target) && Schema.isSchema(target.schema) && typeof target.$id === "string" && typeof target.path === "string" && typeof target.published === "boolean";
|
|
39
41
|
const isCatalogEntryShaped = (catalog) => Predicate.isObject(catalog) && typeof catalog.name === "string" && typeof catalog.description === "string" && Array.isArray(catalog.fileMatch) && typeof catalog.url === "string";
|
|
40
42
|
const describeMalformed = (config) => {
|
|
43
|
+
if (typeof config.name !== "string" || !SchemaVersioning.isSimpleName(config.name)) return "name is not a simple file base name";
|
|
41
44
|
if (typeof config.outputDir !== "string") return "outputDir is not a string";
|
|
42
|
-
if (typeof config.
|
|
45
|
+
if (typeof config.catalogDir !== "string") return "catalogDir is not a string";
|
|
43
46
|
if (!Array.isArray(config.schemas)) return "schemas is not an array";
|
|
44
47
|
for (const [index, schema] of config.schemas.entries()) {
|
|
45
48
|
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 +51,21 @@ const describeMalformed = (config) => {
|
|
|
48
51
|
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
52
|
}
|
|
50
53
|
};
|
|
51
|
-
const
|
|
54
|
+
const describeClashingPath = (config, path) => {
|
|
55
|
+
const documents = config.schemas.flatMap((schema) => [schema.target.path, ...schema.frozen.map((f) => f.path)]);
|
|
56
|
+
const catalogDir = path.normalize(config.catalogDir);
|
|
57
|
+
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)`;
|
|
58
|
+
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`;
|
|
59
|
+
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
60
|
const seen = /* @__PURE__ */ new Set();
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
61
|
+
for (const p of [
|
|
62
|
+
...documents,
|
|
63
|
+
ConfigLoader.slicePath(config, path),
|
|
64
|
+
ConfigLoader.mergedCatalogPath(config, path)
|
|
65
|
+
]) {
|
|
66
|
+
const normalized = path.normalize(p);
|
|
67
|
+
if (seen.has(normalized)) return `output path "${p}" is declared twice after resolution`;
|
|
68
|
+
seen.add(normalized);
|
|
57
69
|
}
|
|
58
70
|
};
|
|
59
71
|
/**
|
|
@@ -98,8 +110,20 @@ var ConfigLoader = class ConfigLoader {
|
|
|
98
110
|
dir = parent;
|
|
99
111
|
}
|
|
100
112
|
});
|
|
113
|
+
/** This config's own catalog slice: `<catalogDir>/<name>.json`. */
|
|
114
|
+
static slicePath(config, path) {
|
|
115
|
+
return path.join(config.catalogDir, `${config.name}.json`);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* The merged catalog every config sharing `catalogDir` maintains:
|
|
119
|
+
* `catalog.json` in `catalogDir`'s parent (`<outputDir>/catalog.json` under
|
|
120
|
+
* the default `catalogDir`).
|
|
121
|
+
*/
|
|
122
|
+
static mergedCatalogPath(config, path) {
|
|
123
|
+
return path.join(path.dirname(config.catalogDir), "catalog.json");
|
|
124
|
+
}
|
|
101
125
|
/**
|
|
102
|
-
* Resolve every relative `path` in the config (`outputDir`, `
|
|
126
|
+
* Resolve every relative `path` in the config (`outputDir`, `catalogDir`,
|
|
103
127
|
* each schema's current target, and every frozen predecessor) against
|
|
104
128
|
* `directory`; absolute paths are left alone. `defineConfig` already
|
|
105
129
|
* prefixes `outputDir` onto every target/frozen `path`, so resolving them
|
|
@@ -112,7 +136,7 @@ var ConfigLoader = class ConfigLoader {
|
|
|
112
136
|
return {
|
|
113
137
|
...config,
|
|
114
138
|
outputDir: absolute(config.outputDir),
|
|
115
|
-
|
|
139
|
+
catalogDir: absolute(config.catalogDir),
|
|
116
140
|
schemas: config.schemas.map((schema) => ({
|
|
117
141
|
...schema,
|
|
118
142
|
target: {
|
|
@@ -155,7 +179,7 @@ var ConfigLoader = class ConfigLoader {
|
|
|
155
179
|
}));
|
|
156
180
|
const directory = path.dirname(configPath);
|
|
157
181
|
const config = yield* ConfigLoader.resolvePaths(exported, directory);
|
|
158
|
-
const duplicate =
|
|
182
|
+
const duplicate = describeClashingPath(config, path);
|
|
159
183
|
if (duplicate !== void 0) return yield* Effect.fail(new ConfigLoadError({
|
|
160
184
|
path: configPath,
|
|
161
185
|
reason: duplicate
|
package/README.md
CHANGED
|
@@ -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
|
|
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
|
-
- `
|
|
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
|
|
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
|
|
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
|
|
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
|
|
144
|
-
| 2 | config not found, failed to load,
|
|
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
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
case "
|
|
25
|
-
case "
|
|
26
|
-
case "
|
|
27
|
-
|
|
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
|
|
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(
|
|
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)
|
|
143
|
-
|
|
144
|
-
"
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
|
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
|
|
123
|
-
* `
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
* `
|
|
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
|
|
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
|
|
229
|
-
|
|
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 };
|
package/cli/commands/build.js
CHANGED
|
@@ -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
|
|
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 };
|
package/cli/commands/check.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
103
|
-
* each carrying exit `1` — when the report says
|
|
104
|
-
*
|
|
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
|
|
144
|
-
const
|
|
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
|
|
32
|
+
* `GateError` / `CatalogMergeError` / `StaleError` arrive already marked
|
|
33
|
+
* `1`.
|
|
33
34
|
*
|
|
34
35
|
* @public
|
|
35
36
|
*/
|
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.
|
|
18
|
+
version: "0.17.0"
|
|
19
19
|
}).pipe(Effect.provide(NodeServices.layer), CliRuntime.reportFailures({
|
|
20
20
|
exitCode: 3,
|
|
21
21
|
render
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effected/schemastore-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.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": [
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"jiti": "^2.6.0"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
|
-
"@effected/schemastore": "0.
|
|
50
|
+
"@effected/schemastore": "0.17.0",
|
|
51
51
|
"effect": "4.0.0-rc.118"
|
|
52
52
|
},
|
|
53
53
|
"engines": {
|