@goodbones/core 0.1.0-beta.2 → 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.
Files changed (33) hide show
  1. package/build/dts/domain/manifest-location.d.ts +2 -0
  2. package/build/dts/domain/manifest-location.d.ts.map +1 -1
  3. package/build/dts/index.d.ts +3 -2
  4. package/build/dts/index.d.ts.map +1 -1
  5. package/build/dts/infrastructure/manifest-file.d.ts +1 -0
  6. package/build/dts/infrastructure/manifest-file.d.ts.map +1 -1
  7. package/build/dts/infrastructure/manifest-include.d.ts +16 -0
  8. package/build/dts/infrastructure/manifest-include.d.ts.map +1 -0
  9. package/build/dts/manifest/json-schema.d.ts +2 -0
  10. package/build/dts/manifest/json-schema.d.ts.map +1 -1
  11. package/build/dts/manifest/manifest.d.ts +1 -1
  12. package/build/dts/manifest/manifest.d.ts.map +1 -1
  13. package/build/esm/domain/manifest-location.js +16 -1
  14. package/build/esm/domain/manifest-location.js.map +1 -1
  15. package/build/esm/index.js +3 -1
  16. package/build/esm/index.js.map +1 -1
  17. package/build/esm/infrastructure/manifest-file.js +31 -17
  18. package/build/esm/infrastructure/manifest-file.js.map +1 -1
  19. package/build/esm/infrastructure/manifest-include.js +187 -0
  20. package/build/esm/infrastructure/manifest-include.js.map +1 -0
  21. package/build/esm/manifest/json-schema.js +72 -17
  22. package/build/esm/manifest/json-schema.js.map +1 -1
  23. package/build/esm/manifest/manifest.js +7 -18
  24. package/build/esm/manifest/manifest.js.map +1 -1
  25. package/package.json +1 -1
  26. package/schema/architecture-node.schema.json +1518 -0
  27. package/schema/architecture.schema.json +1102 -691
  28. package/src/domain/manifest-location.ts +22 -0
  29. package/src/index.ts +13 -1
  30. package/src/infrastructure/manifest-file.ts +37 -17
  31. package/src/infrastructure/manifest-include.ts +318 -0
  32. package/src/manifest/json-schema.ts +83 -18
  33. package/src/manifest/manifest.ts +11 -20
@@ -11,9 +11,31 @@ export type ManifestPosition = {
11
11
  // 1-based, as editors count.
12
12
  readonly line: number;
13
13
  readonly column: number;
14
+ // The file the position is in, when it is not the manifest itself: a
15
+ // manifest assembled from several files through `include` answers with the
16
+ // included file's path, relative to the manifest's own directory. Absent
17
+ // for a position in the manifest file.
18
+ readonly file?: string | undefined;
14
19
  };
15
20
 
16
21
  // Answers with the position of the value at `path`, or the nearest ancestor
17
22
  // that exists when the path names something the file does not contain (a
18
23
  // missing key), or `null` when the source has no positions to give.
19
24
  export type ManifestLocator = (path: ManifestPath) => ManifestPosition | null;
25
+
26
+ const isIdentifier = (key: string): boolean => /^[A-Za-z_$][\w$]*$/.test(key);
27
+
28
+ // `tree["~/core/"].members[0].subject` — dotted where a key reads as a name,
29
+ // bracketed where it does not, so a node key that is a path pattern stays
30
+ // legible.
31
+ export const renderManifestPath = (path: ManifestPath): string =>
32
+ path.length === 0
33
+ ? "(root)"
34
+ : path
35
+ .map((segment, index) => {
36
+ if (typeof segment === "number") return `[${String(segment)}]`;
37
+ const key = String(segment);
38
+ if (isIdentifier(key)) return index === 0 ? key : `.${key}`;
39
+ return `[${JSON.stringify(key)}]`;
40
+ })
41
+ .join("");
package/src/index.ts CHANGED
@@ -112,6 +112,7 @@ export {
112
112
  type ManifestLocator,
113
113
  type ManifestPath,
114
114
  type ManifestPosition,
115
+ renderManifestPath,
115
116
  } from "./domain/manifest-location.js";
116
117
  export {
117
118
  fingerprintOf,
@@ -127,6 +128,12 @@ export {
127
128
  type ManifestFile,
128
129
  readManifestFile,
129
130
  } from "./infrastructure/manifest-file.js";
131
+ export {
132
+ expandIncludes,
133
+ type IncludedManifest,
134
+ type IncludeReader,
135
+ type SourceDocument,
136
+ } from "./infrastructure/manifest-include.js";
130
137
  export { listSourceFiles, type WalkedLanguage } from "./infrastructure/walk.js";
131
138
  export { type LoadedPolicy, loadPolicy, type LoadPolicyInput } from "./load/policy.js";
132
139
  export { type LoweredRules, lowerManifest, type ProbeLanguage } from "./manifest/compile.js";
@@ -138,7 +145,12 @@ export {
138
145
  originOf,
139
146
  type Substitution,
140
147
  } from "./manifest/expand.js";
141
- export { MANIFEST_SCHEMA_ID, manifestJsonSchema } from "./manifest/json-schema.js";
148
+ export {
149
+ MANIFEST_NODE_SCHEMA_ID,
150
+ MANIFEST_SCHEMA_ID,
151
+ manifestJsonSchema,
152
+ manifestNodeJsonSchema,
153
+ } from "./manifest/json-schema.js";
142
154
  export {
143
155
  type DecodedManifest,
144
156
  decodeManifest,
@@ -21,14 +21,17 @@ import type {
21
21
  ManifestPath,
22
22
  ManifestPosition,
23
23
  } from "../domain/manifest-location.js";
24
+ import { expandIncludes, type SourceDocument } from "./manifest-include.js";
24
25
 
25
- // The manifest, as the file on disk states it, before any decoding. A data
26
+ // The manifest, as the files on disk state it, before any decoding. A data
26
27
  // file — YAML, or JSON, which YAML 1.2 contains — is what any host in any
27
28
  // language can read, and is the form the docs are written in. A JavaScript
28
29
  // module is the escape hatch a Node host alone honours: for a manifest
29
30
  // generated from other data, and for what the ecosystem expects of a config
30
- // file. Whichever it is, what comes out is one `unknown` value for the decoder,
31
- // and, from the data formats, a way to turn a path in it back into a line.
31
+ // file. Either may `include` further data files, so a monorepo's policy can
32
+ // live beside the packages it governs. Whichever it is, what comes out is one
33
+ // `unknown` value for the decoder, and, from the data formats, a way to turn
34
+ // a path in it back into a file and a line.
32
35
 
33
36
  // In discovery order. A repository with none of these is told all four; one
34
37
  // with more than one is refused, so nobody edits the wrong file for a week.
@@ -42,8 +45,12 @@ export const MANIFEST_FILENAMES = [
42
45
  export type ManifestFile = {
43
46
  readonly configPath: string;
44
47
  readonly manifest: unknown;
45
- // Absent for a JavaScript module, which has no positions to give.
48
+ // Absent for a JavaScript module that includes nothing, which has no
49
+ // positions to give.
46
50
  readonly locate: ManifestLocator | undefined;
51
+ // Every file the manifest was read from, the root first: what a host that
52
+ // caches or watches the policy has to look at.
53
+ readonly files: ReadonlyArray<string>;
47
54
  };
48
55
 
49
56
  export const findManifestFile = (repoRoot: string): string => {
@@ -76,24 +83,38 @@ const isDataManifest = (configPath: string): boolean =>
76
83
  const isModuleManifest = (configPath: string): boolean =>
77
84
  [".mjs", ".js", ".cjs"].includes(extensionOf(configPath));
78
85
 
86
+ // Whichever form the root takes, an `include` in it names a data file, read
87
+ // by the same parser; the locator that comes back answers across every file.
79
88
  export const readManifestFile = async (configPath: string): Promise<ManifestFile> => {
80
- if (isDataManifest(configPath)) return readDataManifest(configPath);
81
- if (isModuleManifest(configPath)) return readModuleManifest(configPath);
82
- throw new ConfigInvalid({
89
+ const root = isDataManifest(configPath)
90
+ ? parseDataFile(configPath)
91
+ : isModuleManifest(configPath)
92
+ ? await readModule(configPath)
93
+ : undefined;
94
+ if (root === undefined) {
95
+ throw new ConfigInvalid({
96
+ configPath,
97
+ detail:
98
+ "a manifest is a .yaml, .yml or .json file, or a .mjs/.js module — " +
99
+ `not ${JSON.stringify(path.basename(configPath))}.`,
100
+ });
101
+ }
102
+ const assembled = expandIncludes(configPath, root, { exists: existsSync, read: parseDataFile });
103
+ return {
83
104
  configPath,
84
- detail:
85
- "a manifest is a .yaml, .yml or .json file, or a .mjs/.js module — " +
86
- `not ${JSON.stringify(path.basename(configPath))}.`,
87
- });
105
+ manifest: assembled.value,
106
+ locate: assembled.locate,
107
+ files: assembled.files,
108
+ };
88
109
  };
89
110
 
90
- const readModuleManifest = async (configPath: string): Promise<ManifestFile> => {
111
+ const readModule = async (configPath: string): Promise<SourceDocument> => {
91
112
  const module: unknown = await import(pathToFileURL(configPath).href).catch((cause: unknown) => {
92
113
  throw new ConfigInvalid({ configPath, detail: String(cause) });
93
114
  });
94
- const manifest =
115
+ const value =
95
116
  typeof module === "object" && module !== null && "default" in module ? module.default : module;
96
- return { configPath, manifest, locate: undefined };
117
+ return { value, locate: undefined };
97
118
  };
98
119
 
99
120
  // YAML 1.2 core schema, which is what a reader without a YAML background
@@ -102,7 +123,7 @@ const readModuleManifest = async (configPath: string): Promise<ManifestFile> =>
102
123
  // duplicate keys are refused rather than last-one-wins. A tag the parser does
103
124
  // not know is refused too — a manifest is data, and a `!!js/function` in it
104
125
  // would be a manifest only one runtime could read.
105
- const readDataManifest = (configPath: string): ManifestFile => {
126
+ const parseDataFile = (configPath: string): SourceDocument => {
106
127
  let text: string;
107
128
  try {
108
129
  text = readFileSync(configPath, "utf8");
@@ -140,8 +161,7 @@ const readDataManifest = (configPath: string): ManifestFile => {
140
161
  }
141
162
 
142
163
  return {
143
- configPath,
144
- manifest: document.toJS({ mapAsMap: false }) as unknown,
164
+ value: document.toJS({ mapAsMap: false }) as unknown,
145
165
  locate: makeLocator(document, lines),
146
166
  };
147
167
  };
@@ -0,0 +1,318 @@
1
+ import * as path from "node:path";
2
+
3
+ import { ConfigInvalid } from "../domain/architecture-error.js";
4
+ import {
5
+ type ManifestLocator,
6
+ type ManifestPath,
7
+ renderManifestPath,
8
+ } from "../domain/manifest-location.js";
9
+
10
+ // A manifest split across files. `{ include: "<path>" }` standing anywhere in
11
+ // a manifest — a node under `tree`, a whole section, one entry of a list — is
12
+ // replaced by the value of the file it names, read by the same parser and
13
+ // resolved relative to the file that wrote the reference. This runs on the raw
14
+ // value before anything else looks at it: the `defs`/`use` expansion sees one
15
+ // document, the decoder sees one document, and the schema never learns that a
16
+ // reference existed.
17
+ //
18
+ // What is deliberately not here: no merging (a value is replaced, full stop),
19
+ // no parameters, no glob of files. An included file is YAML or JSON only — a
20
+ // module would make a data manifest readable by one runtime — and a file may
21
+ // carry a top-level `defs` of its own, which joins the manifest's under one
22
+ // namespace, and a `$schema` for its editor, which is dropped.
23
+
24
+ // One file, as the reader parsed it.
25
+ export type SourceDocument = {
26
+ readonly value: unknown;
27
+ readonly locate: ManifestLocator | undefined;
28
+ };
29
+
30
+ export type IncludeReader = {
31
+ readonly exists: (file: string) => boolean;
32
+ // Throws `ConfigInvalid` naming the file when it does not parse.
33
+ readonly read: (file: string) => SourceDocument;
34
+ };
35
+
36
+ export type IncludedManifest = {
37
+ readonly value: unknown;
38
+ // Answers for every file: a position inside an included file carries that
39
+ // file's path, relative to the root manifest's directory.
40
+ readonly locate: ManifestLocator | undefined;
41
+ // Every file that took part, root first, as absolute paths.
42
+ readonly files: ReadonlyArray<string>;
43
+ };
44
+
45
+ const INCLUDABLE = [".yaml", ".yml", ".json"];
46
+
47
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
48
+ typeof value === "object" && value !== null && !Array.isArray(value);
49
+
50
+ type IncludeReference = Record<string, unknown> & { readonly include: unknown };
51
+
52
+ const isInclude = (value: unknown): value is IncludeReference =>
53
+ isRecord(value) && "include" in value;
54
+
55
+ const isPrefix = (prefix: ManifestPath, whole: ManifestPath): boolean =>
56
+ prefix.length <= whole.length && prefix.every((segment, index) => whole[index] === segment);
57
+
58
+ // Which file a region of the assembled document came from: everything under
59
+ // `at` was written in the file whose locator this is, starting at `origin`
60
+ // there. The root file mounts at the root; each include mounts where it
61
+ // landed; a fragment hoisted out of an included file mounts under `defs`; and
62
+ // a list item that moved — spliced in from another file, or shifted by a
63
+ // splice before it — mounts on its own, since its index is not the one it
64
+ // was written at.
65
+ type Mount = {
66
+ readonly at: ManifestPath;
67
+ readonly origin: ManifestPath;
68
+ readonly label: string | undefined;
69
+ readonly locate: ManifestLocator | undefined;
70
+ };
71
+
72
+ // The file being walked.
73
+ type Source = {
74
+ readonly file: string;
75
+ // How the file is named in a message: its path relative to the root
76
+ // manifest's directory. The root file itself has none, and is named by
77
+ // whoever reports the error.
78
+ readonly label: string | undefined;
79
+ readonly locate: ManifestLocator | undefined;
80
+ // Every file on the way here, for the cycle check.
81
+ readonly stack: ReadonlyArray<string>;
82
+ };
83
+
84
+ type Opened = {
85
+ readonly document: SourceDocument;
86
+ readonly source: Source;
87
+ };
88
+
89
+ const makeLocator = (mounts: ReadonlyArray<Mount>): ManifestLocator | undefined => {
90
+ if (!mounts.some((mount) => mount.locate !== undefined)) return undefined;
91
+ return (manifestPath) => {
92
+ const innermost = mounts
93
+ .filter((mount) => isPrefix(mount.at, manifestPath))
94
+ .sort((a, b) => a.at.length - b.at.length)
95
+ .at(-1);
96
+ if (innermost === undefined) return null;
97
+ const found =
98
+ innermost.locate?.([...innermost.origin, ...manifestPath.slice(innermost.at.length)]) ?? null;
99
+ if (found === null) return null;
100
+ return innermost.label === undefined ? found : { ...found, file: innermost.label };
101
+ };
102
+ };
103
+
104
+ export const expandIncludes = (
105
+ rootPath: string,
106
+ root: SourceDocument,
107
+ reader: IncludeReader,
108
+ ): IncludedManifest => {
109
+ const rootDirectory = path.dirname(rootPath);
110
+ const labelOf = (file: string): string =>
111
+ path.relative(rootDirectory, file).split(path.sep).join("/");
112
+ const nameOf = (source: Source): string => source.label ?? path.basename(rootPath);
113
+
114
+ const files: Array<string> = [rootPath];
115
+ const mounts: Array<Mount> = [{ at: [], origin: [], label: undefined, locate: root.locate }];
116
+ // Fragments hoisted out of included files, and the file each name came from.
117
+ const hoisted: Record<string, unknown> = {};
118
+ const definedIn = new Map<string, string>();
119
+
120
+ const refuse = (source: Source, origin: ManifestPath, detail: string): never => {
121
+ const position = source.locate?.(origin) ?? null;
122
+ const at =
123
+ position === null
124
+ ? ""
125
+ : `${nameOf(source)}:${String(position.line)}:${String(position.column)} `;
126
+ throw new ConfigInvalid({
127
+ configPath: rootPath,
128
+ detail: `the manifest does not include:\n ${at}${renderManifestPath(origin)}: ${detail}`,
129
+ });
130
+ };
131
+
132
+ // Checks a reference and reads the file it names. Nothing is placed yet:
133
+ // where the value lands depends on whether it is a list spliced into a list.
134
+ const open = (reference: IncludeReference, origin: ManifestPath, source: Source): Opened => {
135
+ const { include: specifier, ...rest } = reference;
136
+ if (typeof specifier !== "string") {
137
+ return refuse(source, origin, "`include` names a file, as a string.");
138
+ }
139
+ const shown = `\`include: ${JSON.stringify(specifier)}\``;
140
+ const beside = Object.keys(rest);
141
+ if (beside.length > 0) {
142
+ return refuse(
143
+ source,
144
+ origin,
145
+ `${shown} stands alone: an included file is replaced whole, so there is nothing for ` +
146
+ `${beside.map((key) => `\`${key}\``).join(", ")} to override. ` +
147
+ `To override a fragment, put it under \`defs\` and \`use\` it.`,
148
+ );
149
+ }
150
+ const target = path.resolve(path.dirname(source.file), specifier);
151
+ if (!INCLUDABLE.includes(path.extname(target).toLowerCase())) {
152
+ return refuse(
153
+ source,
154
+ origin,
155
+ `${shown} names a file that is not YAML or JSON. Only a data file can be included: ` +
156
+ `a module would make the manifest readable by one runtime only.`,
157
+ );
158
+ }
159
+ if (source.stack.includes(target)) {
160
+ return refuse(
161
+ source,
162
+ origin,
163
+ `${shown} includes a file that is already being included: ` +
164
+ `${[...source.stack, target].map(labelOf).join(" → ")}.`,
165
+ );
166
+ }
167
+ if (!reader.exists(target)) {
168
+ return refuse(
169
+ source,
170
+ origin,
171
+ `${shown} names a file that does not exist (looked for ${labelOf(target)}, ` +
172
+ `relative to ${nameOf(source)}).`,
173
+ );
174
+ }
175
+ const document = reader.read(target);
176
+ files.push(target);
177
+ return {
178
+ document,
179
+ source: {
180
+ file: target,
181
+ label: labelOf(target),
182
+ locate: document.locate,
183
+ stack: [...source.stack, target],
184
+ },
185
+ };
186
+ };
187
+
188
+ // Places an opened file at `at`, whole, and walks it so no reference is
189
+ // left inside. A top-level `defs` in the file joins the root's, and its
190
+ // `$schema` is for the editor — unless the file was included somewhere
191
+ // under `defs`, where it is a fragment or the map itself, and a key by
192
+ // either name is the author's.
193
+ const place = (opened: Opened, at: ManifestPath): unknown => {
194
+ const { document, source } = opened;
195
+ mounts.push({ at, origin: [], label: source.label, locate: source.locate });
196
+ if (!isRecord(document.value) || at[0] === "defs") {
197
+ return walk(document.value, at, [], source);
198
+ }
199
+ const { $schema: _schema, defs, ...body } = document.value;
200
+ if (defs !== undefined) {
201
+ if (!isRecord(defs)) {
202
+ return refuse(source, ["defs"], "`defs` must be a map of named fragments.");
203
+ }
204
+ for (const [name, fragment] of Object.entries(defs)) {
205
+ const already = definedIn.get(name);
206
+ if (already !== undefined) {
207
+ return refuse(
208
+ source,
209
+ ["defs", name],
210
+ `\`defs.${name}\` is already defined in ${already}. Every file's \`defs\` share ` +
211
+ `one namespace; rename one of them.`,
212
+ );
213
+ }
214
+ definedIn.set(name, nameOf(source));
215
+ mounts.push({
216
+ at: ["defs", name],
217
+ origin: ["defs", name],
218
+ label: source.label,
219
+ locate: source.locate,
220
+ });
221
+ hoisted[name] = walk(fragment, ["defs", name], ["defs", name], source);
222
+ }
223
+ }
224
+ return walk(body, at, [], source);
225
+ };
226
+
227
+ // Appends each item of `list` to `items`. An include standing as an item
228
+ // whose file holds a list is spliced in, so a list can be split across
229
+ // files; an item that does not sit at the index it was written at is
230
+ // mounted on its own.
231
+ const append = (
232
+ items: Array<unknown>,
233
+ list: ReadonlyArray<unknown>,
234
+ at: ManifestPath,
235
+ origin: ManifestPath,
236
+ source: Source,
237
+ spliced: boolean,
238
+ ): void => {
239
+ for (const [index, item] of list.entries()) {
240
+ const itemOrigin = [...origin, index];
241
+ const slot = [...at, items.length];
242
+ if (isInclude(item)) {
243
+ const opened = open(item, itemOrigin, source);
244
+ if (Array.isArray(opened.document.value)) {
245
+ append(items, opened.document.value, at, [], opened.source, true);
246
+ } else {
247
+ items.push(place(opened, slot));
248
+ }
249
+ continue;
250
+ }
251
+ if (spliced || items.length !== index) {
252
+ mounts.push({ at: slot, origin: itemOrigin, label: source.label, locate: source.locate });
253
+ }
254
+ items.push(walk(item, slot, itemOrigin, source));
255
+ }
256
+ };
257
+
258
+ const walk = (
259
+ value: unknown,
260
+ at: ManifestPath,
261
+ origin: ManifestPath,
262
+ source: Source,
263
+ ): unknown => {
264
+ if (isInclude(value)) return place(open(value, origin, source), at);
265
+
266
+ if (Array.isArray(value)) {
267
+ const items: Array<unknown> = [];
268
+ append(items, value, at, origin, source, false);
269
+ return items;
270
+ }
271
+
272
+ if (isRecord(value)) {
273
+ const entries: Record<string, unknown> = {};
274
+ for (const [key, item] of Object.entries(value)) {
275
+ entries[key] = walk(item, [...at, key], [...origin, key], source);
276
+ }
277
+ return entries;
278
+ }
279
+
280
+ return value;
281
+ };
282
+
283
+ const rootSource: Source = {
284
+ file: rootPath,
285
+ label: undefined,
286
+ locate: root.locate,
287
+ stack: [rootPath],
288
+ };
289
+ const value = walk(root.value, [], [], rootSource);
290
+
291
+ // Nothing was included: the value and the locator are the reader's own.
292
+ if (files.length === 1) return { value, locate: root.locate, files };
293
+
294
+ const names = Object.keys(hoisted);
295
+ if (names.length === 0 || !isRecord(value)) {
296
+ return { value, locate: makeLocator(mounts), files };
297
+ }
298
+
299
+ const own = value.defs;
300
+ if (own !== undefined && !isRecord(own)) {
301
+ return refuse(rootSource, ["defs"], "`defs` must be a map of named fragments.");
302
+ }
303
+ for (const name of names) {
304
+ if (own !== undefined && name in own) {
305
+ return refuse(
306
+ rootSource,
307
+ ["defs", name],
308
+ `\`defs.${name}\` is also defined in ${definedIn.get(name) ?? "an included file"}. ` +
309
+ `Every file's \`defs\` share one namespace; rename one of them.`,
310
+ );
311
+ }
312
+ }
313
+ return {
314
+ value: { ...value, defs: { ...(own ?? {}), ...hoisted } },
315
+ locate: makeLocator(mounts),
316
+ files,
317
+ };
318
+ };
@@ -7,13 +7,19 @@ import { Manifest } from "./manifest.js";
7
7
  // comment and a JSON file in a `$schema` key; either way the editor completes
8
8
  // keys and flags a misspelled one before the loader ever runs.
9
9
  //
10
- // Two things the codec does not know are added here: the `defs` map, and the
11
- // `{ use }` reference form that may stand in for any object both belong to
12
- // the expansion pass that runs before decoding.
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.
13
14
 
14
15
  export const MANIFEST_SCHEMA_ID =
15
16
  "https://dataquail.github.io/goodbones/schema/architecture.schema.json";
16
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
+
17
23
  type JsonValue = string | number | boolean | null | JsonObject | ReadonlyArray<JsonValue>;
18
24
  type JsonObject = { readonly [key: string]: JsonValue };
19
25
 
@@ -23,11 +29,14 @@ const entriesOf = (value: JsonObject): ReadonlyArray<readonly [string, JsonValue
23
29
  const isObject = (value: JsonValue): value is JsonObject =>
24
30
  typeof value === "object" && value !== null && !Array.isArray(value);
25
31
 
32
+ const isList = (value: JsonValue): value is ReadonlyArray<JsonValue> => Array.isArray(value);
33
+
26
34
  // The generator names the recursive node after its own internal wrapper. A
27
35
  // stable name is what a `$ref` in an error message or a docs page can point at.
28
36
  const DEFINITION_NAMES: Readonly<Record<string, string>> = { Suspend_: "ManifestNode" };
29
37
 
30
38
  const USE_REFERENCE = "#/$defs/Use";
39
+ const INCLUDE_REFERENCE = "#/$defs/Include";
31
40
 
32
41
  const Use: JsonObject = {
33
42
  type: "object",
@@ -37,9 +46,20 @@ const Use: JsonObject = {
37
46
  required: ["use"],
38
47
  };
39
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
+
40
58
  // Every object schema below the root becomes "this object, or a `use` of a
41
- // fragment shaped like it". The expansion pass replaces a reference wherever
42
- // it stands, so the schema admits one wherever an object may stand.
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.
43
63
  const admitReferences = (value: JsonValue): JsonValue => {
44
64
  if (Array.isArray(value)) return value.map(admitReferences);
45
65
  if (!isObject(value)) return value;
@@ -53,8 +73,25 @@ const admitReferences = (value: JsonValue): JsonValue => {
53
73
  rebuilt[key] = admitReferences(entry);
54
74
  }
55
75
  }
56
- const isObjectSchema = rebuilt.type === "object" && "properties" in rebuilt;
57
- return isObjectSchema ? { anyOf: [{ $ref: USE_REFERENCE }, rebuilt] } : rebuilt;
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.",
58
95
  };
59
96
 
60
97
  export const manifestJsonSchema = (): JsonObject => {
@@ -80,20 +117,48 @@ export const manifestJsonSchema = (): JsonObject => {
80
117
  "One manifest of a repository's architecture, read by @goodbones/cli and @goodbones/oxlint. See https://dataquail.github.io/goodbones/architecture-rules/manifest/.",
81
118
  ...root,
82
119
  properties: {
83
- $schema: {
84
- type: "string",
85
- description: "For editors. Ignored by the loader.",
86
- },
87
- defs: {
88
- type: "object",
89
- description:
90
- 'Named fragments, referenced elsewhere in the manifest as `{ use: "<name>" }`. A fragment may itself contain `use`.',
91
- additionalProperties: true,
92
- },
120
+ $schema: SCHEMA_PROPERTY,
121
+ defs: DEFS_PROPERTY,
93
122
  ...Object.fromEntries(
94
123
  entriesOf(properties).map(([key, value]) => [key, admitReferences(value)]),
95
124
  ),
96
125
  },
97
- $defs: { ...definitions, Use },
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,
98
163
  };
99
164
  };