@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
package/src/core/coverage.ts
CHANGED
|
@@ -59,52 +59,129 @@ const selects = (
|
|
|
59
59
|
file: string,
|
|
60
60
|
) => firstFromMatch(rule, file) !== null;
|
|
61
61
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
(
|
|
91
|
-
rule
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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: {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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> = [];
|
package/src/core/imports.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import * as Result from "effect/Result";
|
|
2
2
|
|
|
3
|
-
import type {
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
):
|
|
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:
|
|
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
|
-
|
|
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"];
|