@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.
- package/build/dts/core/coverage.d.ts +14 -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 +10 -0
- package/build/dts/core/slack.d.ts.map +1 -0
- package/build/dts/domain/architecture-config.d.ts +13 -0
- package/build/dts/domain/architecture-config.d.ts.map +1 -1
- package/build/dts/domain/snapshot.d.ts +121 -0
- package/build/dts/domain/snapshot.d.ts.map +1 -0
- package/build/dts/index.d.ts +6 -4
- package/build/dts/index.d.ts.map +1 -1
- package/build/dts/manifest/compile.d.ts.map +1 -1
- package/build/esm/core/coverage.js +72 -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 +39 -0
- package/build/esm/core/slack.js.map +1 -0
- package/build/esm/domain/architecture-config.js +19 -0
- package/build/esm/domain/architecture-config.js.map +1 -1
- package/build/esm/domain/snapshot.js +95 -0
- package/build/esm/domain/snapshot.js.map +1 -0
- package/build/esm/index.js +5 -3
- package/build/esm/index.js.map +1 -1
- package/build/esm/manifest/compile.js +25 -21
- package/build/esm/manifest/compile.js.map +1 -1
- package/package.json +3 -1
- package/schema/conformance.schema.json +457 -0
- package/src/core/coverage.ts +111 -34
- package/src/core/graph.ts +36 -0
- package/src/core/imports.ts +29 -13
- package/src/core/slack.ts +62 -0
- package/src/domain/architecture-config.ts +21 -0
- package/src/domain/snapshot.ts +180 -0
- package/src/index.ts +18 -0
- package/src/manifest/compile.ts +34 -27
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodbones/core",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "Architecture policy as one manifest of your repository, language-agnostic: the manifest schema, the evaluators for imports, exports, members, surface, structure and the import graph, the ports a language pack implements, and the loader that turns a manifest into a policy every rule of which has proven it can fire.",
|
|
@@ -34,6 +34,8 @@
|
|
|
34
34
|
"default": "./build/esm/testing.js"
|
|
35
35
|
},
|
|
36
36
|
"./schema/architecture.schema.json": "./schema/architecture.schema.json",
|
|
37
|
+
"./schema/architecture-node.schema.json": "./schema/architecture-node.schema.json",
|
|
38
|
+
"./schema/conformance.schema.json": "./schema/conformance.schema.json",
|
|
37
39
|
"./package.json": "./package.json"
|
|
38
40
|
},
|
|
39
41
|
"types": "./build/dts/index.d.ts",
|
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://dataquail.github.io/goodbones/schema/conformance.schema.json",
|
|
4
|
+
"title": "Conformance snapshot",
|
|
5
|
+
"description": "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/.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"properties": {
|
|
8
|
+
"version": {
|
|
9
|
+
"type": "number",
|
|
10
|
+
"enum": [
|
|
11
|
+
1
|
|
12
|
+
],
|
|
13
|
+
"description": "The shape of this document."
|
|
14
|
+
},
|
|
15
|
+
"manifest": {
|
|
16
|
+
"type": "object",
|
|
17
|
+
"properties": {
|
|
18
|
+
"path": {
|
|
19
|
+
"type": "string",
|
|
20
|
+
"description": "The file the policy was read from — the root file, when split with `include`."
|
|
21
|
+
},
|
|
22
|
+
"sha256": {
|
|
23
|
+
"type": "string",
|
|
24
|
+
"description": "A hash of that file's bytes, so two snapshots can say whether the policy changed between them."
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"required": [
|
|
28
|
+
"path",
|
|
29
|
+
"sha256"
|
|
30
|
+
],
|
|
31
|
+
"additionalProperties": false,
|
|
32
|
+
"description": "The policy this snapshot was taken against — the repository's own, or the one `--against` named."
|
|
33
|
+
},
|
|
34
|
+
"roots": {
|
|
35
|
+
"type": "array",
|
|
36
|
+
"items": {
|
|
37
|
+
"type": "string",
|
|
38
|
+
"description": "Repo-relative, with forward slashes."
|
|
39
|
+
},
|
|
40
|
+
"description": "The directories walked."
|
|
41
|
+
},
|
|
42
|
+
"files": {
|
|
43
|
+
"type": "number",
|
|
44
|
+
"allOf": [
|
|
45
|
+
{
|
|
46
|
+
"description": "Files walked."
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
},
|
|
50
|
+
"ok": {
|
|
51
|
+
"type": "boolean",
|
|
52
|
+
"description": "What `check` would exit with: true when no reportable violation, unresolved import, stale baseline entry or coverage shortfall exists."
|
|
53
|
+
},
|
|
54
|
+
"coverage": {
|
|
55
|
+
"type": "object",
|
|
56
|
+
"properties": {
|
|
57
|
+
"imports": {
|
|
58
|
+
"type": "object",
|
|
59
|
+
"properties": {
|
|
60
|
+
"covered": {
|
|
61
|
+
"type": "number",
|
|
62
|
+
"allOf": [
|
|
63
|
+
{
|
|
64
|
+
"description": "Files this family reaches."
|
|
65
|
+
}
|
|
66
|
+
]
|
|
67
|
+
},
|
|
68
|
+
"total": {
|
|
69
|
+
"type": "number",
|
|
70
|
+
"allOf": [
|
|
71
|
+
{
|
|
72
|
+
"description": "Files walked."
|
|
73
|
+
}
|
|
74
|
+
]
|
|
75
|
+
},
|
|
76
|
+
"floor": {
|
|
77
|
+
"type": "number",
|
|
78
|
+
"allOf": [
|
|
79
|
+
{
|
|
80
|
+
"description": "The fraction the manifest's `limits.coverage` states for this family, when it states one."
|
|
81
|
+
}
|
|
82
|
+
]
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
"required": [
|
|
86
|
+
"covered",
|
|
87
|
+
"total"
|
|
88
|
+
],
|
|
89
|
+
"additionalProperties": false
|
|
90
|
+
},
|
|
91
|
+
"structure": {
|
|
92
|
+
"type": "object",
|
|
93
|
+
"properties": {
|
|
94
|
+
"covered": {
|
|
95
|
+
"type": "number",
|
|
96
|
+
"allOf": [
|
|
97
|
+
{
|
|
98
|
+
"description": "Files this family reaches."
|
|
99
|
+
}
|
|
100
|
+
]
|
|
101
|
+
},
|
|
102
|
+
"total": {
|
|
103
|
+
"type": "number",
|
|
104
|
+
"allOf": [
|
|
105
|
+
{
|
|
106
|
+
"description": "Files walked."
|
|
107
|
+
}
|
|
108
|
+
]
|
|
109
|
+
},
|
|
110
|
+
"floor": {
|
|
111
|
+
"type": "number",
|
|
112
|
+
"allOf": [
|
|
113
|
+
{
|
|
114
|
+
"description": "The fraction the manifest's `limits.coverage` states for this family, when it states one."
|
|
115
|
+
}
|
|
116
|
+
]
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
"required": [
|
|
120
|
+
"covered",
|
|
121
|
+
"total"
|
|
122
|
+
],
|
|
123
|
+
"additionalProperties": false
|
|
124
|
+
},
|
|
125
|
+
"members": {
|
|
126
|
+
"type": "object",
|
|
127
|
+
"properties": {
|
|
128
|
+
"covered": {
|
|
129
|
+
"type": "number",
|
|
130
|
+
"allOf": [
|
|
131
|
+
{
|
|
132
|
+
"description": "Files this family reaches."
|
|
133
|
+
}
|
|
134
|
+
]
|
|
135
|
+
},
|
|
136
|
+
"total": {
|
|
137
|
+
"type": "number",
|
|
138
|
+
"allOf": [
|
|
139
|
+
{
|
|
140
|
+
"description": "Files walked."
|
|
141
|
+
}
|
|
142
|
+
]
|
|
143
|
+
},
|
|
144
|
+
"floor": {
|
|
145
|
+
"type": "number",
|
|
146
|
+
"allOf": [
|
|
147
|
+
{
|
|
148
|
+
"description": "The fraction the manifest's `limits.coverage` states for this family, when it states one."
|
|
149
|
+
}
|
|
150
|
+
]
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
"required": [
|
|
154
|
+
"covered",
|
|
155
|
+
"total"
|
|
156
|
+
],
|
|
157
|
+
"additionalProperties": false
|
|
158
|
+
},
|
|
159
|
+
"surface": {
|
|
160
|
+
"type": "object",
|
|
161
|
+
"properties": {
|
|
162
|
+
"covered": {
|
|
163
|
+
"type": "number",
|
|
164
|
+
"allOf": [
|
|
165
|
+
{
|
|
166
|
+
"description": "Files this family reaches."
|
|
167
|
+
}
|
|
168
|
+
]
|
|
169
|
+
},
|
|
170
|
+
"total": {
|
|
171
|
+
"type": "number",
|
|
172
|
+
"allOf": [
|
|
173
|
+
{
|
|
174
|
+
"description": "Files walked."
|
|
175
|
+
}
|
|
176
|
+
]
|
|
177
|
+
},
|
|
178
|
+
"floor": {
|
|
179
|
+
"type": "number",
|
|
180
|
+
"allOf": [
|
|
181
|
+
{
|
|
182
|
+
"description": "The fraction the manifest's `limits.coverage` states for this family, when it states one."
|
|
183
|
+
}
|
|
184
|
+
]
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
"required": [
|
|
188
|
+
"covered",
|
|
189
|
+
"total"
|
|
190
|
+
],
|
|
191
|
+
"additionalProperties": false
|
|
192
|
+
},
|
|
193
|
+
"graph": {
|
|
194
|
+
"type": "object",
|
|
195
|
+
"properties": {
|
|
196
|
+
"covered": {
|
|
197
|
+
"type": "number",
|
|
198
|
+
"allOf": [
|
|
199
|
+
{
|
|
200
|
+
"description": "Files this family reaches."
|
|
201
|
+
}
|
|
202
|
+
]
|
|
203
|
+
},
|
|
204
|
+
"total": {
|
|
205
|
+
"type": "number",
|
|
206
|
+
"allOf": [
|
|
207
|
+
{
|
|
208
|
+
"description": "Files walked."
|
|
209
|
+
}
|
|
210
|
+
]
|
|
211
|
+
},
|
|
212
|
+
"floor": {
|
|
213
|
+
"type": "number",
|
|
214
|
+
"allOf": [
|
|
215
|
+
{
|
|
216
|
+
"description": "The fraction the manifest's `limits.coverage` states for this family, when it states one."
|
|
217
|
+
}
|
|
218
|
+
]
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
"required": [
|
|
222
|
+
"covered",
|
|
223
|
+
"total"
|
|
224
|
+
],
|
|
225
|
+
"additionalProperties": false
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
"required": [
|
|
229
|
+
"imports",
|
|
230
|
+
"structure",
|
|
231
|
+
"members",
|
|
232
|
+
"surface",
|
|
233
|
+
"graph"
|
|
234
|
+
],
|
|
235
|
+
"additionalProperties": false,
|
|
236
|
+
"description": "Per family, how many walked files it reaches. Structure counts enumerated folders only."
|
|
237
|
+
},
|
|
238
|
+
"residue": {
|
|
239
|
+
"type": "object",
|
|
240
|
+
"properties": {
|
|
241
|
+
"files": {
|
|
242
|
+
"type": "array",
|
|
243
|
+
"items": {
|
|
244
|
+
"type": "string",
|
|
245
|
+
"description": "Repo-relative, with forward slashes."
|
|
246
|
+
},
|
|
247
|
+
"description": "Files no family reaches, sorted."
|
|
248
|
+
},
|
|
249
|
+
"folders": {
|
|
250
|
+
"type": "array",
|
|
251
|
+
"items": {
|
|
252
|
+
"type": "string",
|
|
253
|
+
"description": "Repo-relative, with forward slashes."
|
|
254
|
+
},
|
|
255
|
+
"description": "Folders every walked file of which is residue, each the topmost such folder."
|
|
256
|
+
}
|
|
257
|
+
},
|
|
258
|
+
"required": [
|
|
259
|
+
"files",
|
|
260
|
+
"folders"
|
|
261
|
+
],
|
|
262
|
+
"additionalProperties": false,
|
|
263
|
+
"description": "What the policy has nothing to say about. A file in an open folder under no allowlist is claimed, not policed, and counts."
|
|
264
|
+
},
|
|
265
|
+
"violations": {
|
|
266
|
+
"type": "array",
|
|
267
|
+
"items": {
|
|
268
|
+
"type": "object",
|
|
269
|
+
"properties": {
|
|
270
|
+
"fingerprint": {
|
|
271
|
+
"type": "string",
|
|
272
|
+
"description": "kind|rule|file|subject — line-independent, so it survives edits to the file it names; the baseline's key."
|
|
273
|
+
},
|
|
274
|
+
"kind": {
|
|
275
|
+
"type": "string",
|
|
276
|
+
"enum": [
|
|
277
|
+
"import",
|
|
278
|
+
"export",
|
|
279
|
+
"structure",
|
|
280
|
+
"member",
|
|
281
|
+
"surface",
|
|
282
|
+
"graph"
|
|
283
|
+
]
|
|
284
|
+
},
|
|
285
|
+
"ruleName": {
|
|
286
|
+
"type": "string",
|
|
287
|
+
"description": "The manifest node path the rule was lowered from."
|
|
288
|
+
},
|
|
289
|
+
"file": {
|
|
290
|
+
"type": "string",
|
|
291
|
+
"description": "Repo-relative, with forward slashes."
|
|
292
|
+
},
|
|
293
|
+
"subject": {
|
|
294
|
+
"anyOf": [
|
|
295
|
+
{
|
|
296
|
+
"type": "string"
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
"type": "null"
|
|
300
|
+
}
|
|
301
|
+
],
|
|
302
|
+
"description": "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."
|
|
303
|
+
},
|
|
304
|
+
"message": {
|
|
305
|
+
"type": "string"
|
|
306
|
+
},
|
|
307
|
+
"baselined": {
|
|
308
|
+
"type": "boolean",
|
|
309
|
+
"description": "Carried by the baseline, so `check` does not fail on it."
|
|
310
|
+
}
|
|
311
|
+
},
|
|
312
|
+
"required": [
|
|
313
|
+
"fingerprint",
|
|
314
|
+
"kind",
|
|
315
|
+
"ruleName",
|
|
316
|
+
"file",
|
|
317
|
+
"subject",
|
|
318
|
+
"message",
|
|
319
|
+
"baselined"
|
|
320
|
+
],
|
|
321
|
+
"additionalProperties": false
|
|
322
|
+
},
|
|
323
|
+
"description": "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."
|
|
324
|
+
},
|
|
325
|
+
"unresolved": {
|
|
326
|
+
"type": "array",
|
|
327
|
+
"items": {
|
|
328
|
+
"type": "object",
|
|
329
|
+
"properties": {
|
|
330
|
+
"file": {
|
|
331
|
+
"type": "string",
|
|
332
|
+
"description": "Repo-relative, with forward slashes."
|
|
333
|
+
},
|
|
334
|
+
"specifier": {
|
|
335
|
+
"type": "string"
|
|
336
|
+
},
|
|
337
|
+
"detail": {
|
|
338
|
+
"type": "string"
|
|
339
|
+
}
|
|
340
|
+
},
|
|
341
|
+
"required": [
|
|
342
|
+
"file",
|
|
343
|
+
"specifier",
|
|
344
|
+
"detail"
|
|
345
|
+
],
|
|
346
|
+
"additionalProperties": false
|
|
347
|
+
},
|
|
348
|
+
"description": "Imports the resolver could not turn into a file. Every rule about one enforces nothing."
|
|
349
|
+
},
|
|
350
|
+
"stale": {
|
|
351
|
+
"type": "array",
|
|
352
|
+
"items": {
|
|
353
|
+
"type": "string"
|
|
354
|
+
},
|
|
355
|
+
"description": "Baseline entries the code no longer produces."
|
|
356
|
+
},
|
|
357
|
+
"baseline": {
|
|
358
|
+
"type": "object",
|
|
359
|
+
"properties": {
|
|
360
|
+
"size": {
|
|
361
|
+
"type": "number",
|
|
362
|
+
"allOf": [
|
|
363
|
+
{
|
|
364
|
+
"description": "Entries in the baseline file."
|
|
365
|
+
}
|
|
366
|
+
]
|
|
367
|
+
}
|
|
368
|
+
},
|
|
369
|
+
"required": [
|
|
370
|
+
"size"
|
|
371
|
+
],
|
|
372
|
+
"additionalProperties": false,
|
|
373
|
+
"description": "The debt the policy is carrying. The ratchet: it may only shrink."
|
|
374
|
+
},
|
|
375
|
+
"cycles": {
|
|
376
|
+
"type": "number",
|
|
377
|
+
"allOf": [
|
|
378
|
+
{
|
|
379
|
+
"description": "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."
|
|
380
|
+
}
|
|
381
|
+
]
|
|
382
|
+
},
|
|
383
|
+
"slack": {
|
|
384
|
+
"type": "array",
|
|
385
|
+
"items": {
|
|
386
|
+
"type": "object",
|
|
387
|
+
"properties": {
|
|
388
|
+
"node": {
|
|
389
|
+
"type": "string",
|
|
390
|
+
"description": "The manifest node that wrote the entry."
|
|
391
|
+
},
|
|
392
|
+
"kind": {
|
|
393
|
+
"type": "string",
|
|
394
|
+
"enum": [
|
|
395
|
+
"allow",
|
|
396
|
+
"external"
|
|
397
|
+
],
|
|
398
|
+
"description": "`allow` is a path glob under `imports.allow`; `external` a package name under `imports.external`."
|
|
399
|
+
},
|
|
400
|
+
"entry": {
|
|
401
|
+
"type": "string",
|
|
402
|
+
"description": "The entry as written, after alias expansion."
|
|
403
|
+
}
|
|
404
|
+
},
|
|
405
|
+
"required": [
|
|
406
|
+
"node",
|
|
407
|
+
"kind",
|
|
408
|
+
"entry"
|
|
409
|
+
],
|
|
410
|
+
"additionalProperties": false
|
|
411
|
+
},
|
|
412
|
+
"description": "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."
|
|
413
|
+
},
|
|
414
|
+
"adoption": {
|
|
415
|
+
"type": "object",
|
|
416
|
+
"properties": {
|
|
417
|
+
"unrestricted": {
|
|
418
|
+
"type": "array",
|
|
419
|
+
"items": {
|
|
420
|
+
"type": "string"
|
|
421
|
+
},
|
|
422
|
+
"description": "Nodes that say `unrestricted: true`."
|
|
423
|
+
},
|
|
424
|
+
"partial": {
|
|
425
|
+
"type": "array",
|
|
426
|
+
"items": {
|
|
427
|
+
"type": "string"
|
|
428
|
+
},
|
|
429
|
+
"description": "Nodes that say `partial: true`."
|
|
430
|
+
}
|
|
431
|
+
},
|
|
432
|
+
"required": [
|
|
433
|
+
"unrestricted",
|
|
434
|
+
"partial"
|
|
435
|
+
],
|
|
436
|
+
"additionalProperties": false,
|
|
437
|
+
"description": "The tiers that said \"not tightened yet\", by name; `limits` caps how many may."
|
|
438
|
+
}
|
|
439
|
+
},
|
|
440
|
+
"required": [
|
|
441
|
+
"version",
|
|
442
|
+
"manifest",
|
|
443
|
+
"roots",
|
|
444
|
+
"files",
|
|
445
|
+
"ok",
|
|
446
|
+
"coverage",
|
|
447
|
+
"residue",
|
|
448
|
+
"violations",
|
|
449
|
+
"unresolved",
|
|
450
|
+
"stale",
|
|
451
|
+
"baseline",
|
|
452
|
+
"cycles",
|
|
453
|
+
"slack",
|
|
454
|
+
"adoption"
|
|
455
|
+
],
|
|
456
|
+
"additionalProperties": false
|
|
457
|
+
}
|
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 = {
|
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> = [];
|