@goodbones/core 0.1.0-beta.1 → 0.1.0-beta.3
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/build/dts/domain/manifest-location.d.ts +9 -0
- package/build/dts/domain/manifest-location.d.ts.map +1 -0
- package/build/dts/index.d.ts +6 -2
- package/build/dts/index.d.ts.map +1 -1
- package/build/dts/infrastructure/manifest-file.d.ts +11 -2
- package/build/dts/infrastructure/manifest-file.d.ts.map +1 -1
- package/build/dts/infrastructure/manifest-include.d.ts +16 -0
- package/build/dts/infrastructure/manifest-include.d.ts.map +1 -0
- package/build/dts/load/policy.d.ts +2 -0
- package/build/dts/load/policy.d.ts.map +1 -1
- package/build/dts/manifest/expand.d.ts +26 -0
- package/build/dts/manifest/expand.d.ts.map +1 -0
- package/build/dts/manifest/json-schema.d.ts +10 -0
- package/build/dts/manifest/json-schema.d.ts.map +1 -0
- package/build/dts/manifest/manifest.d.ts +5 -1
- package/build/dts/manifest/manifest.d.ts.map +1 -1
- package/build/esm/domain/manifest-location.js +21 -0
- package/build/esm/domain/manifest-location.js.map +1 -0
- package/build/esm/index.js +5 -1
- package/build/esm/index.js.map +1 -1
- package/build/esm/infrastructure/manifest-file.js +158 -7
- package/build/esm/infrastructure/manifest-file.js.map +1 -1
- package/build/esm/infrastructure/manifest-include.js +187 -0
- package/build/esm/infrastructure/manifest-include.js.map +1 -0
- package/build/esm/load/policy.js +1 -1
- package/build/esm/load/policy.js.map +1 -1
- package/build/esm/manifest/expand.js +111 -0
- package/build/esm/manifest/expand.js.map +1 -0
- package/build/esm/manifest/json-schema.js +131 -0
- package/build/esm/manifest/json-schema.js.map +1 -0
- package/build/esm/manifest/manifest.js +58 -4
- package/build/esm/manifest/manifest.js.map +1 -1
- package/package.json +7 -3
- package/schema/architecture-node.schema.json +1518 -0
- package/schema/architecture.schema.json +1500 -0
- package/src/domain/manifest-location.ts +41 -0
- package/src/index.ts +34 -1
- package/src/infrastructure/manifest-file.ts +204 -8
- package/src/infrastructure/manifest-include.ts +318 -0
- package/src/load/policy.ts +5 -1
- package/src/manifest/expand.ts +174 -0
- package/src/manifest/json-schema.ts +164 -0
- package/src/manifest/manifest.ts +101 -9
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import * as Result from "effect/Result";
|
|
2
|
+
|
|
3
|
+
import type { ManifestPath } from "../domain/manifest-location.js";
|
|
4
|
+
|
|
5
|
+
// Reuse inside a manifest, in the manifest's own schema rather than the
|
|
6
|
+
// format's. A top-level `defs` map names fragments; `{ use: "<name>" }`
|
|
7
|
+
// anywhere in the rest of the document is replaced by a deep copy of the
|
|
8
|
+
// fragment. This runs on the raw value before decoding, so it works the same
|
|
9
|
+
// in YAML, in JSON, and in a JavaScript module that chose to write it — and
|
|
10
|
+
// the schema that decodes the result never has to know a reference existed.
|
|
11
|
+
//
|
|
12
|
+
// There is deliberately nothing else here: no interpolation, no includes, no
|
|
13
|
+
// deep merge. A fragment that needs partial override is two fragments; a
|
|
14
|
+
// manifest that needs more than a data format offers needs a generator, and
|
|
15
|
+
// a generator can emit YAML.
|
|
16
|
+
|
|
17
|
+
export type ExpandIssue = {
|
|
18
|
+
readonly path: ManifestPath;
|
|
19
|
+
readonly detail: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// One `use` that was expanded. `at` is where the fragment landed in the
|
|
23
|
+
// expanded document; `ref` is where the reference was written in the original
|
|
24
|
+
// one; `overrides` are the keys written beside `use`, which came from the
|
|
25
|
+
// reference site rather than from the fragment.
|
|
26
|
+
export type Substitution = {
|
|
27
|
+
readonly at: ManifestPath;
|
|
28
|
+
readonly ref: ManifestPath;
|
|
29
|
+
readonly name: string;
|
|
30
|
+
readonly overrides: ReadonlySet<string>;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type ExpandedManifest = {
|
|
34
|
+
readonly value: unknown;
|
|
35
|
+
readonly substitutions: ReadonlyArray<Substitution>;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Where a path in the expanded document was written in the original one. When
|
|
39
|
+
// the path crosses a `use`, `via` lists each reference it passed through,
|
|
40
|
+
// outermost first, so an error inside a fragment can name the line that pulled
|
|
41
|
+
// the fragment in as well as the fragment itself.
|
|
42
|
+
export type Origin = {
|
|
43
|
+
readonly path: ManifestPath;
|
|
44
|
+
readonly via: ReadonlyArray<{ readonly at: ManifestPath; readonly name: string }>;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
48
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
49
|
+
|
|
50
|
+
const isReference = (value: unknown): value is Record<string, unknown> & { readonly use: string } =>
|
|
51
|
+
isRecord(value) && typeof value.use === "string";
|
|
52
|
+
|
|
53
|
+
export const expandManifest = (input: unknown): Result.Result<ExpandedManifest, ExpandIssue> => {
|
|
54
|
+
if (!isRecord(input)) return Result.succeed({ value: input, substitutions: [] });
|
|
55
|
+
|
|
56
|
+
// Two keys the file may carry that the schema does not: the fragments, and
|
|
57
|
+
// the `$schema` a JSON author writes for editor validation.
|
|
58
|
+
const { $schema: _schema, defs, ...rest } = input;
|
|
59
|
+
if (defs !== undefined && !isRecord(defs)) {
|
|
60
|
+
return Result.fail({
|
|
61
|
+
path: ["defs"],
|
|
62
|
+
detail: "`defs` must be a map of named fragments.",
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
const fragments: Record<string, unknown> = defs ?? {};
|
|
66
|
+
const defined = Object.keys(fragments);
|
|
67
|
+
const substitutions: Array<Substitution> = [];
|
|
68
|
+
|
|
69
|
+
// `at` is the path in the document being built; `origin` the path in the
|
|
70
|
+
// document as written; `stack` the fragments currently being expanded, for
|
|
71
|
+
// the cycle check.
|
|
72
|
+
const walk = (
|
|
73
|
+
value: unknown,
|
|
74
|
+
at: ManifestPath,
|
|
75
|
+
origin: ManifestPath,
|
|
76
|
+
stack: ReadonlyArray<string>,
|
|
77
|
+
): Result.Result<unknown, ExpandIssue> => {
|
|
78
|
+
if (isReference(value)) {
|
|
79
|
+
const { use: name, ...overrides } = value;
|
|
80
|
+
if (!(name in fragments)) {
|
|
81
|
+
return Result.fail({
|
|
82
|
+
path: origin,
|
|
83
|
+
detail:
|
|
84
|
+
`\`use: ${JSON.stringify(name)}\` names no entry in \`defs\`` +
|
|
85
|
+
(defined.length === 0
|
|
86
|
+
? " — the manifest defines none."
|
|
87
|
+
: ` (defined: ${defined.join(", ")}).`),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
if (stack.includes(name)) {
|
|
91
|
+
return Result.fail({
|
|
92
|
+
path: origin,
|
|
93
|
+
detail: `\`defs\` contains a cycle: ${[...stack, name].join(" → ")}.`,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
substitutions.push({ at, ref: origin, name, overrides: new Set(Object.keys(overrides)) });
|
|
97
|
+
|
|
98
|
+
const fragment = walk(fragments[name], at, ["defs", name], [...stack, name]);
|
|
99
|
+
if (Result.isFailure(fragment)) return fragment;
|
|
100
|
+
if (Object.keys(overrides).length === 0) return fragment;
|
|
101
|
+
|
|
102
|
+
if (!isRecord(fragment.success)) {
|
|
103
|
+
return Result.fail({
|
|
104
|
+
path: origin,
|
|
105
|
+
detail:
|
|
106
|
+
`\`use: ${JSON.stringify(name)}\` is written with overrides ` +
|
|
107
|
+
`(${Object.keys(overrides).join(", ")}), but \`defs.${name}\` is not an object, ` +
|
|
108
|
+
`so there is nothing to override.`,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
const merged: Record<string, unknown> = { ...fragment.success };
|
|
112
|
+
for (const [key, override] of Object.entries(overrides)) {
|
|
113
|
+
const expanded = walk(override, [...at, key], [...origin, key], stack);
|
|
114
|
+
if (Result.isFailure(expanded)) return expanded;
|
|
115
|
+
merged[key] = expanded.success;
|
|
116
|
+
}
|
|
117
|
+
return Result.succeed(merged);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (Array.isArray(value)) {
|
|
121
|
+
const items: Array<unknown> = [];
|
|
122
|
+
for (const [index, item] of value.entries()) {
|
|
123
|
+
const expanded = walk(item, [...at, index], [...origin, index], stack);
|
|
124
|
+
if (Result.isFailure(expanded)) return expanded;
|
|
125
|
+
items.push(expanded.success);
|
|
126
|
+
}
|
|
127
|
+
return Result.succeed(items);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (isRecord(value)) {
|
|
131
|
+
const entries: Record<string, unknown> = {};
|
|
132
|
+
for (const [key, item] of Object.entries(value)) {
|
|
133
|
+
const expanded = walk(item, [...at, key], [...origin, key], stack);
|
|
134
|
+
if (Result.isFailure(expanded)) return expanded;
|
|
135
|
+
entries[key] = expanded.success;
|
|
136
|
+
}
|
|
137
|
+
return Result.succeed(entries);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return Result.succeed(value);
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const expanded = walk(rest, [], [], []);
|
|
144
|
+
if (Result.isFailure(expanded)) return Result.fail(expanded.failure);
|
|
145
|
+
return Result.succeed({ value: expanded.success, substitutions });
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const isPrefix = (prefix: ManifestPath, path: ManifestPath): boolean =>
|
|
149
|
+
prefix.length <= path.length && prefix.every((segment, index) => path[index] === segment);
|
|
150
|
+
|
|
151
|
+
// Maps a path in the expanded document back to where it was written.
|
|
152
|
+
export const originOf = (
|
|
153
|
+
substitutions: ReadonlyArray<Substitution>,
|
|
154
|
+
path: ManifestPath,
|
|
155
|
+
): Origin => {
|
|
156
|
+
const crossed = substitutions
|
|
157
|
+
.filter((one) => isPrefix(one.at, path))
|
|
158
|
+
.sort((a, b) => a.at.length - b.at.length);
|
|
159
|
+
const innermost = crossed.at(-1);
|
|
160
|
+
if (innermost === undefined) return { path, via: [] };
|
|
161
|
+
|
|
162
|
+
const outer = crossed.slice(0, -1).map((one) => ({ at: one.ref, name: one.name }));
|
|
163
|
+
const rest = path.slice(innermost.at.length);
|
|
164
|
+
const first = rest[0];
|
|
165
|
+
|
|
166
|
+
// A key written beside `use` belongs to the reference site, not the fragment.
|
|
167
|
+
if (typeof first === "string" && innermost.overrides.has(first)) {
|
|
168
|
+
return { path: [...innermost.ref, ...rest], via: outer };
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
path: ["defs", innermost.name, ...rest],
|
|
172
|
+
via: [...outer, { at: innermost.ref, name: innermost.name }],
|
|
173
|
+
};
|
|
174
|
+
};
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import * as Schema from "effect/Schema";
|
|
2
|
+
|
|
3
|
+
import { Manifest } from "./manifest.js";
|
|
4
|
+
|
|
5
|
+
// The manifest's shape as a JSON Schema, generated from the same codec that
|
|
6
|
+
// decodes it, so the two cannot disagree. A YAML file names it in a header
|
|
7
|
+
// comment and a JSON file in a `$schema` key; either way the editor completes
|
|
8
|
+
// keys and flags a misspelled one before the loader ever runs.
|
|
9
|
+
//
|
|
10
|
+
// Three things the codec does not know are added here: the `defs` map, the
|
|
11
|
+
// `{ use }` reference form that may stand in for any object, and the
|
|
12
|
+
// `{ include }` form that may stand in for any object or list — all belong to
|
|
13
|
+
// the passes that run before decoding.
|
|
14
|
+
|
|
15
|
+
export const MANIFEST_SCHEMA_ID =
|
|
16
|
+
"https://dataquail.github.io/goodbones/schema/architecture.schema.json";
|
|
17
|
+
|
|
18
|
+
// The schema an included file names: one node of the tree, with the two keys
|
|
19
|
+
// a file of its own may carry at the top.
|
|
20
|
+
export const MANIFEST_NODE_SCHEMA_ID =
|
|
21
|
+
"https://dataquail.github.io/goodbones/schema/architecture-node.schema.json";
|
|
22
|
+
|
|
23
|
+
type JsonValue = string | number | boolean | null | JsonObject | ReadonlyArray<JsonValue>;
|
|
24
|
+
type JsonObject = { readonly [key: string]: JsonValue };
|
|
25
|
+
|
|
26
|
+
const entriesOf = (value: JsonObject): ReadonlyArray<readonly [string, JsonValue]> =>
|
|
27
|
+
Object.entries(value);
|
|
28
|
+
|
|
29
|
+
const isObject = (value: JsonValue): value is JsonObject =>
|
|
30
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
31
|
+
|
|
32
|
+
const isList = (value: JsonValue): value is ReadonlyArray<JsonValue> => Array.isArray(value);
|
|
33
|
+
|
|
34
|
+
// The generator names the recursive node after its own internal wrapper. A
|
|
35
|
+
// stable name is what a `$ref` in an error message or a docs page can point at.
|
|
36
|
+
const DEFINITION_NAMES: Readonly<Record<string, string>> = { Suspend_: "ManifestNode" };
|
|
37
|
+
|
|
38
|
+
const USE_REFERENCE = "#/$defs/Use";
|
|
39
|
+
const INCLUDE_REFERENCE = "#/$defs/Include";
|
|
40
|
+
|
|
41
|
+
const Use: JsonObject = {
|
|
42
|
+
type: "object",
|
|
43
|
+
description:
|
|
44
|
+
"A reference to a fragment under the top-level `defs`. Replaced by a copy of the fragment before the manifest is decoded; any other key written beside `use` overrides the fragment's key of the same name.",
|
|
45
|
+
properties: { use: { type: "string" } },
|
|
46
|
+
required: ["use"],
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const Include: JsonObject = {
|
|
50
|
+
type: "object",
|
|
51
|
+
description:
|
|
52
|
+
"A reference to another YAML or JSON file, relative to this one. Replaced by that file's whole value before the manifest is decoded; a list item naming a file that holds a list is spliced in. Nothing may be written beside `include`.",
|
|
53
|
+
properties: { include: { type: "string" } },
|
|
54
|
+
required: ["include"],
|
|
55
|
+
additionalProperties: false,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// Every object schema below the root becomes "this object, or a `use` of a
|
|
59
|
+
// fragment shaped like it, or an `include` of a file holding one", and every
|
|
60
|
+
// list schema "this list, or an `include` of a file holding one". The
|
|
61
|
+
// expansion passes replace a reference wherever it stands, so the schema
|
|
62
|
+
// admits one wherever the value may stand.
|
|
63
|
+
const admitReferences = (value: JsonValue): JsonValue => {
|
|
64
|
+
if (Array.isArray(value)) return value.map(admitReferences);
|
|
65
|
+
if (!isObject(value)) return value;
|
|
66
|
+
|
|
67
|
+
const rebuilt: Record<string, JsonValue> = {};
|
|
68
|
+
for (const [key, entry] of entriesOf(value)) {
|
|
69
|
+
if (key === "$ref" && typeof entry === "string") {
|
|
70
|
+
const name = entry.replace(/^#\/\$defs\//, "");
|
|
71
|
+
rebuilt[key] = `#/$defs/${DEFINITION_NAMES[name] ?? name}`;
|
|
72
|
+
} else {
|
|
73
|
+
rebuilt[key] = admitReferences(entry);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (rebuilt.type === "object" && "properties" in rebuilt) {
|
|
77
|
+
return { anyOf: [{ $ref: USE_REFERENCE }, { $ref: INCLUDE_REFERENCE }, rebuilt] };
|
|
78
|
+
}
|
|
79
|
+
if (rebuilt.type === "array") {
|
|
80
|
+
return { anyOf: [{ $ref: INCLUDE_REFERENCE }, rebuilt] };
|
|
81
|
+
}
|
|
82
|
+
return rebuilt;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const DEFS_PROPERTY: JsonObject = {
|
|
86
|
+
type: "object",
|
|
87
|
+
description:
|
|
88
|
+
'Named fragments, referenced elsewhere in the manifest as `{ use: "<name>" }`. A fragment may itself contain `use`. Every file\'s `defs` share one namespace.',
|
|
89
|
+
additionalProperties: true,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const SCHEMA_PROPERTY: JsonObject = {
|
|
93
|
+
type: "string",
|
|
94
|
+
description: "For editors. Ignored by the loader.",
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export const manifestJsonSchema = (): JsonObject => {
|
|
98
|
+
const generated = Schema.toJsonSchemaDocument(Manifest) as unknown as {
|
|
99
|
+
readonly schema: JsonObject;
|
|
100
|
+
readonly definitions: JsonObject;
|
|
101
|
+
};
|
|
102
|
+
const { properties, ...root } = generated.schema;
|
|
103
|
+
if (properties === undefined || !isObject(properties)) {
|
|
104
|
+
throw new Error("the manifest schema generated with no properties");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const definitions: Record<string, JsonValue> = {};
|
|
108
|
+
for (const [name, definition] of entriesOf(generated.definitions)) {
|
|
109
|
+
definitions[DEFINITION_NAMES[name] ?? name] = admitReferences(definition);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
114
|
+
$id: MANIFEST_SCHEMA_ID,
|
|
115
|
+
title: "Architecture manifest",
|
|
116
|
+
description:
|
|
117
|
+
"One manifest of a repository's architecture, read by @goodbones/cli and @goodbones/oxlint. See https://dataquail.github.io/goodbones/architecture-rules/manifest/.",
|
|
118
|
+
...root,
|
|
119
|
+
properties: {
|
|
120
|
+
$schema: SCHEMA_PROPERTY,
|
|
121
|
+
defs: DEFS_PROPERTY,
|
|
122
|
+
...Object.fromEntries(
|
|
123
|
+
entriesOf(properties).map(([key, value]) => [key, admitReferences(value)]),
|
|
124
|
+
),
|
|
125
|
+
},
|
|
126
|
+
$defs: { ...definitions, Use, Include },
|
|
127
|
+
};
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// One node of the tree as a file of its own — what a per-package
|
|
131
|
+
// `architecture.yaml` that the root manifest `include`s is shaped like. The
|
|
132
|
+
// node's object form, with the `$schema` and `defs` keys such a file may carry
|
|
133
|
+
// at the top, over the same definitions as the whole manifest.
|
|
134
|
+
export const manifestNodeJsonSchema = (): JsonObject => {
|
|
135
|
+
const whole = manifestJsonSchema();
|
|
136
|
+
const definitions = whole.$defs;
|
|
137
|
+
if (definitions === undefined || !isObject(definitions)) {
|
|
138
|
+
throw new Error("the manifest schema generated with no definitions");
|
|
139
|
+
}
|
|
140
|
+
const node = definitions.ManifestNode;
|
|
141
|
+
const variants = node !== undefined && isObject(node) ? node.anyOf : undefined;
|
|
142
|
+
const object =
|
|
143
|
+
variants !== undefined && isList(variants)
|
|
144
|
+
? variants.find((variant) => isObject(variant) && variant.type === "object")
|
|
145
|
+
: undefined;
|
|
146
|
+
if (object === undefined || !isObject(object)) {
|
|
147
|
+
throw new Error("the manifest schema generated with no object form of a node");
|
|
148
|
+
}
|
|
149
|
+
const { $id: _id, $schema: _schema, ...rest } = object;
|
|
150
|
+
return {
|
|
151
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
152
|
+
$id: MANIFEST_NODE_SCHEMA_ID,
|
|
153
|
+
title: "Architecture manifest node",
|
|
154
|
+
description:
|
|
155
|
+
"One node of an architecture manifest's tree, as a file the manifest includes. See https://dataquail.github.io/goodbones/architecture-rules/manifest/#splitting-the-manifest-include.",
|
|
156
|
+
...rest,
|
|
157
|
+
properties: {
|
|
158
|
+
$schema: SCHEMA_PROPERTY,
|
|
159
|
+
defs: DEFS_PROPERTY,
|
|
160
|
+
...(rest.properties !== undefined && isObject(rest.properties) ? rest.properties : {}),
|
|
161
|
+
},
|
|
162
|
+
$defs: definitions,
|
|
163
|
+
};
|
|
164
|
+
};
|
package/src/manifest/manifest.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import * as Result from "effect/Result";
|
|
2
2
|
import * as Schema from "effect/Schema";
|
|
3
|
+
import * as SchemaIssue from "effect/SchemaIssue";
|
|
3
4
|
|
|
4
5
|
import { DeclarationKind, ResolveConfig } from "../domain/architecture-config.js";
|
|
5
6
|
import { ConfigInvalid } from "../domain/architecture-error.js";
|
|
7
|
+
import {
|
|
8
|
+
type ManifestLocator,
|
|
9
|
+
type ManifestPath,
|
|
10
|
+
renderManifestPath,
|
|
11
|
+
} from "../domain/manifest-location.js";
|
|
12
|
+
import { expandManifest, originOf, type Substitution } from "./expand.js";
|
|
6
13
|
|
|
7
14
|
// A manifest is a tree of nodes keyed by path pattern, where everything the
|
|
8
15
|
// architecture says about a part of the tree is written at that part of the tree.
|
|
@@ -286,7 +293,12 @@ export type ExportRestriction = typeof ExportRestriction.Type;
|
|
|
286
293
|
export const globsOf = (globs: string | ReadonlyArray<string>): ReadonlyArray<string> =>
|
|
287
294
|
typeof globs === "string" ? [globs] : globs;
|
|
288
295
|
|
|
289
|
-
|
|
296
|
+
// Every issue, not the first: a manifest is edited by hand, and the reader
|
|
297
|
+
// fixing one line wants to know about the other three. A key the schema does
|
|
298
|
+
// not declare is refused rather than dropped — a misspelled `matchNot` that
|
|
299
|
+
// decoded to nothing would be a rule quietly enforcing less than it says.
|
|
300
|
+
const decode = Schema.decodeUnknownResult(Manifest, { errors: "all", onExcessProperty: "error" });
|
|
301
|
+
const flatten = SchemaIssue.makeFormatterStandardSchemaV1();
|
|
290
302
|
|
|
291
303
|
export type DecodedManifest = {
|
|
292
304
|
readonly manifest: Manifest;
|
|
@@ -392,17 +404,97 @@ const normalizeLegacyMembers = (
|
|
|
392
404
|
return { input: { ...input, tree }, notices };
|
|
393
405
|
};
|
|
394
406
|
|
|
407
|
+
export type DecodeManifestOptions = {
|
|
408
|
+
// Turns a path in the file into a line and column. The YAML reader supplies
|
|
409
|
+
// one; a JavaScript module has no positions to give and passes nothing.
|
|
410
|
+
readonly locate?: ManifestLocator | undefined;
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
const fileLabelOf = (configPath: string): string => configPath.split(/[\\/]/).at(-1) ?? configPath;
|
|
414
|
+
|
|
415
|
+
// A position carries its own file when the value was written in a file the
|
|
416
|
+
// manifest `include`d; otherwise it is in the manifest file itself.
|
|
417
|
+
const positionOf = (
|
|
418
|
+
file: string,
|
|
419
|
+
locate: ManifestLocator | undefined,
|
|
420
|
+
path: ManifestPath,
|
|
421
|
+
): string | null => {
|
|
422
|
+
const found = locate?.(path) ?? null;
|
|
423
|
+
return found === null
|
|
424
|
+
? null
|
|
425
|
+
: `${found.file ?? file}:${String(found.line)}:${String(found.column)}`;
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
// One line per issue: where in the file, which path, what was wrong — and,
|
|
429
|
+
// when the value came in through a `use`, the reference that pulled it in,
|
|
430
|
+
// since the fragment's own line may sit far from where the reader is looking.
|
|
431
|
+
const describeIssue = (
|
|
432
|
+
configPath: string,
|
|
433
|
+
locate: ManifestLocator | undefined,
|
|
434
|
+
substitutions: ReadonlyArray<Substitution>,
|
|
435
|
+
path: ManifestPath,
|
|
436
|
+
detail: string,
|
|
437
|
+
): string => {
|
|
438
|
+
const file = fileLabelOf(configPath);
|
|
439
|
+
const origin = originOf(substitutions, path);
|
|
440
|
+
const at = positionOf(file, locate, origin.path);
|
|
441
|
+
const via = origin.via.map(({ at: ref, name }) => {
|
|
442
|
+
const position = positionOf(file, locate, ref);
|
|
443
|
+
return `via \`use: ${JSON.stringify(name)}\`${position === null ? "" : ` at ${position}`}`;
|
|
444
|
+
});
|
|
445
|
+
return (
|
|
446
|
+
` ${at === null ? "" : `${at} `}${renderManifestPath(origin.path)}: ${detail}` +
|
|
447
|
+
(via.length === 0 ? "" : ` (${via.join(", ")})`)
|
|
448
|
+
);
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
// The standard-schema formatter flattens the issue tree to `{ path, message }`
|
|
452
|
+
// pairs; a path segment may arrive wrapped as `{ key }`.
|
|
453
|
+
const pathOf = (issue: {
|
|
454
|
+
readonly path?: ReadonlyArray<PropertyKey | { readonly key: PropertyKey }> | undefined;
|
|
455
|
+
}): ManifestPath =>
|
|
456
|
+
(issue.path ?? []).map((segment) => (typeof segment === "object" ? segment.key : segment));
|
|
457
|
+
|
|
395
458
|
export const decodeManifest = (
|
|
396
459
|
configPath: string,
|
|
397
460
|
input: unknown,
|
|
461
|
+
options: DecodeManifestOptions = {},
|
|
398
462
|
): Result.Result<DecodedManifest, ConfigInvalid> => {
|
|
399
|
-
const
|
|
463
|
+
const expanded = expandManifest(input);
|
|
464
|
+
if (Result.isFailure(expanded)) {
|
|
465
|
+
return Result.fail(
|
|
466
|
+
new ConfigInvalid({
|
|
467
|
+
configPath,
|
|
468
|
+
detail:
|
|
469
|
+
"the manifest does not expand:\n" +
|
|
470
|
+
describeIssue(
|
|
471
|
+
configPath,
|
|
472
|
+
options.locate,
|
|
473
|
+
[],
|
|
474
|
+
expanded.failure.path,
|
|
475
|
+
expanded.failure.detail,
|
|
476
|
+
),
|
|
477
|
+
}),
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
const { substitutions, value } = expanded.success;
|
|
481
|
+
|
|
482
|
+
const resolve = normalizeLegacyResolve(value);
|
|
400
483
|
const members = normalizeLegacyMembers(resolve.input);
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
(
|
|
405
|
-
)
|
|
406
|
-
|
|
407
|
-
|
|
484
|
+
const decoded = decode(members.input);
|
|
485
|
+
if (Result.isFailure(decoded)) {
|
|
486
|
+
const lines = flatten(decoded.failure.issue).issues.map((issue) =>
|
|
487
|
+
describeIssue(configPath, options.locate, substitutions, pathOf(issue), issue.message),
|
|
488
|
+
);
|
|
489
|
+
return Result.fail(
|
|
490
|
+
new ConfigInvalid({
|
|
491
|
+
configPath,
|
|
492
|
+
detail: `the manifest does not decode:\n${lines.join("\n")}`,
|
|
493
|
+
}),
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
return Result.succeed({
|
|
497
|
+
manifest: decoded.success,
|
|
498
|
+
notices: [...resolve.notices, ...members.notices],
|
|
499
|
+
});
|
|
408
500
|
};
|