@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,41 @@
|
|
|
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
|
+
// 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;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// Answers with the position of the value at `path`, or the nearest ancestor
|
|
22
|
+
// that exists when the path names something the file does not contain (a
|
|
23
|
+
// missing key), or `null` when the source has no positions to give.
|
|
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
|
@@ -108,6 +108,12 @@ 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
|
+
renderManifestPath,
|
|
116
|
+
} from "./domain/manifest-location.js";
|
|
111
117
|
export {
|
|
112
118
|
fingerprintOf,
|
|
113
119
|
formatMessage,
|
|
@@ -115,13 +121,40 @@ export {
|
|
|
115
121
|
type ViolationKind,
|
|
116
122
|
} from "./domain/violation.js";
|
|
117
123
|
export { makeFileSystemLive } from "./infrastructure/file-system-live.js";
|
|
118
|
-
export {
|
|
124
|
+
export {
|
|
125
|
+
findManifestFile,
|
|
126
|
+
formatManifestYaml,
|
|
127
|
+
MANIFEST_FILENAMES,
|
|
128
|
+
type ManifestFile,
|
|
129
|
+
readManifestFile,
|
|
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";
|
|
119
137
|
export { listSourceFiles, type WalkedLanguage } from "./infrastructure/walk.js";
|
|
120
138
|
export { type LoadedPolicy, loadPolicy, type LoadPolicyInput } from "./load/policy.js";
|
|
121
139
|
export { type LoweredRules, lowerManifest, type ProbeLanguage } from "./manifest/compile.js";
|
|
140
|
+
export {
|
|
141
|
+
type ExpandedManifest,
|
|
142
|
+
type ExpandIssue,
|
|
143
|
+
expandManifest,
|
|
144
|
+
type Origin,
|
|
145
|
+
originOf,
|
|
146
|
+
type Substitution,
|
|
147
|
+
} from "./manifest/expand.js";
|
|
148
|
+
export {
|
|
149
|
+
MANIFEST_NODE_SCHEMA_ID,
|
|
150
|
+
MANIFEST_SCHEMA_ID,
|
|
151
|
+
manifestJsonSchema,
|
|
152
|
+
manifestNodeJsonSchema,
|
|
153
|
+
} from "./manifest/json-schema.js";
|
|
122
154
|
export {
|
|
123
155
|
type DecodedManifest,
|
|
124
156
|
decodeManifest,
|
|
157
|
+
type DecodeManifestOptions,
|
|
125
158
|
type Manifest,
|
|
126
159
|
type ManifestNode,
|
|
127
160
|
Manifest as ManifestSchema,
|
|
@@ -1,17 +1,213 @@
|
|
|
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
|
+
import { expandIncludes, type SourceDocument } from "./manifest-include.js";
|
|
25
|
+
|
|
26
|
+
// The manifest, as the files on disk state it, before any decoding. A data
|
|
27
|
+
// file — YAML, or JSON, which YAML 1.2 contains — is what any host in any
|
|
28
|
+
// language can read, and is the form the docs are written in. A JavaScript
|
|
29
|
+
// module is the escape hatch a Node host alone honours: for a manifest
|
|
30
|
+
// generated from other data, and for what the ecosystem expects of a config
|
|
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.
|
|
35
|
+
|
|
36
|
+
// In discovery order. A repository with none of these is told all four; one
|
|
37
|
+
// with more than one is refused, so nobody edits the wrong file for a week.
|
|
38
|
+
export const MANIFEST_FILENAMES = [
|
|
39
|
+
"architecture.yaml",
|
|
40
|
+
"architecture.yml",
|
|
41
|
+
"architecture.json",
|
|
42
|
+
"architecture.config.mjs",
|
|
43
|
+
] as const;
|
|
4
44
|
|
|
5
|
-
export
|
|
45
|
+
export type ManifestFile = {
|
|
46
|
+
readonly configPath: string;
|
|
47
|
+
readonly manifest: unknown;
|
|
48
|
+
// Absent for a JavaScript module that includes nothing, which has no
|
|
49
|
+
// positions to give.
|
|
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>;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const findManifestFile = (repoRoot: string): string => {
|
|
57
|
+
const present = MANIFEST_FILENAMES.filter((name) => existsSync(path.resolve(repoRoot, name)));
|
|
58
|
+
const [found] = present;
|
|
59
|
+
if (found === undefined) {
|
|
60
|
+
throw new ConfigInvalid({
|
|
61
|
+
configPath: repoRoot,
|
|
62
|
+
detail:
|
|
63
|
+
`no architecture manifest found. Looked for ${MANIFEST_FILENAMES.join(", ")} ` +
|
|
64
|
+
`in this directory; \`architecture init\` writes a starter architecture.yaml.`,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
if (present.length > 1) {
|
|
68
|
+
throw new ConfigInvalid({
|
|
69
|
+
configPath: repoRoot,
|
|
70
|
+
detail:
|
|
71
|
+
`more than one architecture manifest is present (${present.join(", ")}), and only one ` +
|
|
72
|
+
`can be the policy. Delete the others, or name one with ARCHITECTURE_CONFIG.`,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return path.resolve(repoRoot, found);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const extensionOf = (configPath: string): string => path.extname(configPath).toLowerCase();
|
|
79
|
+
|
|
80
|
+
const isDataManifest = (configPath: string): boolean =>
|
|
81
|
+
[".yaml", ".yml", ".json"].includes(extensionOf(configPath));
|
|
82
|
+
|
|
83
|
+
const isModuleManifest = (configPath: string): boolean =>
|
|
84
|
+
[".mjs", ".js", ".cjs"].includes(extensionOf(configPath));
|
|
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.
|
|
88
|
+
export const readManifestFile = async (configPath: string): Promise<ManifestFile> => {
|
|
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 {
|
|
104
|
+
configPath,
|
|
105
|
+
manifest: assembled.value,
|
|
106
|
+
locate: assembled.locate,
|
|
107
|
+
files: assembled.files,
|
|
108
|
+
};
|
|
109
|
+
};
|
|
6
110
|
|
|
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> => {
|
|
111
|
+
const readModule = async (configPath: string): Promise<SourceDocument> => {
|
|
11
112
|
const module: unknown = await import(pathToFileURL(configPath).href).catch((cause: unknown) => {
|
|
12
113
|
throw new ConfigInvalid({ configPath, detail: String(cause) });
|
|
13
114
|
});
|
|
14
|
-
|
|
15
|
-
? module.default
|
|
16
|
-
|
|
115
|
+
const value =
|
|
116
|
+
typeof module === "object" && module !== null && "default" in module ? module.default : module;
|
|
117
|
+
return { value, locate: undefined };
|
|
17
118
|
};
|
|
119
|
+
|
|
120
|
+
// YAML 1.2 core schema, which is what a reader without a YAML background
|
|
121
|
+
// expects: `on` and `no` are strings, not booleans. Merge keys (`<<`) resolve,
|
|
122
|
+
// because the parser handles them before the manifest sees the document, and
|
|
123
|
+
// duplicate keys are refused rather than last-one-wins. A tag the parser does
|
|
124
|
+
// not know is refused too — a manifest is data, and a `!!js/function` in it
|
|
125
|
+
// would be a manifest only one runtime could read.
|
|
126
|
+
const parseDataFile = (configPath: string): SourceDocument => {
|
|
127
|
+
let text: string;
|
|
128
|
+
try {
|
|
129
|
+
text = readFileSync(configPath, "utf8");
|
|
130
|
+
} catch (cause) {
|
|
131
|
+
throw new ConfigInvalid({ configPath, detail: String(cause) });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const lines = new LineCounter();
|
|
135
|
+
const document = parseDocument(text, {
|
|
136
|
+
merge: true,
|
|
137
|
+
uniqueKeys: true,
|
|
138
|
+
customTags: [],
|
|
139
|
+
lineCounter: lines,
|
|
140
|
+
// A JSON file is read by the same parser; strict JSON is valid YAML.
|
|
141
|
+
schema: "core",
|
|
142
|
+
});
|
|
143
|
+
const problems = [...document.errors, ...document.warnings];
|
|
144
|
+
if (problems.length > 0) {
|
|
145
|
+
const file = path.basename(configPath);
|
|
146
|
+
throw new ConfigInvalid({
|
|
147
|
+
configPath,
|
|
148
|
+
detail:
|
|
149
|
+
"the manifest does not parse:\n" +
|
|
150
|
+
problems
|
|
151
|
+
.map((problem) => {
|
|
152
|
+
const [position] = problem.linePos ?? [];
|
|
153
|
+
const at =
|
|
154
|
+
position === undefined
|
|
155
|
+
? ""
|
|
156
|
+
: `${file}:${String(position.line)}:${String(position.col)} `;
|
|
157
|
+
return ` ${at}${problem.code}: ${problem.message.split("\n")[0] ?? problem.message}`;
|
|
158
|
+
})
|
|
159
|
+
.join("\n"),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
value: document.toJS({ mapAsMap: false }) as unknown,
|
|
165
|
+
locate: makeLocator(document, lines),
|
|
166
|
+
};
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const keyMatches = (pair: Pair, segment: PropertyKey): boolean =>
|
|
170
|
+
isScalar(pair.key) && String(pair.key.value) === String(segment);
|
|
171
|
+
|
|
172
|
+
// Walks the document's own tree by the decoder's path and answers with the
|
|
173
|
+
// position of the deepest thing that exists along it. A map key is reported
|
|
174
|
+
// at the key, where the reader's eye lands; a sequence item at the item. A
|
|
175
|
+
// key merged in through `<<` lives in the fragment it came from, which the
|
|
176
|
+
// walk cannot see — the reader then gets the position of the map instead.
|
|
177
|
+
const makeLocator = (document: Document, lines: LineCounter): ManifestLocator => {
|
|
178
|
+
const positionOf = (node: Node | null | undefined): ManifestPosition | null => {
|
|
179
|
+
const offset = node?.range?.[0];
|
|
180
|
+
if (offset === undefined) return null;
|
|
181
|
+
const { col, line } = lines.linePos(offset);
|
|
182
|
+
return { line, column: col };
|
|
183
|
+
};
|
|
184
|
+
const resolved = (node: unknown): unknown => (isAlias(node) ? node.resolve(document) : node);
|
|
185
|
+
|
|
186
|
+
return (manifestPath: ManifestPath) => {
|
|
187
|
+
let current: unknown = resolved(document.contents);
|
|
188
|
+
let position = positionOf(document.contents);
|
|
189
|
+
for (const segment of manifestPath) {
|
|
190
|
+
if (isMap(current)) {
|
|
191
|
+
const pair = current.items.find((one) => keyMatches(one, segment));
|
|
192
|
+
if (pair === undefined) break;
|
|
193
|
+
position = positionOf(isScalar(pair.key) ? pair.key : null) ?? position;
|
|
194
|
+
current = resolved(pair.value);
|
|
195
|
+
} else if (isSeq(current) && typeof segment === "number") {
|
|
196
|
+
const item = current.items[segment];
|
|
197
|
+
if (item === undefined) break;
|
|
198
|
+
position = positionOf(item as Node) ?? position;
|
|
199
|
+
current = resolved(item);
|
|
200
|
+
} else {
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return position;
|
|
205
|
+
};
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
// The value as a YAML document, for `architecture migrate` and anything else
|
|
209
|
+
// that writes a manifest rather than reads one. Strings are quoted whenever
|
|
210
|
+
// the core schema would read them as something else, and a multi-line one —
|
|
211
|
+
// a probe source — becomes a block scalar.
|
|
212
|
+
export const formatManifestYaml = (manifest: unknown): string =>
|
|
213
|
+
stringify(manifest, { lineWidth: 100, blockQuote: "literal", schema: "core" });
|
|
@@ -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
|
+
};
|
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
|
|