@goodbones/core 0.1.0-beta.4 → 0.1.0-beta.5

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 (41) hide show
  1. package/build/dts/core/coverage.d.ts +14 -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 +10 -0
  8. package/build/dts/core/slack.d.ts.map +1 -0
  9. package/build/dts/domain/architecture-config.d.ts +13 -0
  10. package/build/dts/domain/architecture-config.d.ts.map +1 -1
  11. package/build/dts/domain/snapshot.d.ts +121 -0
  12. package/build/dts/domain/snapshot.d.ts.map +1 -0
  13. package/build/dts/index.d.ts +6 -4
  14. package/build/dts/index.d.ts.map +1 -1
  15. package/build/dts/manifest/compile.d.ts.map +1 -1
  16. package/build/esm/core/coverage.js +72 -32
  17. package/build/esm/core/coverage.js.map +1 -1
  18. package/build/esm/core/graph.js +37 -0
  19. package/build/esm/core/graph.js.map +1 -1
  20. package/build/esm/core/imports.js +14 -9
  21. package/build/esm/core/imports.js.map +1 -1
  22. package/build/esm/core/slack.js +39 -0
  23. package/build/esm/core/slack.js.map +1 -0
  24. package/build/esm/domain/architecture-config.js +19 -0
  25. package/build/esm/domain/architecture-config.js.map +1 -1
  26. package/build/esm/domain/snapshot.js +95 -0
  27. package/build/esm/domain/snapshot.js.map +1 -0
  28. package/build/esm/index.js +5 -3
  29. package/build/esm/index.js.map +1 -1
  30. package/build/esm/manifest/compile.js +25 -21
  31. package/build/esm/manifest/compile.js.map +1 -1
  32. package/package.json +3 -1
  33. package/schema/conformance.schema.json +457 -0
  34. package/src/core/coverage.ts +111 -34
  35. package/src/core/graph.ts +36 -0
  36. package/src/core/imports.ts +29 -13
  37. package/src/core/slack.ts +62 -0
  38. package/src/domain/architecture-config.ts +21 -0
  39. package/src/domain/snapshot.ts +180 -0
  40. package/src/index.ts +18 -0
  41. package/src/manifest/compile.ts +34 -27
@@ -1,6 +1,11 @@
1
1
  import * as Result from "effect/Result";
2
2
 
3
- import type { ImportProbe, ImportProbeTarget, ImportRule } from "../domain/architecture-config.js";
3
+ import type {
4
+ Allowance,
5
+ ImportProbe,
6
+ ImportProbeTarget,
7
+ ImportRule,
8
+ } from "../domain/architecture-config.js";
4
9
  import type { ImportUnresolved, PatternInvalid } from "../domain/architecture-error.js";
5
10
  import type { Violation } from "../domain/violation.js";
6
11
  import type { DependencyKind, ModuleResolver, ResolvedTarget } from "../ports/module-resolver.js";
@@ -25,6 +30,9 @@ export type CompiledImportRule = {
25
30
  // Third-party packages the rule permits, by name. Judged before the path
26
31
  // patterns, so where a language keeps its packages never reaches a rule.
27
32
  readonly externals: ReadonlySet<string>;
33
+ // Where each `toNot` and external came from, when lowered from a manifest;
34
+ // empty for a hand-written rule. What `slackOf` reads.
35
+ readonly allowances: ReadonlyArray<Allowance>;
28
36
  readonly dependencyKind: DependencyKind | null;
29
37
  readonly probe: ImportProbe;
30
38
  };
@@ -52,6 +60,7 @@ export const compileImportRule = (
52
60
  to: sourcesOf(rule.to),
53
61
  toNot: sourcesOf(rule.toNot),
54
62
  externals: new Set(rule.externals ?? []),
63
+ allowances: rule.allowances ?? [],
55
64
  dependencyKind: rule.dependencyKind ?? null,
56
65
  });
57
66
  };
@@ -105,17 +114,13 @@ const reports = (rule: CompiledImportRule, captures: RegExpExecArray, target: Re
105
114
  return targetAllowed(rule, captures, target.path);
106
115
  };
107
116
 
108
- export const evaluateSelectedEdge = (
117
+ // The same judgement over a target the host has already resolved — for a host
118
+ // that resolves each edge once and wants the target for something else too.
119
+ export const evaluateResolvedEdge = (
109
120
  selected: ReadonlyArray<SelectedRule>,
110
- resolver: ModuleResolver,
111
- edge: ImportEdge,
112
- ): Result.Result<ReadonlyArray<Violation>, ImportUnresolved> => {
113
- if (selected.length === 0) return Result.succeed([]);
114
-
115
- const resolved = resolver.resolve(edge.importer, edge.specifier);
116
- if (Result.isFailure(resolved)) return Result.fail(resolved.failure);
117
- const target = resolved.success;
118
-
121
+ importer: string,
122
+ target: ResolvedTarget,
123
+ ): ReadonlyArray<Violation> => {
119
124
  const violations: Array<Violation> = [];
120
125
  for (const [rule, captures] of selected) {
121
126
  if (reports(rule, captures, target)) {
@@ -123,13 +128,24 @@ export const evaluateSelectedEdge = (
123
128
  kind: "import",
124
129
  ruleName: rule.name,
125
130
  message: rule.message,
126
- file: edge.importer,
131
+ file: importer,
127
132
  subject: target.path,
128
133
  });
129
134
  }
130
135
  }
136
+ return violations;
137
+ };
138
+
139
+ export const evaluateSelectedEdge = (
140
+ selected: ReadonlyArray<SelectedRule>,
141
+ resolver: ModuleResolver,
142
+ edge: ImportEdge,
143
+ ): Result.Result<ReadonlyArray<Violation>, ImportUnresolved> => {
144
+ if (selected.length === 0) return Result.succeed([]);
131
145
 
132
- return Result.succeed(violations);
146
+ const resolved = resolver.resolve(edge.importer, edge.specifier);
147
+ if (Result.isFailure(resolved)) return Result.fail(resolved.failure);
148
+ return Result.succeed(evaluateResolvedEdge(selected, edge.importer, resolved.success));
133
149
  };
134
150
 
135
151
  export const evaluateImportEdge = (
@@ -0,0 +1,62 @@
1
+ import type { Allowance } from "../domain/architecture-config.js";
2
+ import type { ResolvedTarget } from "../ports/module-resolver.js";
3
+ import type { CompiledImportRule } from "./imports.js";
4
+ import { firstFromMatch, matchesAny } from "./patterns.js";
5
+
6
+ // Coverage says how many files an allowlist reaches. This is the other
7
+ // question about an allowlist: how much of it is used. A manifest inferred
8
+ // from today's edges reaches every file and constrains nothing, so coverage
9
+ // alone reads as complete while the policy is pure permission; slack is the
10
+ // number that tells the two apart. It is also the signature of an allowlist
11
+ // widened to make a build green — an entry nothing imports through.
12
+
13
+ // An edge the host resolved: the importer, and what the specifier became.
14
+ export type ObservedEdge = {
15
+ readonly importer: string;
16
+ readonly target: ResolvedTarget;
17
+ };
18
+
19
+ // An allowance no observed edge uses. The same shape the lowering recorded,
20
+ // minus the compiled pattern nobody wrote.
21
+ export type Slack = Pick<Allowance, "node" | "kind" | "entry">;
22
+
23
+ const keyOf = (one: Slack): string => `${one.node} ${one.kind} ${one.entry}`;
24
+
25
+ // Whether one edge, from a file this rule selects, passes through this entry.
26
+ const uses = (allowance: Allowance, captures: RegExpExecArray, target: ResolvedTarget): boolean => {
27
+ if (allowance.kind === "external") {
28
+ return target.kind === "external" && target.package === allowance.entry;
29
+ }
30
+ return allowance.pattern !== undefined && matchesAny([allowance.pattern], captures, target.path);
31
+ };
32
+
33
+ // Every allowance the rules carry that no edge uses, in the order the manifest
34
+ // declared them. An entry is inherited by every descendant's rule and written
35
+ // once, so it is keyed by where it was written: an import anywhere under the
36
+ // declaring node is a use.
37
+ export const slackOf = (
38
+ rules: ReadonlyArray<CompiledImportRule>,
39
+ edges: ReadonlyArray<ObservedEdge>,
40
+ ): ReadonlyArray<Slack> => {
41
+ const declared = new Map<string, Slack>();
42
+ for (const rule of rules) {
43
+ for (const { entry, kind, node } of rule.allowances) {
44
+ const one = { node, kind, entry };
45
+ if (!declared.has(keyOf(one))) declared.set(keyOf(one), one);
46
+ }
47
+ }
48
+
49
+ const used = new Set<string>();
50
+ for (const edge of edges) {
51
+ for (const rule of rules) {
52
+ if (rule.allowances.length === 0) continue;
53
+ const captures = firstFromMatch(rule, edge.importer);
54
+ if (captures === null) continue;
55
+ for (const allowance of rule.allowances) {
56
+ if (uses(allowance, captures, edge.target)) used.add(keyOf(allowance));
57
+ }
58
+ }
59
+ }
60
+
61
+ return [...declared.values()].filter((one) => !used.has(keyOf(one)));
62
+ };
@@ -27,6 +27,23 @@ const ImportProbe = Schema.Struct({
27
27
  to: ImportProbeTarget,
28
28
  });
29
29
 
30
+ // One entry of an `imports` allowlist, as the author wrote it and where. An
31
+ // allowlist rule compiles its entries into `toNot` patterns and `externals`
32
+ // names, which is all evaluation needs; this is the other direction — from a
33
+ // pattern back to the line in the manifest that put it there — so a report
34
+ // can say which line no import uses. Slack is the signature of an allowlist
35
+ // widened to make a build green, and it is only visible with the entry kept.
36
+ export const Allowance = Schema.Struct({
37
+ // The manifest node that declared the entry, as rule names name nodes.
38
+ node: Schema.String,
39
+ // `allow` is a path glob; `external` is a package name.
40
+ kind: Schema.Literals(["allow", "external"]),
41
+ // The entry as written, after alias expansion.
42
+ entry: Schema.String,
43
+ // For an `allow`: the compiled target pattern, as `toNot` carries it.
44
+ pattern: Schema.optionalKey(Schema.String),
45
+ });
46
+
30
47
  export const ImportRule = Schema.Struct({
31
48
  name: Schema.String,
32
49
  message: Schema.String,
@@ -35,6 +52,9 @@ export const ImportRule = Schema.Struct({
35
52
  fromNot: Schema.optionalKey(PatternList),
36
53
  to: Schema.optionalKey(PatternList),
37
54
  toNot: Schema.optionalKey(PatternList),
55
+ // Where each `toNot` pattern and `externals` name came from, for an
56
+ // allowlist lowered from a manifest. A hand-written rule carries none.
57
+ allowances: Schema.optionalKey(Schema.Array(Allowance)),
38
58
  // Third-party packages this rule permits, by package name. An external
39
59
  // target is judged by its package, never by where the language's resolver
40
60
  // happened to find it on disk — `to`/`toNot` patterns are for the
@@ -336,6 +356,7 @@ const StructureConfig = Schema.Struct({
336
356
  naming: Schema.optionalKey(Schema.Array(StructureNaming)),
337
357
  });
338
358
 
359
+ export type Allowance = (typeof Allowance)["Type"];
339
360
  export type ImportRule = (typeof ImportRule)["Type"];
340
361
  export type ResolveConfig = (typeof ResolveConfig)["Type"];
341
362
  export type ResolveScope = (typeof ResolveScope)["Type"];
@@ -0,0 +1,180 @@
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
+ export const SnapshotSlack = Schema.Struct({
69
+ node: describe(Schema.String, "The manifest node that wrote the entry."),
70
+ kind: describe(
71
+ Schema.Literals(["allow", "external"]),
72
+ "`allow` is a path glob under `imports.allow`; `external` a package name under `imports.external`.",
73
+ ),
74
+ entry: describe(Schema.String, "The entry as written, after alias expansion."),
75
+ });
76
+
77
+ export const Snapshot = Schema.Struct({
78
+ version: describe(Schema.Literal(SNAPSHOT_VERSION), "The shape of this document."),
79
+ manifest: describe(
80
+ Schema.Struct({
81
+ path: describe(
82
+ Path,
83
+ "The file the policy was read from — the root file, when split with `include`.",
84
+ ),
85
+ sha256: describe(
86
+ Schema.String,
87
+ "A hash of that file's bytes, so two snapshots can say whether the policy changed between them.",
88
+ ),
89
+ }),
90
+ "The policy this snapshot was taken against — the repository's own, or the one `--against` named.",
91
+ ),
92
+ roots: describe(Schema.Array(Path), "The directories walked."),
93
+ files: describe(Schema.Finite, "Files walked."),
94
+ ok: describe(
95
+ Schema.Boolean,
96
+ "What `check` would exit with: true when no reportable violation, unresolved import, stale baseline entry or coverage shortfall exists.",
97
+ ),
98
+ coverage: describe(
99
+ Schema.Struct(
100
+ Object.fromEntries(COVERAGE_FAMILIES.map((family) => [family, FamilyCoverage])) as Record<
101
+ (typeof COVERAGE_FAMILIES)[number],
102
+ typeof FamilyCoverage
103
+ >,
104
+ ),
105
+ "Per family, how many walked files it reaches. Structure counts enumerated folders only.",
106
+ ),
107
+ residue: describe(
108
+ Schema.Struct({
109
+ files: describe(Schema.Array(Path), "Files no family reaches, sorted."),
110
+ folders: describe(
111
+ Schema.Array(Path),
112
+ "Folders every walked file of which is residue, each the topmost such folder.",
113
+ ),
114
+ }),
115
+ "What the policy has nothing to say about. A file in an open folder under no allowlist is claimed, not policed, and counts.",
116
+ ),
117
+ violations: describe(
118
+ Schema.Array(SnapshotViolation),
119
+ "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.",
120
+ ),
121
+ unresolved: describe(
122
+ Schema.Array(Schema.Struct({ file: Path, specifier: Schema.String, detail: Schema.String })),
123
+ "Imports the resolver could not turn into a file. Every rule about one enforces nothing.",
124
+ ),
125
+ stale: describe(Schema.Array(Schema.String), "Baseline entries the code no longer produces."),
126
+ baseline: describe(
127
+ Schema.Struct({
128
+ size: describe(Schema.Finite, "Entries in the baseline file."),
129
+ }),
130
+ "The debt the policy is carrying. The ratchet: it may only shrink.",
131
+ ),
132
+ cycles: describe(
133
+ Schema.Finite,
134
+ "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.",
135
+ ),
136
+ slack: describe(
137
+ Schema.Array(SnapshotSlack),
138
+ "Allowances no observed import uses, in manifest order. A manifest inferred from the tree has none on the day it is written; every entry here is permission nothing needs.",
139
+ ),
140
+ adoption: describe(
141
+ Schema.Struct({
142
+ unrestricted: describe(Schema.Array(Schema.String), "Nodes that say `unrestricted: true`."),
143
+ partial: describe(Schema.Array(Schema.String), "Nodes that say `partial: true`."),
144
+ }),
145
+ 'The tiers that said "not tightened yet", by name; `limits` caps how many may.',
146
+ ),
147
+ });
148
+
149
+ export type Snapshot = typeof Snapshot.Type;
150
+ export type SnapshotViolation = typeof SnapshotViolation.Type;
151
+ export type SnapshotSlack = typeof SnapshotSlack.Type;
152
+
153
+ // Decodes a document some other run wrote — the base of a pull request, a
154
+ // stored one — refusing a key the shape does not declare, so a consumer never
155
+ // reads a field that a later version renamed.
156
+ export const decodeSnapshot = Schema.decodeUnknownResult(Snapshot, {
157
+ errors: "all",
158
+ onExcessProperty: "error",
159
+ });
160
+
161
+ type JsonValue = string | number | boolean | null | JsonObject | ReadonlyArray<JsonValue>;
162
+ type JsonObject = { readonly [key: string]: JsonValue };
163
+
164
+ // The document's shape as a JSON Schema, generated from the same codec, so
165
+ // the two cannot disagree. Published beside the manifest's.
166
+ export const snapshotJsonSchema = (): JsonObject => {
167
+ const generated = Schema.toJsonSchemaDocument(Snapshot) as unknown as {
168
+ readonly schema: JsonObject;
169
+ readonly definitions: JsonObject;
170
+ };
171
+ return {
172
+ $schema: "https://json-schema.org/draft/2020-12/schema",
173
+ $id: SNAPSHOT_SCHEMA_ID,
174
+ title: "Conformance snapshot",
175
+ description:
176
+ "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/.",
177
+ ...generated.schema,
178
+ ...(Object.keys(generated.definitions).length === 0 ? {} : { $defs: generated.definitions }),
179
+ };
180
+ };
package/src/index.ts CHANGED
@@ -21,6 +21,10 @@ export {
21
21
  coverageOf,
22
22
  coverageShortfalls,
23
23
  fractionsOf,
24
+ type Reach,
25
+ reachOf,
26
+ type Residue,
27
+ residueOf,
24
28
  } from "./core/coverage.js";
25
29
  export {
26
30
  type BindingEdge,
@@ -43,12 +47,14 @@ export {
43
47
  type Graph,
44
48
  graphRulesFailingTheirProbe,
45
49
  hasGraphRules,
50
+ heightOf,
46
51
  } from "./core/graph.js";
47
52
  export {
48
53
  type CompiledImportRule,
49
54
  compileImportRule,
50
55
  compileImportRules,
51
56
  evaluateImportEdge,
57
+ evaluateResolvedEdge,
52
58
  evaluateSelectedEdge,
53
59
  type ImportEdge,
54
60
  probeTargetOf,
@@ -63,6 +69,7 @@ export {
63
69
  memberRulesFailingTheirProbe,
64
70
  memberRulesSelecting,
65
71
  } from "./core/members.js";
72
+ export { type ObservedEdge, type Slack, slackOf } from "./core/slack.js";
66
73
  export {
67
74
  type CompiledStructure,
68
75
  compileStructure,
@@ -79,6 +86,7 @@ export {
79
86
  surfaceRulesSelecting,
80
87
  } from "./core/surface.js";
81
88
  export {
89
+ type Allowance,
82
90
  type BindingKind,
83
91
  type DeclarationKind,
84
92
  type ExportFix,
@@ -115,6 +123,16 @@ export {
115
123
  type ManifestPosition,
116
124
  renderManifestPath,
117
125
  } from "./domain/manifest-location.js";
126
+ export {
127
+ decodeSnapshot,
128
+ type Snapshot,
129
+ SNAPSHOT_SCHEMA_ID,
130
+ SNAPSHOT_VERSION,
131
+ snapshotJsonSchema,
132
+ Snapshot as SnapshotSchema,
133
+ type SnapshotSlack,
134
+ type SnapshotViolation,
135
+ } from "./domain/snapshot.js";
118
136
  export {
119
137
  fingerprintOf,
120
138
  formatMessage,
@@ -1,4 +1,5 @@
1
1
  import {
2
+ type Allowance,
2
3
  type ExportRule,
3
4
  type GraphConfig,
4
5
  type ImportRule,
@@ -84,12 +85,11 @@ type Frame = {
84
85
  readonly pathGlob: string;
85
86
  readonly captures: CaptureIndex;
86
87
  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>;
88
+ // The allowlist in force, accumulated down the tree; `reset` is the only
89
+ // thing that clears it. Each entry remembers the node that wrote it. A path
90
+ // glob is compiled to a target pattern; a package is judged by its name and
91
+ // never by where the language's resolver found it, so it carries none.
92
+ readonly allowances: ReadonlyArray<Allowance>;
93
93
  readonly importsMessage: string;
94
94
  // Inherited like the allowlist: a tier states its naming convention once.
95
95
  readonly naming: NamingSpec | undefined;
@@ -231,16 +231,12 @@ const mergeImports = (
231
231
  aliases: Readonly<Record<string, string>>,
232
232
  captures: CaptureIndex,
233
233
  nextGroup: number,
234
- ): Pick<Frame, "allow" | "externals" | "importsMessage"> & {
234
+ node: string,
235
+ ): Pick<Frame, "allowances" | "importsMessage"> & {
235
236
  readonly deny: ReadonlyArray<Denial>;
236
237
  } => {
237
238
  if (spec === undefined) {
238
- return {
239
- allow: frame.allow,
240
- externals: frame.externals,
241
- deny: [],
242
- importsMessage: frame.importsMessage,
243
- };
239
+ return { allowances: frame.allowances, deny: [], importsMessage: frame.importsMessage };
244
240
  }
245
241
 
246
242
  const compileAllow = (glob: string): string =>
@@ -249,8 +245,15 @@ const mergeImports = (
249
245
  .source,
250
246
  );
251
247
 
252
- const own = globsOf(spec.allow ?? []).map(compileAllow);
253
- const external = spec.external ?? [];
248
+ const own: ReadonlyArray<Allowance> = [
249
+ ...globsOf(spec.allow ?? []).map((glob) => ({
250
+ node,
251
+ kind: "allow" as const,
252
+ entry: expandAliases(glob, aliases),
253
+ pattern: compileAllow(glob),
254
+ })),
255
+ ...(spec.external ?? []).map((name) => ({ node, kind: "external" as const, entry: name })),
256
+ ];
254
257
  const deny = (spec.deny ?? []).flatMap((entry) =>
255
258
  globsOf(entry.match).map((glob) => ({
256
259
  match: compileAllow(glob),
@@ -266,8 +269,7 @@ const mergeImports = (
266
269
  // a mistake here would be dangerous in.
267
270
  const dropping = spec.reset === true || spec.unrestricted === true;
268
271
  return {
269
- allow: dropping ? own : [...frame.allow, ...own],
270
- externals: dropping ? external : [...frame.externals, ...external],
272
+ allowances: dropping ? own : [...frame.allowances, ...own],
271
273
  // Only what this node declares. A prohibition is emitted once, over its whole
272
274
  // subtree, so descendants neither re-emit it nor can escape it — which is
273
275
  // what makes `reset` structurally unable to make a subtree quieter.
@@ -392,15 +394,14 @@ export const lowerManifest = (
392
394
  parent.nextGroup +
393
395
  (Object.keys(compiled.captures).length - Object.keys(parent.captures).length);
394
396
 
395
- const merged = mergeImports(parent, node.imports, aliases, compiled.captures, nextGroup);
397
+ const merged = mergeImports(parent, node.imports, aliases, compiled.captures, nextGroup, name);
396
398
  const ownDenials = merged.deny;
397
399
  const frame: Frame = {
398
400
  pathSource,
399
401
  pathGlob: joinedGlob,
400
402
  captures: compiled.captures,
401
403
  nextGroup,
402
- allow: merged.allow,
403
- externals: merged.externals,
404
+ allowances: merged.allowances,
404
405
  importsMessage: merged.importsMessage,
405
406
  naming: node.name ?? parent.naming,
406
407
  };
@@ -608,9 +609,14 @@ export const lowerManifest = (
608
609
  ? probePathOf(joinedGlob, "")
609
610
  : probePathOf(joinedGlob, "").replace(/\/[^/]*$/, "");
610
611
 
611
- const admitsEverything = frame.allow.some((pattern) => pattern === "^.*" || pattern === "^");
612
- const hasAllowlist =
613
- (frame.allow.length > 0 || frame.externals.length > 0) && !admitsEverything;
612
+ const allow = frame.allowances.flatMap((one) =>
613
+ one.pattern === undefined ? [] : [one.pattern],
614
+ );
615
+ const externals = frame.allowances
616
+ .filter((one) => one.kind === "external")
617
+ .map((one) => one.entry);
618
+ const admitsEverything = allow.some((pattern) => pattern === "^.*" || pattern === "^");
619
+ const hasAllowlist = frame.allowances.length > 0 && !admitsEverything;
614
620
 
615
621
  if (emitsOwnImports && node.imports?.unrestricted !== true && !hasAllowlist) {
616
622
  throw new Error(
@@ -630,8 +636,9 @@ export const lowerManifest = (
630
636
  probe: { from: scopeProbe, to: probeOutside("nowhere", ownFolder) },
631
637
  from: scope,
632
638
  ...exemptions,
633
- toNot: [...frame.allow],
634
- ...(frame.externals.length > 0 ? { externals: [...frame.externals] } : {}),
639
+ toNot: allow,
640
+ ...(externals.length > 0 ? { externals } : {}),
641
+ allowances: frame.allowances,
635
642
  });
636
643
  }
637
644
  }
@@ -841,8 +848,7 @@ export const lowerManifest = (
841
848
  pathGlob: "",
842
849
  captures: {},
843
850
  nextGroup: 1,
844
- allow: [],
845
- externals: [],
851
+ allowances: [],
846
852
  importsMessage: "This import is not on this folder's allowlist.",
847
853
  naming: undefined,
848
854
  };
@@ -856,6 +862,7 @@ export const lowerManifest = (
856
862
  aliases,
857
863
  {},
858
864
  1,
865
+ "repo",
859
866
  ).deny.entries()) {
860
867
  imports.push({
861
868
  name: `repo/deny-${String(index)}`,