@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
@@ -59,52 +59,129 @@ const selects = (
59
59
  file: string,
60
60
  ) => firstFromMatch(rule, file) !== null;
61
61
 
62
- export const coverageOf = (policy: CoverageInputs, files: ReadonlyArray<string>): Coverage => {
63
- const allowlists = policy.importRules.filter(isAllowlist);
64
- let imports = 0;
65
- let enumerated = 0;
66
- let open = 0;
67
- let members = 0;
68
- let surface = 0;
69
- let graph = 0;
70
-
71
- for (const file of files) {
72
- if (allowlists.some((rule) => selects(rule, file))) imports += 1;
62
+ // Which families reach one file. The one loop both `coverageOf` and
63
+ // `residueOf` run: the first counts, the second keeps the names.
64
+ export type Reach = {
65
+ readonly file: string;
66
+ readonly imports: boolean;
67
+ // Enumerated and open are told apart, as `coverageOf` counts them.
68
+ readonly structure: "enumerated" | "open" | null;
69
+ readonly members: boolean;
70
+ readonly surface: boolean;
71
+ readonly graph: boolean;
72
+ };
73
73
 
74
+ export const reachOf = (
75
+ policy: CoverageInputs,
76
+ files: ReadonlyArray<string>,
77
+ ): ReadonlyArray<Reach> => {
78
+ const allowlists = policy.importRules.filter(isAllowlist);
79
+ return files.map((file) => {
74
80
  const folder = dirnameOf(file);
75
81
  const governing = policy.structure.folders.filter((rule) =>
76
82
  rule.folder.some((pattern) => pattern.test(folder)),
77
83
  );
78
- if (governing.length > 0) {
79
- if (governing.every((rule) => rule.files.some((pattern) => pattern.source === OPEN_LAYOUT))) {
80
- open += 1;
81
- } else {
82
- enumerated += 1;
83
- }
84
- }
85
-
86
- if (policy.memberRules.some((rule) => selects(rule, file))) members += 1;
87
- if (policy.surfaceRules.some((rule) => selects(rule, file))) surface += 1;
88
-
89
- const scoped = [...policy.graph.cycles, ...policy.graph.orphans].some(
90
- (rule) =>
91
- rule.within.some((pattern) => pattern.test(file)) &&
92
- !rule.withinNot.some((pattern) => pattern.test(file)),
93
- );
94
- if (scoped) graph += 1;
95
- }
84
+ const structure =
85
+ governing.length === 0
86
+ ? null
87
+ : governing.every((rule) => rule.files.some((pattern) => pattern.source === OPEN_LAYOUT))
88
+ ? "open"
89
+ : "enumerated";
90
+ return {
91
+ file,
92
+ imports: allowlists.some((rule) => selects(rule, file)),
93
+ structure,
94
+ members: policy.memberRules.some((rule) => selects(rule, file)),
95
+ surface: policy.surfaceRules.some((rule) => selects(rule, file)),
96
+ graph: [...policy.graph.cycles, ...policy.graph.orphans].some(
97
+ (rule) =>
98
+ rule.within.some((pattern) => pattern.test(file)) &&
99
+ !rule.withinNot.some((pattern) => pattern.test(file)),
100
+ ),
101
+ };
102
+ });
103
+ };
96
104
 
105
+ export const coverageOf = (policy: CoverageInputs, files: ReadonlyArray<string>): Coverage => {
106
+ const reach = reachOf(policy, files);
107
+ const count = (is: (one: Reach) => boolean): number => reach.filter(is).length;
97
108
  const total = files.length;
98
109
  return {
99
110
  files: total,
100
- imports: { covered: imports, total },
101
- structure: { enumerated, open, total },
102
- members: { covered: members, total },
103
- surface: { covered: surface, total },
104
- graph: { covered: graph, total },
111
+ imports: { covered: count((one) => one.imports), total },
112
+ structure: {
113
+ enumerated: count((one) => one.structure === "enumerated"),
114
+ open: count((one) => one.structure === "open"),
115
+ total,
116
+ },
117
+ members: { covered: count((one) => one.members), total },
118
+ surface: { covered: count((one) => one.surface), total },
119
+ graph: { covered: count((one) => one.graph), total },
105
120
  };
106
121
  };
107
122
 
123
+ // The files no family reaches — counted by no family's coverage, so a file in
124
+ // an open folder under no allowlist is residue: claimed, not policed — and
125
+ // the folders wholly made of them, each the topmost such folder. A policy
126
+ // that is 40% silence looks exactly like one that is 100% enforced, until
127
+ // counted; this is what the silence is made of.
128
+ export type Residue = {
129
+ readonly files: ReadonlyArray<string>;
130
+ readonly folders: ReadonlyArray<string>;
131
+ };
132
+
133
+ export const residueOf = (policy: CoverageInputs, files: ReadonlyArray<string>): Residue => {
134
+ const unreached = reachOf(policy, files)
135
+ .filter(
136
+ (one) =>
137
+ !one.imports &&
138
+ one.structure !== "enumerated" &&
139
+ !one.members &&
140
+ !one.surface &&
141
+ !one.graph,
142
+ )
143
+ .map((one) => one.file)
144
+ .sort();
145
+ return { files: unreached, folders: foldersWhollyIn(unreached, files) };
146
+ };
147
+
148
+ // Every ancestor folder of a file, nearest first, the root (`""`) excluded.
149
+ const ancestorsOf = (file: string): ReadonlyArray<string> => {
150
+ const folders: Array<string> = [];
151
+ let folder = dirnameOf(file);
152
+ while (folder !== "") {
153
+ folders.push(folder);
154
+ folder = dirnameOf(folder);
155
+ }
156
+ return folders;
157
+ };
158
+
159
+ // The topmost folders every walked file of which is in `subset`. Each is
160
+ // reported once, with none of its subfolders, and a lone file's folder counts
161
+ // — a folder with one file the policy ignores is a folder the policy ignores.
162
+ const foldersWhollyIn = (
163
+ subset: ReadonlyArray<string>,
164
+ files: ReadonlyArray<string>,
165
+ ): ReadonlyArray<string> => {
166
+ const walked = new Map<string, number>();
167
+ for (const file of files) {
168
+ for (const folder of ancestorsOf(file)) walked.set(folder, (walked.get(folder) ?? 0) + 1);
169
+ }
170
+ const inSubset = new Map<string, number>();
171
+ for (const file of subset) {
172
+ for (const folder of ancestorsOf(file)) {
173
+ inSubset.set(folder, (inSubset.get(folder) ?? 0) + 1);
174
+ }
175
+ }
176
+ const whole = [...inSubset.entries()]
177
+ .filter(([folder, count]) => walked.get(folder) === count)
178
+ .map(([folder]) => folder)
179
+ .sort();
180
+ return whole.filter(
181
+ (folder) => !whole.some((other) => other !== folder && folder.startsWith(`${other}/`)),
182
+ );
183
+ };
184
+
108
185
  // A floor the policy states for itself, per family, as a fraction. Structure
109
186
  // counts enumerated folders only: an open one is claimed, not policed by name.
110
187
  export type CoverageFloors = {
@@ -117,6 +194,59 @@ export type CoverageFloors = {
117
194
 
118
195
  export type CoverageFamily = keyof CoverageFloors;
119
196
 
197
+ // Residue is files no node reaches; this is nodes no file reaches. A node
198
+ // that states an import allowlist and selects no walked file grants
199
+ // permission to nothing: every allowance on it is unused by construction,
200
+ // which is not slack — there is no line to delete, only a node that is a
201
+ // tier declared ahead of its first file, or a pattern that no longer
202
+ // matches. Which of the two, the reader decides; the report tells both
203
+ // apart from slack so nothing has to be read twice.
204
+ export type Vacancy = ReadonlyArray<{
205
+ readonly node: string;
206
+ // Distinct entries the node wrote, `allow` and `external` together.
207
+ readonly allowances: number;
208
+ }>;
209
+
210
+ // The nodes whose allowances no live rule carries. A node's allowlist is
211
+ // inherited by every descendant's rule until one `reset`s, so a node whose
212
+ // own rule steps aside for an overriding child still reaches that child's
213
+ // files through the child's rule — and is not vacant while the child has any.
214
+ export const vacantNodesOf = (
215
+ rules: ReadonlyArray<CompiledImportRule>,
216
+ files: ReadonlyArray<string>,
217
+ ): ReadonlySet<string> => {
218
+ const declaring = new Set<string>();
219
+ const reached = new Set<string>();
220
+ for (const rule of rules) {
221
+ if (rule.allowances.length === 0) continue;
222
+ const live = files.some((file) => selects(rule, file));
223
+ for (const { node } of rule.allowances) {
224
+ declaring.add(node);
225
+ if (live) reached.add(node);
226
+ }
227
+ }
228
+ return new Set([...declaring].filter((node) => !reached.has(node)));
229
+ };
230
+
231
+ // Every vacant node with how many entries it wrote, in the order the
232
+ // allowlists declared them.
233
+ export const vacancyOf = (
234
+ rules: ReadonlyArray<CompiledImportRule>,
235
+ files: ReadonlyArray<string>,
236
+ ): Vacancy => {
237
+ const vacant = vacantNodesOf(rules, files);
238
+ const entries = new Map<string, Set<string>>();
239
+ for (const rule of rules) {
240
+ for (const { entry, kind, node } of rule.allowances) {
241
+ if (!vacant.has(node)) continue;
242
+ const written = entries.get(node) ?? new Set<string>();
243
+ written.add(`${kind} ${entry}`);
244
+ entries.set(node, written);
245
+ }
246
+ }
247
+ return [...entries.entries()].map(([node, written]) => ({ node, allowances: written.size }));
248
+ };
249
+
120
250
  export const fractionOf = (covered: number, total: number): number =>
121
251
  total === 0 ? 1 : covered / total;
122
252
 
package/src/core/graph.ts CHANGED
@@ -227,6 +227,42 @@ export const cyclesIn = (graph: Graph): ReadonlyArray<ReadonlyArray<string>> =>
227
227
  );
228
228
  });
229
229
 
230
+ // How far each file stands above a leaf: 0 for a file importing nothing the
231
+ // walk saw, else one more than the tallest thing it imports. A violation on a
232
+ // short target is local to fix; one on a tall target drags the tower with it —
233
+ // so a report lists the short ones first. Measured over the strongly
234
+ // connected components, so the members of a cycle share one height and the
235
+ // answer is finite: a cycle is one thing to fix, not a ladder.
236
+ export const heightOf = (graph: Graph): ReadonlyMap<string, number> => {
237
+ const components = stronglyConnected(graph.files, graph);
238
+ const componentOf = new Map<string, number>();
239
+ components.forEach((component, index) => {
240
+ for (const file of component) componentOf.set(file, index);
241
+ });
242
+
243
+ const heights = new Map<number, number>();
244
+ const measure = (index: number): number => {
245
+ const known = heights.get(index);
246
+ if (known !== undefined) return known;
247
+ let tallest = -1;
248
+ for (const file of components[index] ?? []) {
249
+ for (const target of neighboursOf(graph, file)) {
250
+ const other = componentOf.get(target);
251
+ if (other !== undefined && other !== index) tallest = Math.max(tallest, measure(other));
252
+ }
253
+ }
254
+ heights.set(index, tallest + 1);
255
+ return tallest + 1;
256
+ };
257
+
258
+ const byFile = new Map<string, number>();
259
+ for (const file of [...graph.files].sort()) {
260
+ const index = componentOf.get(file);
261
+ if (index !== undefined) byFile.set(file, measure(index));
262
+ }
263
+ return byFile;
264
+ };
265
+
230
266
  const evaluateCycles = (rule: CompiledGraphCycleRule, graph: Graph): ReadonlyArray<Violation> => {
231
267
  const nodes = graph.files.filter((file) => inScope(rule, file));
232
268
  const violations: Array<Violation> = [];
@@ -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,135 @@
1
+ import type { Allowance } from "../domain/architecture-config.js";
2
+ import type { ResolvedTarget } from "../ports/module-resolver.js";
3
+ import { vacantNodesOf } from "./coverage.js";
4
+ import type { CompiledImportRule } from "./imports.js";
5
+ import { firstFromMatch, matchesAny } from "./patterns.js";
6
+
7
+ // Coverage says how many files an allowlist reaches. This is the other
8
+ // question about an allowlist: how much of it is used. A manifest inferred
9
+ // from today's edges reaches every file and constrains nothing, so coverage
10
+ // alone reads as complete while the policy is pure permission; slack is the
11
+ // number that tells the two apart. It is also the signature of an allowlist
12
+ // widened to make a build green — an entry nothing imports through.
13
+
14
+ // An edge the host resolved: the importer, and what the specifier became.
15
+ export type ObservedEdge = {
16
+ readonly importer: string;
17
+ readonly target: ResolvedTarget;
18
+ };
19
+
20
+ // An allowance no observed edge uses. The same shape the lowering recorded,
21
+ // minus the compiled pattern nobody wrote. When the entry arrived through
22
+ // `use`, `node` is the fragment's name — the line to delete is in `defs` —
23
+ // and `of` says how many nodes wrote the reference that carried it.
24
+ export type Slack = Pick<Allowance, "node" | "kind" | "entry"> & {
25
+ readonly fragment?: string;
26
+ readonly of?: number;
27
+ };
28
+
29
+ // A fragment entry used at some of the nodes it was granted to and not the
30
+ // rest. At the fragment level it is not slack — there is no line nobody
31
+ // needs — but it is a per-file permission written as a many-node allowance,
32
+ // which is what an allowlist widened to make one build green looks like.
33
+ export type Concentration = Pick<Allowance, "kind" | "entry"> & {
34
+ readonly fragment: string;
35
+ readonly usedAt: number;
36
+ readonly of: number;
37
+ };
38
+
39
+ export type SlackReport = {
40
+ readonly slack: ReadonlyArray<Slack>;
41
+ readonly concentration: ReadonlyArray<Concentration>;
42
+ };
43
+
44
+ const keyOf = (one: Pick<Allowance, "node" | "kind" | "entry">): string =>
45
+ `${one.node} ${one.kind} ${one.entry}`;
46
+
47
+ // Whether one edge, from a file this rule selects, passes through this entry.
48
+ const uses = (allowance: Allowance, captures: RegExpExecArray, target: ResolvedTarget): boolean => {
49
+ if (allowance.kind === "external") {
50
+ return target.kind === "external" && target.package === allowance.entry;
51
+ }
52
+ return allowance.pattern !== undefined && matchesAny([allowance.pattern], captures, target.path);
53
+ };
54
+
55
+ // One entry as one place wrote it: a node that wrote the line, or a fragment
56
+ // that N nodes pulled in with `use`. Keyed by that place, so a fragment's
57
+ // entry is reported once however many nodes reference it.
58
+ type Declared = {
59
+ readonly node: string;
60
+ readonly kind: Allowance["kind"];
61
+ readonly entry: string;
62
+ readonly fragment: string | undefined;
63
+ // The nodes that declared it, vacant ones excluded — every allowance on a
64
+ // vacant node is unused by construction, and is vacancy, not slack.
65
+ readonly at: Set<string>;
66
+ };
67
+
68
+ const groupKeyOf = (one: Allowance): string =>
69
+ one.fragment === undefined
70
+ ? `node ${one.node} ${one.kind} ${one.entry}`
71
+ : `use ${one.fragment} ${one.kind} ${one.entry}`;
72
+
73
+ // Every allowance the rules carry that no edge uses, in the order the manifest
74
+ // declared them. An entry is inherited by every descendant's rule and written
75
+ // once, so it is keyed by where it was written: an import anywhere under the
76
+ // declaring node is a use. An entry that arrived through `use` is keyed by
77
+ // the fragment, and is slack only when no node it was granted to uses it;
78
+ // used at some and not others, it is reported as concentration instead. A
79
+ // vacant node — one whose allowlist selects no walked file — contributes
80
+ // nothing to either.
81
+ export const slackOf = (
82
+ rules: ReadonlyArray<CompiledImportRule>,
83
+ edges: ReadonlyArray<ObservedEdge>,
84
+ files: ReadonlyArray<string>,
85
+ ): SlackReport => {
86
+ const vacant = vacantNodesOf(rules, files);
87
+
88
+ const declared = new Map<string, Declared>();
89
+ for (const rule of rules) {
90
+ for (const allowance of rule.allowances) {
91
+ if (vacant.has(allowance.node)) continue;
92
+ const key = groupKeyOf(allowance);
93
+ const group = declared.get(key) ?? {
94
+ node: allowance.fragment ?? allowance.node,
95
+ kind: allowance.kind,
96
+ entry: allowance.entry,
97
+ fragment: allowance.fragment,
98
+ at: new Set<string>(),
99
+ };
100
+ group.at.add(allowance.node);
101
+ declared.set(key, group);
102
+ }
103
+ }
104
+
105
+ // Which (node, kind, entry) some edge passes through.
106
+ const used = new Set<string>();
107
+ for (const edge of edges) {
108
+ for (const rule of rules) {
109
+ if (rule.allowances.length === 0) continue;
110
+ const captures = firstFromMatch(rule, edge.importer);
111
+ if (captures === null) continue;
112
+ for (const allowance of rule.allowances) {
113
+ if (uses(allowance, captures, edge.target)) used.add(keyOf(allowance));
114
+ }
115
+ }
116
+ }
117
+
118
+ const slack: Array<Slack> = [];
119
+ const concentration: Array<Concentration> = [];
120
+ for (const group of declared.values()) {
121
+ const { entry, kind } = group;
122
+ const usedAt = [...group.at].filter((node) => used.has(keyOf({ node, kind, entry }))).length;
123
+ if (group.fragment === undefined) {
124
+ if (usedAt === 0) slack.push({ node: group.node, kind, entry });
125
+ continue;
126
+ }
127
+ const of = group.at.size;
128
+ if (usedAt === 0) {
129
+ slack.push({ node: group.node, kind, entry, fragment: group.fragment, of });
130
+ } else if (usedAt < of) {
131
+ concentration.push({ fragment: group.fragment, kind, entry, usedAt, of });
132
+ }
133
+ }
134
+ return { slack, concentration };
135
+ };
@@ -27,6 +27,28 @@ 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
+ // The `defs` fragment the entry arrived through, when the node wrote
46
+ // `use: <name>` rather than the entry itself. Slack is attributed to the
47
+ // fragment then: the node's authors wrote one word, and the line to delete
48
+ // is in `defs`.
49
+ fragment: Schema.optionalKey(Schema.String),
50
+ });
51
+
30
52
  export const ImportRule = Schema.Struct({
31
53
  name: Schema.String,
32
54
  message: Schema.String,
@@ -35,6 +57,9 @@ export const ImportRule = Schema.Struct({
35
57
  fromNot: Schema.optionalKey(PatternList),
36
58
  to: Schema.optionalKey(PatternList),
37
59
  toNot: Schema.optionalKey(PatternList),
60
+ // Where each `toNot` pattern and `externals` name came from, for an
61
+ // allowlist lowered from a manifest. A hand-written rule carries none.
62
+ allowances: Schema.optionalKey(Schema.Array(Allowance)),
38
63
  // Third-party packages this rule permits, by package name. An external
39
64
  // target is judged by its package, never by where the language's resolver
40
65
  // happened to find it on disk — `to`/`toNot` patterns are for the
@@ -336,6 +361,7 @@ const StructureConfig = Schema.Struct({
336
361
  naming: Schema.optionalKey(Schema.Array(StructureNaming)),
337
362
  });
338
363
 
364
+ export type Allowance = (typeof Allowance)["Type"];
339
365
  export type ImportRule = (typeof ImportRule)["Type"];
340
366
  export type ResolveConfig = (typeof ResolveConfig)["Type"];
341
367
  export type ResolveScope = (typeof ResolveScope)["Type"];