@goodbones/core 0.1.0-beta.1 → 0.1.0-beta.10
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/campaigns.d.ts +141 -0
- package/build/dts/core/campaigns.d.ts.map +1 -0
- 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 +2 -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/ledger.d.ts +61 -0
- package/build/dts/core/ledger.d.ts.map +1 -0
- package/build/dts/core/slack.d.ts +22 -0
- package/build/dts/core/slack.d.ts.map +1 -0
- package/build/dts/core/structure.d.ts +1 -0
- package/build/dts/core/structure.d.ts.map +1 -1
- package/build/dts/domain/architecture-config.d.ts +324 -1
- package/build/dts/domain/architecture-config.d.ts.map +1 -1
- package/build/dts/domain/architecture-error.d.ts +8 -0
- package/build/dts/domain/architecture-error.d.ts.map +1 -1
- package/build/dts/domain/facts.d.ts +1 -0
- package/build/dts/domain/facts.d.ts.map +1 -1
- package/build/dts/domain/manifest-location.d.ts +9 -0
- package/build/dts/domain/manifest-location.d.ts.map +1 -0
- package/build/dts/domain/report.d.ts +16 -0
- package/build/dts/domain/report.d.ts.map +1 -0
- package/build/dts/domain/snapshot.d.ts +224 -0
- package/build/dts/domain/snapshot.d.ts.map +1 -0
- package/build/dts/domain/violation.d.ts +1 -1
- package/build/dts/domain/violation.d.ts.map +1 -1
- package/build/dts/index.d.ts +24 -9
- package/build/dts/index.d.ts.map +1 -1
- package/build/dts/infrastructure/campaign-functions.d.ts +7 -0
- package/build/dts/infrastructure/campaign-functions.d.ts.map +1 -0
- package/build/dts/infrastructure/manifest-file.d.ts +11 -2
- package/build/dts/infrastructure/manifest-file.d.ts.map +1 -1
- package/build/dts/infrastructure/manifest-include.d.ts +16 -0
- package/build/dts/infrastructure/manifest-include.d.ts.map +1 -0
- package/build/dts/infrastructure/report-source-fake.d.ts +4 -0
- package/build/dts/infrastructure/report-source-fake.d.ts.map +1 -0
- package/build/dts/infrastructure/report-source-live.d.ts +3 -0
- package/build/dts/infrastructure/report-source-live.d.ts.map +1 -0
- package/build/dts/infrastructure/syntax-matcher-fake.d.ts +10 -0
- package/build/dts/infrastructure/syntax-matcher-fake.d.ts.map +1 -0
- package/build/dts/infrastructure/walk.d.ts +6 -0
- package/build/dts/infrastructure/walk.d.ts.map +1 -1
- package/build/dts/load/policy.d.ts +16 -0
- package/build/dts/load/policy.d.ts.map +1 -1
- package/build/dts/manifest/compile.d.ts +7 -2
- package/build/dts/manifest/compile.d.ts.map +1 -1
- package/build/dts/manifest/expand.d.ts +27 -0
- package/build/dts/manifest/expand.d.ts.map +1 -0
- package/build/dts/manifest/infer.d.ts +55 -0
- package/build/dts/manifest/infer.d.ts.map +1 -0
- package/build/dts/manifest/json-schema.d.ts +10 -0
- package/build/dts/manifest/json-schema.d.ts.map +1 -0
- package/build/dts/manifest/manifest.d.ts +381 -1
- package/build/dts/manifest/manifest.d.ts.map +1 -1
- package/build/dts/ports/campaign-predicate.d.ts +24 -0
- package/build/dts/ports/campaign-predicate.d.ts.map +1 -0
- package/build/dts/ports/language.d.ts +4 -0
- package/build/dts/ports/language.d.ts.map +1 -1
- package/build/dts/ports/report-source.d.ts +14 -0
- package/build/dts/ports/report-source.d.ts.map +1 -0
- package/build/dts/ports/syntax-matcher.d.ts +21 -0
- package/build/dts/ports/syntax-matcher.d.ts.map +1 -0
- package/build/dts/testing.d.ts +2 -0
- package/build/dts/testing.d.ts.map +1 -1
- package/build/esm/core/campaigns.js +778 -0
- package/build/esm/core/campaigns.js.map +1 -0
- package/build/esm/core/coverage.js +107 -32
- package/build/esm/core/coverage.js.map +1 -1
- package/build/esm/core/graph.js +45 -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/ledger.js +171 -0
- package/build/esm/core/ledger.js.map +1 -0
- package/build/esm/core/slack.js +76 -0
- package/build/esm/core/slack.js.map +1 -0
- package/build/esm/core/structure.js +5 -2
- package/build/esm/core/structure.js.map +1 -1
- package/build/esm/domain/architecture-config.js +158 -1
- package/build/esm/domain/architecture-config.js.map +1 -1
- package/build/esm/domain/architecture-error.js +27 -0
- package/build/esm/domain/architecture-error.js.map +1 -1
- package/build/esm/domain/manifest-location.js +21 -0
- package/build/esm/domain/manifest-location.js.map +1 -0
- package/build/esm/domain/report.js +168 -0
- package/build/esm/domain/report.js.map +1 -0
- package/build/esm/domain/snapshot.js +141 -0
- package/build/esm/domain/snapshot.js.map +1 -0
- package/build/esm/domain/violation.js.map +1 -1
- package/build/esm/index.js +21 -8
- package/build/esm/index.js.map +1 -1
- package/build/esm/infrastructure/campaign-functions.js +67 -0
- package/build/esm/infrastructure/campaign-functions.js.map +1 -0
- package/build/esm/infrastructure/manifest-file.js +158 -7
- package/build/esm/infrastructure/manifest-file.js.map +1 -1
- package/build/esm/infrastructure/manifest-include.js +187 -0
- package/build/esm/infrastructure/manifest-include.js.map +1 -0
- package/build/esm/infrastructure/report-source-fake.js +6 -0
- package/build/esm/infrastructure/report-source-fake.js.map +1 -0
- package/build/esm/infrastructure/report-source-live.js +165 -0
- package/build/esm/infrastructure/report-source-live.js.map +1 -0
- package/build/esm/infrastructure/syntax-matcher-fake.js +33 -0
- package/build/esm/infrastructure/syntax-matcher-fake.js.map +1 -0
- package/build/esm/infrastructure/walk.js +57 -1
- package/build/esm/infrastructure/walk.js.map +1 -1
- package/build/esm/load/policy.js +123 -3
- package/build/esm/load/policy.js.map +1 -1
- package/build/esm/manifest/compile.js +228 -26
- package/build/esm/manifest/compile.js.map +1 -1
- package/build/esm/manifest/expand.js +116 -0
- package/build/esm/manifest/expand.js.map +1 -0
- package/build/esm/manifest/infer.js +455 -0
- package/build/esm/manifest/infer.js.map +1 -0
- package/build/esm/manifest/json-schema.js +135 -0
- package/build/esm/manifest/json-schema.js.map +1 -0
- package/build/esm/manifest/manifest.js +243 -5
- package/build/esm/manifest/manifest.js.map +1 -1
- package/build/esm/ports/campaign-predicate.js +2 -0
- package/build/esm/ports/campaign-predicate.js.map +1 -0
- package/build/esm/ports/report-source.js +7 -0
- package/build/esm/ports/report-source.js.map +1 -0
- package/build/esm/ports/syntax-matcher.js +12 -0
- package/build/esm/ports/syntax-matcher.js.map +1 -0
- package/build/esm/testing.js +2 -0
- package/build/esm/testing.js.map +1 -1
- package/package.json +9 -3
- package/schema/architecture-node.schema.json +2405 -0
- package/schema/architecture.schema.json +2825 -0
- package/schema/conformance.schema.json +775 -0
- package/src/core/campaigns.ts +1056 -0
- package/src/core/coverage.ts +164 -34
- package/src/core/graph.ts +48 -0
- package/src/core/imports.ts +29 -13
- package/src/core/ledger.ts +242 -0
- package/src/core/slack.ts +135 -0
- package/src/core/structure.ts +10 -5
- package/src/domain/architecture-config.ts +205 -1
- package/src/domain/architecture-error.ts +30 -0
- package/src/domain/facts.ts +9 -4
- package/src/domain/manifest-location.ts +41 -0
- package/src/domain/report.ts +203 -0
- package/src/domain/snapshot.ts +302 -0
- package/src/domain/violation.ts +4 -1
- package/src/index.ts +172 -3
- package/src/infrastructure/campaign-functions.ts +98 -0
- package/src/infrastructure/manifest-file.ts +204 -8
- package/src/infrastructure/manifest-include.ts +318 -0
- package/src/infrastructure/report-source-fake.ts +10 -0
- package/src/infrastructure/report-source-live.ts +192 -0
- package/src/infrastructure/syntax-matcher-fake.ts +51 -0
- package/src/infrastructure/walk.ts +70 -1
- package/src/load/policy.ts +193 -3
- package/src/manifest/compile.ts +290 -28
- package/src/manifest/expand.ts +183 -0
- package/src/manifest/infer.ts +643 -0
- package/src/manifest/json-schema.ts +168 -0
- package/src/manifest/manifest.ts +339 -11
- package/src/ports/campaign-predicate.ts +30 -0
- package/src/ports/language.ts +17 -0
- package/src/ports/report-source.ts +35 -0
- package/src/ports/syntax-matcher.ts +42 -0
- package/src/testing.ts +2 -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
|
@@ -215,6 +215,54 @@ const stronglyConnected = (
|
|
|
215
215
|
return components;
|
|
216
216
|
};
|
|
217
217
|
|
|
218
|
+
// Every cycle in the graph, each as its sorted member set. What `infer` asks
|
|
219
|
+
// before it writes a no-cycles rule: a rule the tree fails on day one is a
|
|
220
|
+
// baseline entry, not a description of the tree.
|
|
221
|
+
export const cyclesIn = (graph: Graph): ReadonlyArray<ReadonlyArray<string>> =>
|
|
222
|
+
stronglyConnected(graph.files, graph).filter((component) => {
|
|
223
|
+
const [first] = component;
|
|
224
|
+
return (
|
|
225
|
+
first !== undefined &&
|
|
226
|
+
(component.length > 1 || (graph.edges.get(first) ?? []).includes(first))
|
|
227
|
+
);
|
|
228
|
+
});
|
|
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
|
+
|
|
218
266
|
const evaluateCycles = (rule: CompiledGraphCycleRule, graph: Graph): ReadonlyArray<Violation> => {
|
|
219
267
|
const nodes = graph.files.filter((file) => inScope(rule, file));
|
|
220
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,242 @@
|
|
|
1
|
+
import * as Result from "effect/Result";
|
|
2
|
+
import * as Schema from "effect/Schema";
|
|
3
|
+
|
|
4
|
+
import type { CampaignUnit } from "../domain/architecture-config.js";
|
|
5
|
+
import type { Violation } from "../domain/violation.js";
|
|
6
|
+
|
|
7
|
+
// A campaign's ledger: every place the pattern still occurs, and the record of
|
|
8
|
+
// every time the count was allowed to go up. It is the baseline's cousin and
|
|
9
|
+
// not the baseline — same fingerprint, different file, different semantics.
|
|
10
|
+
//
|
|
11
|
+
// - One file per campaign, so two pull requests migrating different
|
|
12
|
+
// components never conflict, and finishing a campaign is deleting its file.
|
|
13
|
+
// - Growth is only ever recorded, never silent. `prune` only removes;
|
|
14
|
+
// `allow` is the one way an entry is added, and it always appends a
|
|
15
|
+
// regression with a reason, a timestamp and an author.
|
|
16
|
+
// - The arithmetic is checked: `entries.length === initial + Σ delta − fixed`.
|
|
17
|
+
// A hand-added entry with no regression record fails the build, with no
|
|
18
|
+
// git history in the loop.
|
|
19
|
+
// - A fixed entry is stale, and `check` fails on it as it fails on a stale
|
|
20
|
+
// baseline entry, so progress lands as a visible diff.
|
|
21
|
+
|
|
22
|
+
const Regression = Schema.Struct({
|
|
23
|
+
at: Schema.String,
|
|
24
|
+
by: Schema.String,
|
|
25
|
+
delta: Schema.Finite,
|
|
26
|
+
reason: Schema.String,
|
|
27
|
+
entries: Schema.Array(Schema.String),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export const Ledger = Schema.Struct({
|
|
31
|
+
version: Schema.Literal(1),
|
|
32
|
+
id: Schema.String,
|
|
33
|
+
created: Schema.String,
|
|
34
|
+
// The count on the day the ledger was written.
|
|
35
|
+
initial: Schema.Finite,
|
|
36
|
+
// Entries removed by `prune` since.
|
|
37
|
+
fixed: Schema.Finite,
|
|
38
|
+
// When an entry last left the ledger — what the stall clock reads.
|
|
39
|
+
lastProgress: Schema.String,
|
|
40
|
+
regressions: Schema.Array(Regression),
|
|
41
|
+
// `file` or `file#subject`, deduplicated and sorted.
|
|
42
|
+
entries: Schema.Array(Schema.String),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
export type Ledger = typeof Ledger.Type;
|
|
46
|
+
export type Regression = typeof Regression.Type;
|
|
47
|
+
|
|
48
|
+
const decode = Schema.decodeUnknownResult(Ledger, { errors: "all", onExcessProperty: "error" });
|
|
49
|
+
|
|
50
|
+
export const EMPTY_LEDGER = (id: string, now: number): Ledger => {
|
|
51
|
+
const at = new Date(now).toISOString();
|
|
52
|
+
return {
|
|
53
|
+
version: 1,
|
|
54
|
+
id,
|
|
55
|
+
created: at,
|
|
56
|
+
initial: 0,
|
|
57
|
+
fixed: 0,
|
|
58
|
+
lastProgress: at,
|
|
59
|
+
regressions: [],
|
|
60
|
+
entries: [],
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// A malformed ledger is refused, unlike a malformed baseline, which reads as
|
|
65
|
+
// empty: an empty baseline reports every violation, the safe direction, while
|
|
66
|
+
// an empty ledger would report every hit as unrecorded growth and demand a
|
|
67
|
+
// regression record for debt that was already counted.
|
|
68
|
+
export const decodeLedger = (raw: unknown): Result.Result<Ledger, string> => {
|
|
69
|
+
const decoded = decode(raw);
|
|
70
|
+
return Result.isFailure(decoded)
|
|
71
|
+
? Result.fail(String(decoded.failure.issue))
|
|
72
|
+
: Result.succeed(decoded.success);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export const serializeLedger = (ledger: Ledger): string => `${JSON.stringify(ledger, null, 2)}\n`;
|
|
76
|
+
|
|
77
|
+
// A hit's entry: the file, and its subject when the campaign's unit has one.
|
|
78
|
+
export const entryOf = (violation: Violation): string =>
|
|
79
|
+
violation.subject === null ? violation.file : `${violation.file}#${violation.subject}`;
|
|
80
|
+
|
|
81
|
+
const allowedTotal = (ledger: Ledger): number =>
|
|
82
|
+
ledger.initial + ledger.regressions.reduce((sum, one) => sum + one.delta, 0);
|
|
83
|
+
|
|
84
|
+
export const ledgerArithmeticHolds = (ledger: Ledger): boolean =>
|
|
85
|
+
ledger.entries.length === allowedTotal(ledger) - ledger.fixed;
|
|
86
|
+
|
|
87
|
+
// A `match` entry is `file#anchor#hash`; an edit inside the anchored
|
|
88
|
+
// declaration changes the hash and nothing else, and is the same entry.
|
|
89
|
+
const anchorOf = (entry: string): string => entry.slice(0, entry.lastIndexOf("#"));
|
|
90
|
+
|
|
91
|
+
export type Reconciliation = {
|
|
92
|
+
// Hits the ledger already carries, exactly or by anchor.
|
|
93
|
+
readonly ledgered: ReadonlyArray<Violation>;
|
|
94
|
+
// Hits the ledger does not carry: unrecorded growth.
|
|
95
|
+
readonly unrecorded: ReadonlyArray<Violation>;
|
|
96
|
+
// Entries no hit produces: fixed, and waiting to be pruned.
|
|
97
|
+
readonly stale: ReadonlyArray<string>;
|
|
98
|
+
// Entries whose hash moved under a still-present anchor, with what they
|
|
99
|
+
// read now. `prune` rewrites them; they count as neither fixed nor new.
|
|
100
|
+
readonly drifted: ReadonlyArray<{ readonly from: string; readonly to: string }>;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export const reconcile = (
|
|
104
|
+
ledger: Ledger,
|
|
105
|
+
hits: Iterable<Violation>,
|
|
106
|
+
unit: CampaignUnit,
|
|
107
|
+
): Reconciliation => {
|
|
108
|
+
const entries = new Set(ledger.entries);
|
|
109
|
+
const ledgered: Array<Violation> = [];
|
|
110
|
+
const unmatched: Array<Violation> = [];
|
|
111
|
+
const current = new Set<string>();
|
|
112
|
+
for (const hit of hits) {
|
|
113
|
+
const entry = entryOf(hit);
|
|
114
|
+
current.add(entry);
|
|
115
|
+
if (entries.has(entry)) ledgered.push(hit);
|
|
116
|
+
else unmatched.push(hit);
|
|
117
|
+
}
|
|
118
|
+
let stale = ledger.entries.filter((entry) => !current.has(entry));
|
|
119
|
+
const drifted: Array<{ from: string; to: string }> = [];
|
|
120
|
+
const unrecorded: Array<Violation> = [];
|
|
121
|
+
|
|
122
|
+
if (unit === "match") {
|
|
123
|
+
// Pair each stale entry with an unmatched hit under the same anchor, in
|
|
124
|
+
// order, so a declaration with two edited matches keeps two entries.
|
|
125
|
+
const byAnchor = new Map<string, Array<string>>();
|
|
126
|
+
for (const entry of stale) {
|
|
127
|
+
const anchor = anchorOf(entry);
|
|
128
|
+
byAnchor.set(anchor, [...(byAnchor.get(anchor) ?? []), entry]);
|
|
129
|
+
}
|
|
130
|
+
const paired = new Set<string>();
|
|
131
|
+
for (const hit of unmatched) {
|
|
132
|
+
const entry = entryOf(hit);
|
|
133
|
+
const candidates = byAnchor.get(anchorOf(entry));
|
|
134
|
+
const from = candidates?.shift();
|
|
135
|
+
if (from === undefined) {
|
|
136
|
+
unrecorded.push(hit);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
paired.add(from);
|
|
140
|
+
drifted.push({ from, to: entry });
|
|
141
|
+
ledgered.push(hit);
|
|
142
|
+
}
|
|
143
|
+
stale = stale.filter((entry) => !paired.has(entry));
|
|
144
|
+
} else {
|
|
145
|
+
for (const hit of unmatched) unrecorded.push(hit);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return { ledgered, unrecorded, stale, drifted };
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
export const staleEntriesOf = (
|
|
152
|
+
ledger: Ledger,
|
|
153
|
+
hits: Iterable<Violation>,
|
|
154
|
+
unit: CampaignUnit,
|
|
155
|
+
): ReadonlyArray<string> => reconcile(ledger, hits, unit).stale;
|
|
156
|
+
|
|
157
|
+
export const newEntriesOf = (
|
|
158
|
+
ledger: Ledger,
|
|
159
|
+
hits: Iterable<Violation>,
|
|
160
|
+
unit: CampaignUnit,
|
|
161
|
+
): ReadonlyArray<string> => reconcile(ledger, hits, unit).unrecorded.map(entryOf);
|
|
162
|
+
|
|
163
|
+
const sorted = (entries: Iterable<string>): ReadonlyArray<string> => [...new Set(entries)].sort();
|
|
164
|
+
|
|
165
|
+
// A first ledger: every hit is an entry, and the count is the `initial`.
|
|
166
|
+
export const ledgerOf = (id: string, hits: Iterable<Violation>, now: number): Ledger => {
|
|
167
|
+
const entries = sorted([...hits].map(entryOf));
|
|
168
|
+
return { ...EMPTY_LEDGER(id, now), initial: entries.length, entries };
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
// `prune`: the stale entries leave, `fixed` rises by as many, and
|
|
172
|
+
// `lastProgress` moves — only when something left. A drifted entry is
|
|
173
|
+
// rewritten in place and counts as neither.
|
|
174
|
+
export const pruned = (
|
|
175
|
+
ledger: Ledger,
|
|
176
|
+
hits: Iterable<Violation>,
|
|
177
|
+
unit: CampaignUnit,
|
|
178
|
+
now: number,
|
|
179
|
+
): Ledger => {
|
|
180
|
+
const { drifted, stale } = reconcile(ledger, hits, unit);
|
|
181
|
+
const rewritten = new Map(drifted.map((one) => [one.from, one.to]));
|
|
182
|
+
const gone = new Set(stale);
|
|
183
|
+
const entries = sorted(
|
|
184
|
+
ledger.entries
|
|
185
|
+
.filter((entry) => !gone.has(entry))
|
|
186
|
+
.map((entry) => rewritten.get(entry) ?? entry),
|
|
187
|
+
);
|
|
188
|
+
return {
|
|
189
|
+
...ledger,
|
|
190
|
+
fixed: ledger.fixed + stale.length,
|
|
191
|
+
lastProgress: stale.length > 0 ? new Date(now).toISOString() : ledger.lastProgress,
|
|
192
|
+
entries,
|
|
193
|
+
};
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
export type RegressionRecord = {
|
|
197
|
+
readonly at: number;
|
|
198
|
+
readonly by: string;
|
|
199
|
+
readonly reason: string;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
// `allow`: the entries join the ledger, and a regression records that they
|
|
203
|
+
// did. The delta is what was actually added — an entry already present is
|
|
204
|
+
// not growth.
|
|
205
|
+
export const allowed = (
|
|
206
|
+
ledger: Ledger,
|
|
207
|
+
entries: Iterable<string>,
|
|
208
|
+
record: RegressionRecord,
|
|
209
|
+
): Ledger => {
|
|
210
|
+
const present = new Set(ledger.entries);
|
|
211
|
+
const added = sorted([...entries].filter((entry) => !present.has(entry)));
|
|
212
|
+
if (added.length === 0) return ledger;
|
|
213
|
+
return {
|
|
214
|
+
...ledger,
|
|
215
|
+
regressions: [
|
|
216
|
+
...ledger.regressions,
|
|
217
|
+
{
|
|
218
|
+
at: new Date(record.at).toISOString(),
|
|
219
|
+
by: record.by,
|
|
220
|
+
delta: added.length,
|
|
221
|
+
reason: record.reason,
|
|
222
|
+
entries: added,
|
|
223
|
+
},
|
|
224
|
+
],
|
|
225
|
+
entries: sorted([...ledger.entries, ...added]),
|
|
226
|
+
};
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
// `1 − count / (initial + Σ delta)`: how much of everything the campaign was
|
|
230
|
+
// ever asked to pay down has been paid.
|
|
231
|
+
export const progressOf = (ledger: Ledger): number => {
|
|
232
|
+
const total = allowedTotal(ledger);
|
|
233
|
+
return total === 0 ? 1 : 1 - ledger.entries.length / total;
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
export const isComplete = (ledger: Ledger): boolean => ledger.entries.length === 0;
|
|
237
|
+
|
|
238
|
+
export const isStalled = (
|
|
239
|
+
rule: { readonly staleAfter: number },
|
|
240
|
+
ledger: Ledger,
|
|
241
|
+
now: number,
|
|
242
|
+
): boolean => !isComplete(ledger) && now - Date.parse(ledger.lastProgress) > rule.staleAfter;
|