@goodbones/core 0.1.0-beta.1 → 0.1.0-beta.2
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 +7 -0
- package/build/dts/domain/manifest-location.d.ts.map +1 -0
- package/build/dts/index.d.ts +5 -2
- package/build/dts/index.d.ts.map +1 -1
- package/build/dts/infrastructure/manifest-file.d.ts +10 -2
- package/build/dts/infrastructure/manifest-file.d.ts.map +1 -1
- 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 +8 -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 +6 -0
- package/build/esm/domain/manifest-location.js.map +1 -0
- package/build/esm/index.js +3 -1
- package/build/esm/index.js.map +1 -1
- package/build/esm/infrastructure/manifest-file.js +144 -7
- package/build/esm/infrastructure/manifest-file.js.map +1 -1
- 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 +76 -0
- package/build/esm/manifest/json-schema.js.map +1 -0
- package/build/esm/manifest/manifest.js +69 -4
- package/build/esm/manifest/manifest.js.map +1 -1
- package/package.json +7 -3
- package/schema/architecture.schema.json +1089 -0
- package/src/domain/manifest-location.ts +19 -0
- package/src/index.ts +22 -1
- package/src/infrastructure/manifest-file.ts +184 -8
- package/src/load/policy.ts +5 -1
- package/src/manifest/expand.ts +174 -0
- package/src/manifest/json-schema.ts +99 -0
- package/src/manifest/manifest.ts +110 -9
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Where in the manifest file a value was written. A decode error that names a
|
|
2
|
+
// path is something the reader has to find; one that names a line is something
|
|
3
|
+
// an editor can jump to. The host that read the file knows its lines; the
|
|
4
|
+
// decoder knows the path — the locator is how the second asks the first.
|
|
5
|
+
|
|
6
|
+
// A path into the manifest as the decoder sees it: object keys and array
|
|
7
|
+
// indices, root first.
|
|
8
|
+
export type ManifestPath = ReadonlyArray<PropertyKey>;
|
|
9
|
+
|
|
10
|
+
export type ManifestPosition = {
|
|
11
|
+
// 1-based, as editors count.
|
|
12
|
+
readonly line: number;
|
|
13
|
+
readonly column: number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// Answers with the position of the value at `path`, or the nearest ancestor
|
|
17
|
+
// that exists when the path names something the file does not contain (a
|
|
18
|
+
// missing key), or `null` when the source has no positions to give.
|
|
19
|
+
export type ManifestLocator = (path: ManifestPath) => ManifestPosition | null;
|
package/src/index.ts
CHANGED
|
@@ -108,6 +108,11 @@ export {
|
|
|
108
108
|
type MemberSite,
|
|
109
109
|
type SourceFacts,
|
|
110
110
|
} from "./domain/facts.js";
|
|
111
|
+
export {
|
|
112
|
+
type ManifestLocator,
|
|
113
|
+
type ManifestPath,
|
|
114
|
+
type ManifestPosition,
|
|
115
|
+
} from "./domain/manifest-location.js";
|
|
111
116
|
export {
|
|
112
117
|
fingerprintOf,
|
|
113
118
|
formatMessage,
|
|
@@ -115,13 +120,29 @@ export {
|
|
|
115
120
|
type ViolationKind,
|
|
116
121
|
} from "./domain/violation.js";
|
|
117
122
|
export { makeFileSystemLive } from "./infrastructure/file-system-live.js";
|
|
118
|
-
export {
|
|
123
|
+
export {
|
|
124
|
+
findManifestFile,
|
|
125
|
+
formatManifestYaml,
|
|
126
|
+
MANIFEST_FILENAMES,
|
|
127
|
+
type ManifestFile,
|
|
128
|
+
readManifestFile,
|
|
129
|
+
} from "./infrastructure/manifest-file.js";
|
|
119
130
|
export { listSourceFiles, type WalkedLanguage } from "./infrastructure/walk.js";
|
|
120
131
|
export { type LoadedPolicy, loadPolicy, type LoadPolicyInput } from "./load/policy.js";
|
|
121
132
|
export { type LoweredRules, lowerManifest, type ProbeLanguage } from "./manifest/compile.js";
|
|
133
|
+
export {
|
|
134
|
+
type ExpandedManifest,
|
|
135
|
+
type ExpandIssue,
|
|
136
|
+
expandManifest,
|
|
137
|
+
type Origin,
|
|
138
|
+
originOf,
|
|
139
|
+
type Substitution,
|
|
140
|
+
} from "./manifest/expand.js";
|
|
141
|
+
export { MANIFEST_SCHEMA_ID, manifestJsonSchema } from "./manifest/json-schema.js";
|
|
122
142
|
export {
|
|
123
143
|
type DecodedManifest,
|
|
124
144
|
decodeManifest,
|
|
145
|
+
type DecodeManifestOptions,
|
|
125
146
|
type Manifest,
|
|
126
147
|
type ManifestNode,
|
|
127
148
|
Manifest as ManifestSchema,
|
|
@@ -1,17 +1,193 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
1
3
|
import { pathToFileURL } from "node:url";
|
|
2
4
|
|
|
5
|
+
import {
|
|
6
|
+
type Document,
|
|
7
|
+
isAlias,
|
|
8
|
+
isMap,
|
|
9
|
+
isScalar,
|
|
10
|
+
isSeq,
|
|
11
|
+
LineCounter,
|
|
12
|
+
type Node,
|
|
13
|
+
type Pair,
|
|
14
|
+
parseDocument,
|
|
15
|
+
stringify,
|
|
16
|
+
} from "yaml";
|
|
17
|
+
|
|
3
18
|
import { ConfigInvalid } from "../domain/architecture-error.js";
|
|
19
|
+
import type {
|
|
20
|
+
ManifestLocator,
|
|
21
|
+
ManifestPath,
|
|
22
|
+
ManifestPosition,
|
|
23
|
+
} from "../domain/manifest-location.js";
|
|
24
|
+
|
|
25
|
+
// The manifest, as the file on disk states it, before any decoding. A data
|
|
26
|
+
// file — YAML, or JSON, which YAML 1.2 contains — is what any host in any
|
|
27
|
+
// language can read, and is the form the docs are written in. A JavaScript
|
|
28
|
+
// module is the escape hatch a Node host alone honours: for a manifest
|
|
29
|
+
// 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.
|
|
32
|
+
|
|
33
|
+
// In discovery order. A repository with none of these is told all four; one
|
|
34
|
+
// with more than one is refused, so nobody edits the wrong file for a week.
|
|
35
|
+
export const MANIFEST_FILENAMES = [
|
|
36
|
+
"architecture.yaml",
|
|
37
|
+
"architecture.yml",
|
|
38
|
+
"architecture.json",
|
|
39
|
+
"architecture.config.mjs",
|
|
40
|
+
] as const;
|
|
41
|
+
|
|
42
|
+
export type ManifestFile = {
|
|
43
|
+
readonly configPath: string;
|
|
44
|
+
readonly manifest: unknown;
|
|
45
|
+
// Absent for a JavaScript module, which has no positions to give.
|
|
46
|
+
readonly locate: ManifestLocator | undefined;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const findManifestFile = (repoRoot: string): string => {
|
|
50
|
+
const present = MANIFEST_FILENAMES.filter((name) => existsSync(path.resolve(repoRoot, name)));
|
|
51
|
+
const [found] = present;
|
|
52
|
+
if (found === undefined) {
|
|
53
|
+
throw new ConfigInvalid({
|
|
54
|
+
configPath: repoRoot,
|
|
55
|
+
detail:
|
|
56
|
+
`no architecture manifest found. Looked for ${MANIFEST_FILENAMES.join(", ")} ` +
|
|
57
|
+
`in this directory; \`architecture init\` writes a starter architecture.yaml.`,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
if (present.length > 1) {
|
|
61
|
+
throw new ConfigInvalid({
|
|
62
|
+
configPath: repoRoot,
|
|
63
|
+
detail:
|
|
64
|
+
`more than one architecture manifest is present (${present.join(", ")}), and only one ` +
|
|
65
|
+
`can be the policy. Delete the others, or name one with ARCHITECTURE_CONFIG.`,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
return path.resolve(repoRoot, found);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const extensionOf = (configPath: string): string => path.extname(configPath).toLowerCase();
|
|
72
|
+
|
|
73
|
+
const isDataManifest = (configPath: string): boolean =>
|
|
74
|
+
[".yaml", ".yml", ".json"].includes(extensionOf(configPath));
|
|
4
75
|
|
|
5
|
-
|
|
76
|
+
const isModuleManifest = (configPath: string): boolean =>
|
|
77
|
+
[".mjs", ".js", ".cjs"].includes(extensionOf(configPath));
|
|
78
|
+
|
|
79
|
+
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({
|
|
83
|
+
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
|
+
});
|
|
88
|
+
};
|
|
6
89
|
|
|
7
|
-
|
|
8
|
-
// is a JavaScript module and its default export; a manifest in another format
|
|
9
|
-
// changes this function and nothing behind it.
|
|
10
|
-
export const readManifestFile = async (configPath: string): Promise<unknown> => {
|
|
90
|
+
const readModuleManifest = async (configPath: string): Promise<ManifestFile> => {
|
|
11
91
|
const module: unknown = await import(pathToFileURL(configPath).href).catch((cause: unknown) => {
|
|
12
92
|
throw new ConfigInvalid({ configPath, detail: String(cause) });
|
|
13
93
|
});
|
|
14
|
-
|
|
15
|
-
? module.default
|
|
16
|
-
|
|
94
|
+
const manifest =
|
|
95
|
+
typeof module === "object" && module !== null && "default" in module ? module.default : module;
|
|
96
|
+
return { configPath, manifest, locate: undefined };
|
|
17
97
|
};
|
|
98
|
+
|
|
99
|
+
// YAML 1.2 core schema, which is what a reader without a YAML background
|
|
100
|
+
// expects: `on` and `no` are strings, not booleans. Merge keys (`<<`) resolve,
|
|
101
|
+
// because the parser handles them before the manifest sees the document, and
|
|
102
|
+
// duplicate keys are refused rather than last-one-wins. A tag the parser does
|
|
103
|
+
// not know is refused too — a manifest is data, and a `!!js/function` in it
|
|
104
|
+
// would be a manifest only one runtime could read.
|
|
105
|
+
const readDataManifest = (configPath: string): ManifestFile => {
|
|
106
|
+
let text: string;
|
|
107
|
+
try {
|
|
108
|
+
text = readFileSync(configPath, "utf8");
|
|
109
|
+
} catch (cause) {
|
|
110
|
+
throw new ConfigInvalid({ configPath, detail: String(cause) });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const lines = new LineCounter();
|
|
114
|
+
const document = parseDocument(text, {
|
|
115
|
+
merge: true,
|
|
116
|
+
uniqueKeys: true,
|
|
117
|
+
customTags: [],
|
|
118
|
+
lineCounter: lines,
|
|
119
|
+
// A JSON file is read by the same parser; strict JSON is valid YAML.
|
|
120
|
+
schema: "core",
|
|
121
|
+
});
|
|
122
|
+
const problems = [...document.errors, ...document.warnings];
|
|
123
|
+
if (problems.length > 0) {
|
|
124
|
+
const file = path.basename(configPath);
|
|
125
|
+
throw new ConfigInvalid({
|
|
126
|
+
configPath,
|
|
127
|
+
detail:
|
|
128
|
+
"the manifest does not parse:\n" +
|
|
129
|
+
problems
|
|
130
|
+
.map((problem) => {
|
|
131
|
+
const [position] = problem.linePos ?? [];
|
|
132
|
+
const at =
|
|
133
|
+
position === undefined
|
|
134
|
+
? ""
|
|
135
|
+
: `${file}:${String(position.line)}:${String(position.col)} `;
|
|
136
|
+
return ` ${at}${problem.code}: ${problem.message.split("\n")[0] ?? problem.message}`;
|
|
137
|
+
})
|
|
138
|
+
.join("\n"),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
configPath,
|
|
144
|
+
manifest: document.toJS({ mapAsMap: false }) as unknown,
|
|
145
|
+
locate: makeLocator(document, lines),
|
|
146
|
+
};
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const keyMatches = (pair: Pair, segment: PropertyKey): boolean =>
|
|
150
|
+
isScalar(pair.key) && String(pair.key.value) === String(segment);
|
|
151
|
+
|
|
152
|
+
// Walks the document's own tree by the decoder's path and answers with the
|
|
153
|
+
// position of the deepest thing that exists along it. A map key is reported
|
|
154
|
+
// at the key, where the reader's eye lands; a sequence item at the item. A
|
|
155
|
+
// key merged in through `<<` lives in the fragment it came from, which the
|
|
156
|
+
// walk cannot see — the reader then gets the position of the map instead.
|
|
157
|
+
const makeLocator = (document: Document, lines: LineCounter): ManifestLocator => {
|
|
158
|
+
const positionOf = (node: Node | null | undefined): ManifestPosition | null => {
|
|
159
|
+
const offset = node?.range?.[0];
|
|
160
|
+
if (offset === undefined) return null;
|
|
161
|
+
const { col, line } = lines.linePos(offset);
|
|
162
|
+
return { line, column: col };
|
|
163
|
+
};
|
|
164
|
+
const resolved = (node: unknown): unknown => (isAlias(node) ? node.resolve(document) : node);
|
|
165
|
+
|
|
166
|
+
return (manifestPath: ManifestPath) => {
|
|
167
|
+
let current: unknown = resolved(document.contents);
|
|
168
|
+
let position = positionOf(document.contents);
|
|
169
|
+
for (const segment of manifestPath) {
|
|
170
|
+
if (isMap(current)) {
|
|
171
|
+
const pair = current.items.find((one) => keyMatches(one, segment));
|
|
172
|
+
if (pair === undefined) break;
|
|
173
|
+
position = positionOf(isScalar(pair.key) ? pair.key : null) ?? position;
|
|
174
|
+
current = resolved(pair.value);
|
|
175
|
+
} else if (isSeq(current) && typeof segment === "number") {
|
|
176
|
+
const item = current.items[segment];
|
|
177
|
+
if (item === undefined) break;
|
|
178
|
+
position = positionOf(item as Node) ?? position;
|
|
179
|
+
current = resolved(item);
|
|
180
|
+
} else {
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return position;
|
|
185
|
+
};
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// The value as a YAML document, for `architecture migrate` and anything else
|
|
189
|
+
// that writes a manifest rather than reads one. Strings are quoted whenever
|
|
190
|
+
// the core schema would read them as something else, and a multi-line one —
|
|
191
|
+
// a probe source — becomes a block scalar.
|
|
192
|
+
export const formatManifestYaml = (manifest: unknown): string =>
|
|
193
|
+
stringify(manifest, { lineWidth: 100, blockQuote: "literal", schema: "core" });
|
package/src/load/policy.ts
CHANGED
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
type PatternInvalid,
|
|
45
45
|
} from "../domain/architecture-error.js";
|
|
46
46
|
import type { SourceFacts } from "../domain/facts.js";
|
|
47
|
+
import type { ManifestLocator } from "../domain/manifest-location.js";
|
|
47
48
|
import { type LoweredRules, lowerManifest } from "../manifest/compile.js";
|
|
48
49
|
import { decodeManifest, type Manifest } from "../manifest/manifest.js";
|
|
49
50
|
import type { FactExtractor } from "../ports/fact-extractor.js";
|
|
@@ -93,6 +94,9 @@ export type LoadPolicyInput = {
|
|
|
93
94
|
// The manifest as the host read it — a module's default export, a parsed
|
|
94
95
|
// document — before decoding.
|
|
95
96
|
readonly manifest: unknown;
|
|
97
|
+
// From a data file, where a path in the manifest was written, so a decode
|
|
98
|
+
// error names a line. A module manifest has none to give.
|
|
99
|
+
readonly locate?: ManifestLocator | undefined;
|
|
96
100
|
readonly languages: ReadonlyArray<Language>;
|
|
97
101
|
readonly fileSystem: FileSystem;
|
|
98
102
|
};
|
|
@@ -202,7 +206,7 @@ export const loadPolicy = (
|
|
|
202
206
|
): Result.Result<LoadedPolicy, ConfigInvalid | PatternInvalid> => {
|
|
203
207
|
const { configPath, fileSystem, languages, repoRoot } = input;
|
|
204
208
|
|
|
205
|
-
const decoded = decodeManifest(configPath, input.manifest);
|
|
209
|
+
const decoded = decodeManifest(configPath, input.manifest, { locate: input.locate });
|
|
206
210
|
if (Result.isFailure(decoded)) return Result.fail(decoded.failure);
|
|
207
211
|
const config = decoded.success.manifest;
|
|
208
212
|
|
|
@@ -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,99 @@
|
|
|
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
|
+
// 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.
|
|
13
|
+
|
|
14
|
+
export const MANIFEST_SCHEMA_ID =
|
|
15
|
+
"https://dataquail.github.io/goodbones/schema/architecture.schema.json";
|
|
16
|
+
|
|
17
|
+
type JsonValue = string | number | boolean | null | JsonObject | ReadonlyArray<JsonValue>;
|
|
18
|
+
type JsonObject = { readonly [key: string]: JsonValue };
|
|
19
|
+
|
|
20
|
+
const entriesOf = (value: JsonObject): ReadonlyArray<readonly [string, JsonValue]> =>
|
|
21
|
+
Object.entries(value);
|
|
22
|
+
|
|
23
|
+
const isObject = (value: JsonValue): value is JsonObject =>
|
|
24
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
25
|
+
|
|
26
|
+
// The generator names the recursive node after its own internal wrapper. A
|
|
27
|
+
// stable name is what a `$ref` in an error message or a docs page can point at.
|
|
28
|
+
const DEFINITION_NAMES: Readonly<Record<string, string>> = { Suspend_: "ManifestNode" };
|
|
29
|
+
|
|
30
|
+
const USE_REFERENCE = "#/$defs/Use";
|
|
31
|
+
|
|
32
|
+
const Use: JsonObject = {
|
|
33
|
+
type: "object",
|
|
34
|
+
description:
|
|
35
|
+
"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.",
|
|
36
|
+
properties: { use: { type: "string" } },
|
|
37
|
+
required: ["use"],
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// 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.
|
|
43
|
+
const admitReferences = (value: JsonValue): JsonValue => {
|
|
44
|
+
if (Array.isArray(value)) return value.map(admitReferences);
|
|
45
|
+
if (!isObject(value)) return value;
|
|
46
|
+
|
|
47
|
+
const rebuilt: Record<string, JsonValue> = {};
|
|
48
|
+
for (const [key, entry] of entriesOf(value)) {
|
|
49
|
+
if (key === "$ref" && typeof entry === "string") {
|
|
50
|
+
const name = entry.replace(/^#\/\$defs\//, "");
|
|
51
|
+
rebuilt[key] = `#/$defs/${DEFINITION_NAMES[name] ?? name}`;
|
|
52
|
+
} else {
|
|
53
|
+
rebuilt[key] = admitReferences(entry);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const isObjectSchema = rebuilt.type === "object" && "properties" in rebuilt;
|
|
57
|
+
return isObjectSchema ? { anyOf: [{ $ref: USE_REFERENCE }, rebuilt] } : rebuilt;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const manifestJsonSchema = (): JsonObject => {
|
|
61
|
+
const generated = Schema.toJsonSchemaDocument(Manifest) as unknown as {
|
|
62
|
+
readonly schema: JsonObject;
|
|
63
|
+
readonly definitions: JsonObject;
|
|
64
|
+
};
|
|
65
|
+
const { properties, ...root } = generated.schema;
|
|
66
|
+
if (properties === undefined || !isObject(properties)) {
|
|
67
|
+
throw new Error("the manifest schema generated with no properties");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const definitions: Record<string, JsonValue> = {};
|
|
71
|
+
for (const [name, definition] of entriesOf(generated.definitions)) {
|
|
72
|
+
definitions[DEFINITION_NAMES[name] ?? name] = admitReferences(definition);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
77
|
+
$id: MANIFEST_SCHEMA_ID,
|
|
78
|
+
title: "Architecture manifest",
|
|
79
|
+
description:
|
|
80
|
+
"One manifest of a repository's architecture, read by @goodbones/cli and @goodbones/oxlint. See https://dataquail.github.io/goodbones/architecture-rules/manifest/.",
|
|
81
|
+
...root,
|
|
82
|
+
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
|
+
},
|
|
93
|
+
...Object.fromEntries(
|
|
94
|
+
entriesOf(properties).map(([key, value]) => [key, admitReferences(value)]),
|
|
95
|
+
),
|
|
96
|
+
},
|
|
97
|
+
$defs: { ...definitions, Use },
|
|
98
|
+
};
|
|
99
|
+
};
|