@goodbones/core 0.1.0-beta.4 → 0.1.0-beta.6
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/core/coverage.d.ts +20 -0
- package/build/dts/core/coverage.d.ts.map +1 -1
- package/build/dts/core/graph.d.ts +1 -0
- package/build/dts/core/graph.d.ts.map +1 -1
- package/build/dts/core/imports.d.ts +3 -1
- package/build/dts/core/imports.d.ts.map +1 -1
- package/build/dts/core/slack.d.ts +22 -0
- package/build/dts/core/slack.d.ts.map +1 -0
- package/build/dts/domain/architecture-config.d.ts +15 -0
- package/build/dts/domain/architecture-config.d.ts.map +1 -1
- package/build/dts/domain/snapshot.d.ts +162 -0
- package/build/dts/domain/snapshot.d.ts.map +1 -0
- package/build/dts/index.d.ts +7 -5
- package/build/dts/index.d.ts.map +1 -1
- package/build/dts/manifest/compile.d.ts +5 -1
- package/build/dts/manifest/compile.d.ts.map +1 -1
- package/build/dts/manifest/expand.d.ts +1 -0
- package/build/dts/manifest/expand.d.ts.map +1 -1
- package/build/dts/manifest/manifest.d.ts +2 -0
- package/build/dts/manifest/manifest.d.ts.map +1 -1
- package/build/esm/core/coverage.js +107 -32
- package/build/esm/core/coverage.js.map +1 -1
- package/build/esm/core/graph.js +37 -0
- package/build/esm/core/graph.js.map +1 -1
- package/build/esm/core/imports.js +14 -9
- package/build/esm/core/imports.js.map +1 -1
- package/build/esm/core/slack.js +76 -0
- package/build/esm/core/slack.js.map +1 -0
- package/build/esm/domain/architecture-config.js +24 -0
- package/build/esm/domain/architecture-config.js.map +1 -1
- package/build/esm/domain/snapshot.js +112 -0
- package/build/esm/domain/snapshot.js.map +1 -0
- package/build/esm/index.js +6 -4
- package/build/esm/index.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/compile.js +48 -25
- package/build/esm/manifest/compile.js.map +1 -1
- package/build/esm/manifest/expand.js +5 -0
- package/build/esm/manifest/expand.js.map +1 -1
- package/build/esm/manifest/manifest.js +1 -0
- package/build/esm/manifest/manifest.js.map +1 -1
- package/package.json +3 -1
- package/schema/conformance.schema.json +546 -0
- package/src/core/coverage.ts +164 -34
- package/src/core/graph.ts +36 -0
- package/src/core/imports.ts +29 -13
- package/src/core/slack.ts +135 -0
- package/src/domain/architecture-config.ts +26 -0
- package/src/domain/snapshot.ts +225 -0
- package/src/index.ts +34 -1
- package/src/load/policy.ts +1 -1
- package/src/manifest/compile.ts +79 -28
- package/src/manifest/expand.ts +9 -0
- package/src/manifest/manifest.ts +4 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import * as Schema from "effect/Schema";
|
|
2
|
+
|
|
3
|
+
import type { ViolationKind } from "./violation.js";
|
|
4
|
+
|
|
5
|
+
// The conformance snapshot: one JSON document per run saying how far the
|
|
6
|
+
// tree is from the manifest. `architecture conformance --json` emits it, a
|
|
7
|
+
// pull-request check compares two of them, an agent reads one before it
|
|
8
|
+
// edits, and a service that keeps history stores them — so it is a wire
|
|
9
|
+
// format, defined here with a schema before anything consumes it, and
|
|
10
|
+
// versioned so a document that grows it can say which one it grew.
|
|
11
|
+
//
|
|
12
|
+
// Three things it says that `check` does not: the residue (what no family
|
|
13
|
+
// reaches), the slack (what the allowlists permit and nothing uses), and the
|
|
14
|
+
// violations ordered by how much of the graph each fix drags along. Every
|
|
15
|
+
// entry that names a violation names it by its line-independent fingerprint,
|
|
16
|
+
// which is what lets one be tracked across commits.
|
|
17
|
+
|
|
18
|
+
export const SNAPSHOT_SCHEMA_ID =
|
|
19
|
+
"https://dataquail.github.io/goodbones/schema/conformance.schema.json";
|
|
20
|
+
|
|
21
|
+
export const SNAPSHOT_VERSION = 1;
|
|
22
|
+
|
|
23
|
+
const describe = <S extends Schema.Top>(schema: S, description: string) =>
|
|
24
|
+
schema.annotate({ description });
|
|
25
|
+
|
|
26
|
+
const COVERAGE_FAMILIES = ["imports", "structure", "members", "surface", "graph"] as const;
|
|
27
|
+
|
|
28
|
+
// The kinds `Violation` carries, spelled here as a schema; the `satisfies`
|
|
29
|
+
// below keeps the two lists one.
|
|
30
|
+
const VIOLATION_KINDS = [
|
|
31
|
+
"import",
|
|
32
|
+
"export",
|
|
33
|
+
"structure",
|
|
34
|
+
"member",
|
|
35
|
+
"surface",
|
|
36
|
+
"graph",
|
|
37
|
+
] as const satisfies ReadonlyArray<ViolationKind>;
|
|
38
|
+
|
|
39
|
+
const Path = describe(Schema.String, "Repo-relative, with forward slashes.");
|
|
40
|
+
|
|
41
|
+
const FamilyCoverage = Schema.Struct({
|
|
42
|
+
covered: describe(Schema.Finite, "Files this family reaches."),
|
|
43
|
+
total: describe(Schema.Finite, "Files walked."),
|
|
44
|
+
floor: Schema.optionalKey(
|
|
45
|
+
describe(
|
|
46
|
+
Schema.Finite,
|
|
47
|
+
"The fraction the manifest's `limits.coverage` states for this family, when it states one.",
|
|
48
|
+
),
|
|
49
|
+
),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
export const SnapshotViolation = Schema.Struct({
|
|
53
|
+
fingerprint: describe(
|
|
54
|
+
Schema.String,
|
|
55
|
+
"kind|rule|file|subject — line-independent, so it survives edits to the file it names; the baseline's key.",
|
|
56
|
+
),
|
|
57
|
+
kind: Schema.Literals(VIOLATION_KINDS),
|
|
58
|
+
ruleName: describe(Schema.String, "The manifest node path the rule was lowered from."),
|
|
59
|
+
file: Path,
|
|
60
|
+
subject: describe(
|
|
61
|
+
Schema.NullOr(Schema.String),
|
|
62
|
+
"The other end of the violated relationship — the resolved target, the restricted symbol, the missing sibling — or null when the file alone is the violation.",
|
|
63
|
+
),
|
|
64
|
+
message: Schema.String,
|
|
65
|
+
baselined: describe(Schema.Boolean, "Carried by the baseline, so `check` does not fail on it."),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const AllowanceKind = describe(
|
|
69
|
+
Schema.Literals(["allow", "external"]),
|
|
70
|
+
"`allow` is a path glob under `imports.allow`; `external` a package name under `imports.external`.",
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const Entry = describe(Schema.String, "The entry as written, after alias expansion.");
|
|
74
|
+
|
|
75
|
+
export const SnapshotSlack = Schema.Struct({
|
|
76
|
+
node: describe(
|
|
77
|
+
Schema.String,
|
|
78
|
+
"The manifest node that wrote the entry — or, when `fragment` is set, the `defs` fragment it was written in.",
|
|
79
|
+
),
|
|
80
|
+
kind: AllowanceKind,
|
|
81
|
+
entry: Entry,
|
|
82
|
+
fragment: Schema.optionalKey(
|
|
83
|
+
describe(
|
|
84
|
+
Schema.String,
|
|
85
|
+
"Present when the entry arrived through `use`. The nodes that referenced the fragment wrote one word, so the entry is reported once, against the fragment, rather than once per node.",
|
|
86
|
+
),
|
|
87
|
+
),
|
|
88
|
+
of: Schema.optionalKey(
|
|
89
|
+
describe(
|
|
90
|
+
Schema.Finite,
|
|
91
|
+
"With `fragment`: how many non-vacant nodes were granted the entry through it. None of them uses it.",
|
|
92
|
+
),
|
|
93
|
+
),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
export const SnapshotConcentration = Schema.Struct({
|
|
97
|
+
fragment: describe(Schema.String, "The `defs` fragment the entry was written in."),
|
|
98
|
+
kind: AllowanceKind,
|
|
99
|
+
entry: Entry,
|
|
100
|
+
usedAt: describe(Schema.Finite, "Nodes granted the entry through the fragment that use it."),
|
|
101
|
+
of: describe(Schema.Finite, "Non-vacant nodes granted the entry through the fragment."),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
export const SnapshotVacancy = Schema.Struct({
|
|
105
|
+
node: describe(Schema.String, "The manifest node."),
|
|
106
|
+
allowances: describe(
|
|
107
|
+
Schema.Finite,
|
|
108
|
+
"Distinct entries the node wrote, `allow` and `external` together.",
|
|
109
|
+
),
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
export const Snapshot = Schema.Struct({
|
|
113
|
+
version: describe(Schema.Literal(SNAPSHOT_VERSION), "The shape of this document."),
|
|
114
|
+
manifest: describe(
|
|
115
|
+
Schema.Struct({
|
|
116
|
+
path: describe(
|
|
117
|
+
Path,
|
|
118
|
+
"The file the policy was read from — the root file, when split with `include`.",
|
|
119
|
+
),
|
|
120
|
+
sha256: describe(
|
|
121
|
+
Schema.String,
|
|
122
|
+
"A hash of that file's bytes, so two snapshots can say whether the policy changed between them.",
|
|
123
|
+
),
|
|
124
|
+
}),
|
|
125
|
+
"The policy this snapshot was taken against — the repository's own, or the one `--against` named.",
|
|
126
|
+
),
|
|
127
|
+
roots: describe(Schema.Array(Path), "The directories walked."),
|
|
128
|
+
files: describe(Schema.Finite, "Files walked."),
|
|
129
|
+
ok: describe(
|
|
130
|
+
Schema.Boolean,
|
|
131
|
+
"What `check` would exit with: true when no reportable violation, unresolved import, stale baseline entry or coverage shortfall exists.",
|
|
132
|
+
),
|
|
133
|
+
coverage: describe(
|
|
134
|
+
Schema.Struct(
|
|
135
|
+
Object.fromEntries(COVERAGE_FAMILIES.map((family) => [family, FamilyCoverage])) as Record<
|
|
136
|
+
(typeof COVERAGE_FAMILIES)[number],
|
|
137
|
+
typeof FamilyCoverage
|
|
138
|
+
>,
|
|
139
|
+
),
|
|
140
|
+
"Per family, how many walked files it reaches. Structure counts enumerated folders only.",
|
|
141
|
+
),
|
|
142
|
+
residue: describe(
|
|
143
|
+
Schema.Struct({
|
|
144
|
+
files: describe(Schema.Array(Path), "Files no family reaches, sorted."),
|
|
145
|
+
folders: describe(
|
|
146
|
+
Schema.Array(Path),
|
|
147
|
+
"Folders every walked file of which is residue, each the topmost such folder.",
|
|
148
|
+
),
|
|
149
|
+
}),
|
|
150
|
+
"What the policy has nothing to say about. A file in an open folder under no allowlist is claimed, not policed, and counts.",
|
|
151
|
+
),
|
|
152
|
+
vacant: describe(
|
|
153
|
+
Schema.Array(SnapshotVacancy),
|
|
154
|
+
"Nodes that state an import allowlist and select no walked file, in manifest order. Every allowance on one is unused by construction, so none is counted as slack; the node is a tier declared ahead of its first file, or a pattern that no longer matches. Residue is files no node reaches; this is nodes no file reaches.",
|
|
155
|
+
),
|
|
156
|
+
violations: describe(
|
|
157
|
+
Schema.Array(SnapshotViolation),
|
|
158
|
+
"Every finding, baselined ones included, ordered so the ones cheapest to fix come first: by the height of the violated target in the import graph, leaves first.",
|
|
159
|
+
),
|
|
160
|
+
unresolved: describe(
|
|
161
|
+
Schema.Array(Schema.Struct({ file: Path, specifier: Schema.String, detail: Schema.String })),
|
|
162
|
+
"Imports the resolver could not turn into a file. Every rule about one enforces nothing.",
|
|
163
|
+
),
|
|
164
|
+
stale: describe(Schema.Array(Schema.String), "Baseline entries the code no longer produces."),
|
|
165
|
+
baseline: describe(
|
|
166
|
+
Schema.Struct({
|
|
167
|
+
size: describe(Schema.Finite, "Entries in the baseline file."),
|
|
168
|
+
}),
|
|
169
|
+
"The debt the policy is carrying. The ratchet: it may only shrink.",
|
|
170
|
+
),
|
|
171
|
+
cycles: describe(
|
|
172
|
+
Schema.Finite,
|
|
173
|
+
"Strongly connected components of more than one file, or a file importing itself, anywhere in the walked graph — in a cycles rule's scope or not.",
|
|
174
|
+
),
|
|
175
|
+
slack: describe(
|
|
176
|
+
Schema.Array(SnapshotSlack),
|
|
177
|
+
"Allowances no observed import uses, in manifest order, vacant nodes excluded. A manifest inferred from the tree has none on the day it is written; every entry here is permission nothing needs. An entry that arrived through `use` is reported once, against the fragment, and only when no node granted it uses it.",
|
|
178
|
+
),
|
|
179
|
+
concentration: describe(
|
|
180
|
+
Schema.Array(SnapshotConcentration),
|
|
181
|
+
"Fragment entries used at some of the nodes granted them and not the rest. Not slack — the fragment's line is needed somewhere — but a per-file permission written as a many-node allowance, which is what an allowlist widened to make one build green looks like.",
|
|
182
|
+
),
|
|
183
|
+
adoption: describe(
|
|
184
|
+
Schema.Struct({
|
|
185
|
+
unrestricted: describe(Schema.Array(Schema.String), "Nodes that say `unrestricted: true`."),
|
|
186
|
+
partial: describe(Schema.Array(Schema.String), "Nodes that say `partial: true`."),
|
|
187
|
+
}),
|
|
188
|
+
'The tiers that said "not tightened yet", by name; `limits` caps how many may.',
|
|
189
|
+
),
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
export type Snapshot = typeof Snapshot.Type;
|
|
193
|
+
export type SnapshotViolation = typeof SnapshotViolation.Type;
|
|
194
|
+
export type SnapshotSlack = typeof SnapshotSlack.Type;
|
|
195
|
+
export type SnapshotConcentration = typeof SnapshotConcentration.Type;
|
|
196
|
+
export type SnapshotVacancy = typeof SnapshotVacancy.Type;
|
|
197
|
+
|
|
198
|
+
// Decodes a document some other run wrote — the base of a pull request, a
|
|
199
|
+
// stored one — refusing a key the shape does not declare, so a consumer never
|
|
200
|
+
// reads a field that a later version renamed.
|
|
201
|
+
export const decodeSnapshot = Schema.decodeUnknownResult(Snapshot, {
|
|
202
|
+
errors: "all",
|
|
203
|
+
onExcessProperty: "error",
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
type JsonValue = string | number | boolean | null | JsonObject | ReadonlyArray<JsonValue>;
|
|
207
|
+
type JsonObject = { readonly [key: string]: JsonValue };
|
|
208
|
+
|
|
209
|
+
// The document's shape as a JSON Schema, generated from the same codec, so
|
|
210
|
+
// the two cannot disagree. Published beside the manifest's.
|
|
211
|
+
export const snapshotJsonSchema = (): JsonObject => {
|
|
212
|
+
const generated = Schema.toJsonSchemaDocument(Snapshot) as unknown as {
|
|
213
|
+
readonly schema: JsonObject;
|
|
214
|
+
readonly definitions: JsonObject;
|
|
215
|
+
};
|
|
216
|
+
return {
|
|
217
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
218
|
+
$id: SNAPSHOT_SCHEMA_ID,
|
|
219
|
+
title: "Conformance snapshot",
|
|
220
|
+
description:
|
|
221
|
+
"How far a repository's tree is from its architecture manifest, as `architecture conformance --json` reports it. See https://dataquail.github.io/goodbones/architecture-rules/enforcement/conformance/.",
|
|
222
|
+
...generated.schema,
|
|
223
|
+
...(Object.keys(generated.definitions).length === 0 ? {} : { $defs: generated.definitions }),
|
|
224
|
+
};
|
|
225
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -21,6 +21,12 @@ export {
|
|
|
21
21
|
coverageOf,
|
|
22
22
|
coverageShortfalls,
|
|
23
23
|
fractionsOf,
|
|
24
|
+
type Reach,
|
|
25
|
+
reachOf,
|
|
26
|
+
type Residue,
|
|
27
|
+
residueOf,
|
|
28
|
+
type Vacancy,
|
|
29
|
+
vacancyOf,
|
|
24
30
|
} from "./core/coverage.js";
|
|
25
31
|
export {
|
|
26
32
|
type BindingEdge,
|
|
@@ -43,12 +49,14 @@ export {
|
|
|
43
49
|
type Graph,
|
|
44
50
|
graphRulesFailingTheirProbe,
|
|
45
51
|
hasGraphRules,
|
|
52
|
+
heightOf,
|
|
46
53
|
} from "./core/graph.js";
|
|
47
54
|
export {
|
|
48
55
|
type CompiledImportRule,
|
|
49
56
|
compileImportRule,
|
|
50
57
|
compileImportRules,
|
|
51
58
|
evaluateImportEdge,
|
|
59
|
+
evaluateResolvedEdge,
|
|
52
60
|
evaluateSelectedEdge,
|
|
53
61
|
type ImportEdge,
|
|
54
62
|
probeTargetOf,
|
|
@@ -63,6 +71,13 @@ export {
|
|
|
63
71
|
memberRulesFailingTheirProbe,
|
|
64
72
|
memberRulesSelecting,
|
|
65
73
|
} from "./core/members.js";
|
|
74
|
+
export {
|
|
75
|
+
type Concentration,
|
|
76
|
+
type ObservedEdge,
|
|
77
|
+
type Slack,
|
|
78
|
+
slackOf,
|
|
79
|
+
type SlackReport,
|
|
80
|
+
} from "./core/slack.js";
|
|
66
81
|
export {
|
|
67
82
|
type CompiledStructure,
|
|
68
83
|
compileStructure,
|
|
@@ -79,6 +94,7 @@ export {
|
|
|
79
94
|
surfaceRulesSelecting,
|
|
80
95
|
} from "./core/surface.js";
|
|
81
96
|
export {
|
|
97
|
+
type Allowance,
|
|
82
98
|
type BindingKind,
|
|
83
99
|
type DeclarationKind,
|
|
84
100
|
type ExportFix,
|
|
@@ -115,6 +131,18 @@ export {
|
|
|
115
131
|
type ManifestPosition,
|
|
116
132
|
renderManifestPath,
|
|
117
133
|
} from "./domain/manifest-location.js";
|
|
134
|
+
export {
|
|
135
|
+
decodeSnapshot,
|
|
136
|
+
type Snapshot,
|
|
137
|
+
SNAPSHOT_SCHEMA_ID,
|
|
138
|
+
SNAPSHOT_VERSION,
|
|
139
|
+
type SnapshotConcentration,
|
|
140
|
+
snapshotJsonSchema,
|
|
141
|
+
Snapshot as SnapshotSchema,
|
|
142
|
+
type SnapshotSlack,
|
|
143
|
+
type SnapshotVacancy,
|
|
144
|
+
type SnapshotViolation,
|
|
145
|
+
} from "./domain/snapshot.js";
|
|
118
146
|
export {
|
|
119
147
|
fingerprintOf,
|
|
120
148
|
formatMessage,
|
|
@@ -143,7 +171,12 @@ export {
|
|
|
143
171
|
type WalkedLanguage,
|
|
144
172
|
} from "./infrastructure/walk.js";
|
|
145
173
|
export { type LoadedPolicy, loadPolicy, type LoadPolicyInput } from "./load/policy.js";
|
|
146
|
-
export {
|
|
174
|
+
export {
|
|
175
|
+
type LoweredRules,
|
|
176
|
+
lowerManifest,
|
|
177
|
+
type LowerOptions,
|
|
178
|
+
type ProbeLanguage,
|
|
179
|
+
} from "./manifest/compile.js";
|
|
147
180
|
export {
|
|
148
181
|
type ExpandedManifest,
|
|
149
182
|
type ExpandIssue,
|
package/src/load/policy.ts
CHANGED
|
@@ -216,7 +216,7 @@ export const loadPolicy = (
|
|
|
216
216
|
// The manifest is the authoring surface; these flat rules are the machine's.
|
|
217
217
|
// The languages tell lowering what a source file in each scope is called, so
|
|
218
218
|
// a synthetic probe is a file of the scope's language.
|
|
219
|
-
const rules = lowerManifest(config, languages);
|
|
219
|
+
const rules = lowerManifest(config, languages, { substitutions: decoded.success.substitutions });
|
|
220
220
|
|
|
221
221
|
// The ceilings. A tier that says "not tightened yet" is a sentence someone
|
|
222
222
|
// wrote; a ceiling on how many may say so is what keeps the backlog from
|
package/src/manifest/compile.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
type Allowance,
|
|
2
3
|
type ExportRule,
|
|
3
4
|
type GraphConfig,
|
|
4
5
|
type ImportRule,
|
|
@@ -10,6 +11,8 @@ import {
|
|
|
10
11
|
type StructureRoot,
|
|
11
12
|
type SurfaceRule,
|
|
12
13
|
} from "../domain/architecture-config.js";
|
|
14
|
+
import type { ManifestPath } from "../domain/manifest-location.js";
|
|
15
|
+
import { fragmentOf, type Substitution } from "./expand.js";
|
|
13
16
|
import { anchored, type CaptureIndex, globToRegexSource, prefixed } from "./glob.js";
|
|
14
17
|
import {
|
|
15
18
|
globsOf,
|
|
@@ -84,12 +87,11 @@ type Frame = {
|
|
|
84
87
|
readonly pathGlob: string;
|
|
85
88
|
readonly captures: CaptureIndex;
|
|
86
89
|
readonly nextGroup: number;
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
|
|
92
|
-
readonly externals: ReadonlyArray<string>;
|
|
90
|
+
// The allowlist in force, accumulated down the tree; `reset` is the only
|
|
91
|
+
// thing that clears it. Each entry remembers the node that wrote it. A path
|
|
92
|
+
// glob is compiled to a target pattern; a package is judged by its name and
|
|
93
|
+
// never by where the language's resolver found it, so it carries none.
|
|
94
|
+
readonly allowances: ReadonlyArray<Allowance>;
|
|
93
95
|
readonly importsMessage: string;
|
|
94
96
|
// Inherited like the allowlist: a tier states its naming convention once.
|
|
95
97
|
readonly naming: NamingSpec | undefined;
|
|
@@ -225,22 +227,24 @@ type Denial = {
|
|
|
225
227
|
readonly probe: string;
|
|
226
228
|
};
|
|
227
229
|
|
|
230
|
+
// Which `defs` fragment one key of a node's `imports` was written in, when it
|
|
231
|
+
// was written in one. Resolved per key, since `imports: { use: x, allow: […] }`
|
|
232
|
+
// takes `allow` from the reference site and `external` from the fragment.
|
|
233
|
+
type ImportsProvenance = (key: "allow" | "external") => string | undefined;
|
|
234
|
+
|
|
228
235
|
const mergeImports = (
|
|
229
236
|
frame: Frame,
|
|
230
237
|
spec: ImportsSpec | undefined,
|
|
231
238
|
aliases: Readonly<Record<string, string>>,
|
|
232
239
|
captures: CaptureIndex,
|
|
233
240
|
nextGroup: number,
|
|
234
|
-
|
|
241
|
+
node: string,
|
|
242
|
+
provenance: ImportsProvenance,
|
|
243
|
+
): Pick<Frame, "allowances" | "importsMessage"> & {
|
|
235
244
|
readonly deny: ReadonlyArray<Denial>;
|
|
236
245
|
} => {
|
|
237
246
|
if (spec === undefined) {
|
|
238
|
-
return {
|
|
239
|
-
allow: frame.allow,
|
|
240
|
-
externals: frame.externals,
|
|
241
|
-
deny: [],
|
|
242
|
-
importsMessage: frame.importsMessage,
|
|
243
|
-
};
|
|
247
|
+
return { allowances: frame.allowances, deny: [], importsMessage: frame.importsMessage };
|
|
244
248
|
}
|
|
245
249
|
|
|
246
250
|
const compileAllow = (glob: string): string =>
|
|
@@ -249,8 +253,25 @@ const mergeImports = (
|
|
|
249
253
|
.source,
|
|
250
254
|
);
|
|
251
255
|
|
|
252
|
-
const
|
|
253
|
-
|
|
256
|
+
const via = (key: "allow" | "external"): Pick<Allowance, "fragment"> => {
|
|
257
|
+
const fragment = provenance(key);
|
|
258
|
+
return fragment === undefined ? {} : { fragment };
|
|
259
|
+
};
|
|
260
|
+
const own: ReadonlyArray<Allowance> = [
|
|
261
|
+
...globsOf(spec.allow ?? []).map((glob) => ({
|
|
262
|
+
node,
|
|
263
|
+
kind: "allow" as const,
|
|
264
|
+
entry: expandAliases(glob, aliases),
|
|
265
|
+
pattern: compileAllow(glob),
|
|
266
|
+
...via("allow"),
|
|
267
|
+
})),
|
|
268
|
+
...(spec.external ?? []).map((name) => ({
|
|
269
|
+
node,
|
|
270
|
+
kind: "external" as const,
|
|
271
|
+
entry: name,
|
|
272
|
+
...via("external"),
|
|
273
|
+
})),
|
|
274
|
+
];
|
|
254
275
|
const deny = (spec.deny ?? []).flatMap((entry) =>
|
|
255
276
|
globsOf(entry.match).map((glob) => ({
|
|
256
277
|
match: compileAllow(glob),
|
|
@@ -266,8 +287,7 @@ const mergeImports = (
|
|
|
266
287
|
// a mistake here would be dangerous in.
|
|
267
288
|
const dropping = spec.reset === true || spec.unrestricted === true;
|
|
268
289
|
return {
|
|
269
|
-
|
|
270
|
-
externals: dropping ? external : [...frame.externals, ...external],
|
|
290
|
+
allowances: dropping ? own : [...frame.allowances, ...own],
|
|
271
291
|
// Only what this node declares. A prohibition is emitted once, over its whole
|
|
272
292
|
// subtree, so descendants neither re-emit it nor can escape it — which is
|
|
273
293
|
// what makes `reset` structurally unable to make a subtree quieter.
|
|
@@ -276,11 +296,20 @@ const mergeImports = (
|
|
|
276
296
|
};
|
|
277
297
|
};
|
|
278
298
|
|
|
299
|
+
export type LowerOptions = {
|
|
300
|
+
// The `use` references the expansion replaced, so an allowance can say
|
|
301
|
+
// which fragment it came through. A manifest lowered without them is one
|
|
302
|
+
// whose every entry reads as authored where it sits.
|
|
303
|
+
readonly substitutions?: ReadonlyArray<Substitution>;
|
|
304
|
+
};
|
|
305
|
+
|
|
279
306
|
export const lowerManifest = (
|
|
280
307
|
manifest: Manifest,
|
|
281
308
|
languages: ReadonlyArray<ProbeLanguage> = [],
|
|
309
|
+
options: LowerOptions = {},
|
|
282
310
|
): LoweredRules => {
|
|
283
311
|
const aliases = manifest.aliases ?? {};
|
|
312
|
+
const substitutions = options.substitutions ?? [];
|
|
284
313
|
|
|
285
314
|
// The extension a synthetic probe file carries: the first extension of the
|
|
286
315
|
// language whose scope covers the probe's folder. A probe is matched by its
|
|
@@ -343,6 +372,9 @@ export const lowerManifest = (
|
|
|
343
372
|
parent: Frame,
|
|
344
373
|
name: string,
|
|
345
374
|
siblings: ReadonlyArray<string>,
|
|
375
|
+
// Where this node sits in the expanded document, so its `imports` keys
|
|
376
|
+
// can be traced back through any `use` that carried them.
|
|
377
|
+
nodePath: ManifestPath,
|
|
346
378
|
): void => {
|
|
347
379
|
const literalSiblings = siblings
|
|
348
380
|
.filter((sibling) => sibling !== key)
|
|
@@ -392,15 +424,22 @@ export const lowerManifest = (
|
|
|
392
424
|
parent.nextGroup +
|
|
393
425
|
(Object.keys(compiled.captures).length - Object.keys(parent.captures).length);
|
|
394
426
|
|
|
395
|
-
const merged = mergeImports(
|
|
427
|
+
const merged = mergeImports(
|
|
428
|
+
parent,
|
|
429
|
+
node.imports,
|
|
430
|
+
aliases,
|
|
431
|
+
compiled.captures,
|
|
432
|
+
nextGroup,
|
|
433
|
+
name,
|
|
434
|
+
(field) => fragmentOf(substitutions, [...nodePath, "imports", field]),
|
|
435
|
+
);
|
|
396
436
|
const ownDenials = merged.deny;
|
|
397
437
|
const frame: Frame = {
|
|
398
438
|
pathSource,
|
|
399
439
|
pathGlob: joinedGlob,
|
|
400
440
|
captures: compiled.captures,
|
|
401
441
|
nextGroup,
|
|
402
|
-
|
|
403
|
-
externals: merged.externals,
|
|
442
|
+
allowances: merged.allowances,
|
|
404
443
|
importsMessage: merged.importsMessage,
|
|
405
444
|
naming: node.name ?? parent.naming,
|
|
406
445
|
};
|
|
@@ -590,7 +629,11 @@ export const lowerManifest = (
|
|
|
590
629
|
}
|
|
591
630
|
const siblingKeys = childKeys.map(([childKey]) => childKey);
|
|
592
631
|
for (const [childKey, child] of childKeys) {
|
|
593
|
-
walk(childKey, child, frame, `${name}/${alternativesOf(childKey)[0] ?? ""}`, siblingKeys
|
|
632
|
+
walk(childKey, child, frame, `${name}/${alternativesOf(childKey)[0] ?? ""}`, siblingKeys, [
|
|
633
|
+
...nodePath,
|
|
634
|
+
"children",
|
|
635
|
+
childKey,
|
|
636
|
+
]);
|
|
594
637
|
}
|
|
595
638
|
}
|
|
596
639
|
|
|
@@ -608,9 +651,14 @@ export const lowerManifest = (
|
|
|
608
651
|
? probePathOf(joinedGlob, "")
|
|
609
652
|
: probePathOf(joinedGlob, "").replace(/\/[^/]*$/, "");
|
|
610
653
|
|
|
611
|
-
const
|
|
612
|
-
|
|
613
|
-
|
|
654
|
+
const allow = frame.allowances.flatMap((one) =>
|
|
655
|
+
one.pattern === undefined ? [] : [one.pattern],
|
|
656
|
+
);
|
|
657
|
+
const externals = frame.allowances
|
|
658
|
+
.filter((one) => one.kind === "external")
|
|
659
|
+
.map((one) => one.entry);
|
|
660
|
+
const admitsEverything = allow.some((pattern) => pattern === "^.*" || pattern === "^");
|
|
661
|
+
const hasAllowlist = frame.allowances.length > 0 && !admitsEverything;
|
|
614
662
|
|
|
615
663
|
if (emitsOwnImports && node.imports?.unrestricted !== true && !hasAllowlist) {
|
|
616
664
|
throw new Error(
|
|
@@ -630,8 +678,9 @@ export const lowerManifest = (
|
|
|
630
678
|
probe: { from: scopeProbe, to: probeOutside("nowhere", ownFolder) },
|
|
631
679
|
from: scope,
|
|
632
680
|
...exemptions,
|
|
633
|
-
toNot:
|
|
634
|
-
...(
|
|
681
|
+
toNot: allow,
|
|
682
|
+
...(externals.length > 0 ? { externals } : {}),
|
|
683
|
+
allowances: frame.allowances,
|
|
635
684
|
});
|
|
636
685
|
}
|
|
637
686
|
}
|
|
@@ -841,8 +890,7 @@ export const lowerManifest = (
|
|
|
841
890
|
pathGlob: "",
|
|
842
891
|
captures: {},
|
|
843
892
|
nextGroup: 1,
|
|
844
|
-
|
|
845
|
-
externals: [],
|
|
893
|
+
allowances: [],
|
|
846
894
|
importsMessage: "This import is not on this folder's allowlist.",
|
|
847
895
|
naming: undefined,
|
|
848
896
|
};
|
|
@@ -856,6 +904,8 @@ export const lowerManifest = (
|
|
|
856
904
|
aliases,
|
|
857
905
|
{},
|
|
858
906
|
1,
|
|
907
|
+
"repo",
|
|
908
|
+
() => undefined,
|
|
859
909
|
).deny.entries()) {
|
|
860
910
|
imports.push({
|
|
861
911
|
name: `repo/deny-${String(index)}`,
|
|
@@ -898,6 +948,7 @@ export const lowerManifest = (
|
|
|
898
948
|
.replace(/[^a-zA-Z0-9]+/g, "-")
|
|
899
949
|
.replace(/^-|-$/g, ""),
|
|
900
950
|
Object.keys(manifest.tree),
|
|
951
|
+
["tree", key],
|
|
901
952
|
);
|
|
902
953
|
}
|
|
903
954
|
|
package/src/manifest/expand.ts
CHANGED
|
@@ -172,3 +172,12 @@ export const originOf = (
|
|
|
172
172
|
via: [...outer, { at: innermost.ref, name: innermost.name }],
|
|
173
173
|
};
|
|
174
174
|
};
|
|
175
|
+
|
|
176
|
+
// The fragment a path in the expanded document was written in, when it was:
|
|
177
|
+
// the innermost `use` the path crossed, or nothing when the value at that path
|
|
178
|
+
// was authored where it sits — including a key written beside `use`, which
|
|
179
|
+
// belongs to the reference site.
|
|
180
|
+
export const fragmentOf = (
|
|
181
|
+
substitutions: ReadonlyArray<Substitution>,
|
|
182
|
+
path: ManifestPath,
|
|
183
|
+
): string | undefined => originOf(substitutions, path).via.at(-1)?.name;
|
package/src/manifest/manifest.ts
CHANGED
|
@@ -305,6 +305,9 @@ export type DecodedManifest = {
|
|
|
305
305
|
// Things the manifest said in a form that still loads but is on its way out.
|
|
306
306
|
// The host prints them; nothing else acts on them.
|
|
307
307
|
readonly notices: ReadonlyArray<string>;
|
|
308
|
+
// Every `use` the expansion replaced. Lowering reads them to say which
|
|
309
|
+
// fragment an allowance came through; nothing else needs them.
|
|
310
|
+
readonly substitutions: ReadonlyArray<Substitution>;
|
|
308
311
|
};
|
|
309
312
|
|
|
310
313
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
@@ -496,5 +499,6 @@ export const decodeManifest = (
|
|
|
496
499
|
return Result.succeed({
|
|
497
500
|
manifest: decoded.success,
|
|
498
501
|
notices: [...resolve.notices, ...members.notices],
|
|
502
|
+
substitutions,
|
|
499
503
|
});
|
|
500
504
|
};
|