@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.
Files changed (55) hide show
  1. package/build/dts/core/coverage.d.ts +20 -0
  2. package/build/dts/core/coverage.d.ts.map +1 -1
  3. package/build/dts/core/graph.d.ts +1 -0
  4. package/build/dts/core/graph.d.ts.map +1 -1
  5. package/build/dts/core/imports.d.ts +3 -1
  6. package/build/dts/core/imports.d.ts.map +1 -1
  7. package/build/dts/core/slack.d.ts +22 -0
  8. package/build/dts/core/slack.d.ts.map +1 -0
  9. package/build/dts/domain/architecture-config.d.ts +15 -0
  10. package/build/dts/domain/architecture-config.d.ts.map +1 -1
  11. package/build/dts/domain/snapshot.d.ts +162 -0
  12. package/build/dts/domain/snapshot.d.ts.map +1 -0
  13. package/build/dts/index.d.ts +7 -5
  14. package/build/dts/index.d.ts.map +1 -1
  15. package/build/dts/manifest/compile.d.ts +5 -1
  16. package/build/dts/manifest/compile.d.ts.map +1 -1
  17. package/build/dts/manifest/expand.d.ts +1 -0
  18. package/build/dts/manifest/expand.d.ts.map +1 -1
  19. package/build/dts/manifest/manifest.d.ts +2 -0
  20. package/build/dts/manifest/manifest.d.ts.map +1 -1
  21. package/build/esm/core/coverage.js +107 -32
  22. package/build/esm/core/coverage.js.map +1 -1
  23. package/build/esm/core/graph.js +37 -0
  24. package/build/esm/core/graph.js.map +1 -1
  25. package/build/esm/core/imports.js +14 -9
  26. package/build/esm/core/imports.js.map +1 -1
  27. package/build/esm/core/slack.js +76 -0
  28. package/build/esm/core/slack.js.map +1 -0
  29. package/build/esm/domain/architecture-config.js +24 -0
  30. package/build/esm/domain/architecture-config.js.map +1 -1
  31. package/build/esm/domain/snapshot.js +112 -0
  32. package/build/esm/domain/snapshot.js.map +1 -0
  33. package/build/esm/index.js +6 -4
  34. package/build/esm/index.js.map +1 -1
  35. package/build/esm/load/policy.js +1 -1
  36. package/build/esm/load/policy.js.map +1 -1
  37. package/build/esm/manifest/compile.js +48 -25
  38. package/build/esm/manifest/compile.js.map +1 -1
  39. package/build/esm/manifest/expand.js +5 -0
  40. package/build/esm/manifest/expand.js.map +1 -1
  41. package/build/esm/manifest/manifest.js +1 -0
  42. package/build/esm/manifest/manifest.js.map +1 -1
  43. package/package.json +3 -1
  44. package/schema/conformance.schema.json +546 -0
  45. package/src/core/coverage.ts +164 -34
  46. package/src/core/graph.ts +36 -0
  47. package/src/core/imports.ts +29 -13
  48. package/src/core/slack.ts +135 -0
  49. package/src/domain/architecture-config.ts +26 -0
  50. package/src/domain/snapshot.ts +225 -0
  51. package/src/index.ts +34 -1
  52. package/src/load/policy.ts +1 -1
  53. package/src/manifest/compile.ts +79 -28
  54. package/src/manifest/expand.ts +9 -0
  55. 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 { type LoweredRules, lowerManifest, type ProbeLanguage } from "./manifest/compile.js";
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,
@@ -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
@@ -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
- // Accumulated down the tree. `reset` is the only thing that clears it.
88
- readonly allow: ReadonlyArray<string>;
89
- // Third-party packages, by name, accumulated and reset the same way. Kept
90
- // apart from `allow` because a package is judged by its name and never by
91
- // where the language's resolver found it.
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
- ): Pick<Frame, "allow" | "externals" | "importsMessage"> & {
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 own = globsOf(spec.allow ?? []).map(compileAllow);
253
- const external = spec.external ?? [];
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
- allow: dropping ? own : [...frame.allow, ...own],
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(parent, node.imports, aliases, compiled.captures, nextGroup);
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
- allow: merged.allow,
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 admitsEverything = frame.allow.some((pattern) => pattern === "^.*" || pattern === "^");
612
- const hasAllowlist =
613
- (frame.allow.length > 0 || frame.externals.length > 0) && !admitsEverything;
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: [...frame.allow],
634
- ...(frame.externals.length > 0 ? { externals: [...frame.externals] } : {}),
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
- allow: [],
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
 
@@ -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;
@@ -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
  };