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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/build/dts/core/coverage.d.ts +14 -0
  2. package/build/dts/core/coverage.d.ts.map +1 -1
  3. package/build/dts/core/graph.d.ts +2 -0
  4. package/build/dts/core/graph.d.ts.map +1 -1
  5. package/build/dts/core/imports.d.ts +3 -1
  6. package/build/dts/core/imports.d.ts.map +1 -1
  7. package/build/dts/core/slack.d.ts +10 -0
  8. package/build/dts/core/slack.d.ts.map +1 -0
  9. package/build/dts/domain/architecture-config.d.ts +13 -0
  10. package/build/dts/domain/architecture-config.d.ts.map +1 -1
  11. package/build/dts/domain/snapshot.d.ts +121 -0
  12. package/build/dts/domain/snapshot.d.ts.map +1 -0
  13. package/build/dts/index.d.ts +8 -5
  14. package/build/dts/index.d.ts.map +1 -1
  15. package/build/dts/infrastructure/walk.d.ts +6 -0
  16. package/build/dts/infrastructure/walk.d.ts.map +1 -1
  17. package/build/dts/manifest/compile.d.ts.map +1 -1
  18. package/build/dts/manifest/infer.d.ts +55 -0
  19. package/build/dts/manifest/infer.d.ts.map +1 -0
  20. package/build/dts/ports/language.d.ts +2 -0
  21. package/build/dts/ports/language.d.ts.map +1 -1
  22. package/build/esm/core/coverage.js +72 -32
  23. package/build/esm/core/coverage.js.map +1 -1
  24. package/build/esm/core/graph.js +45 -0
  25. package/build/esm/core/graph.js.map +1 -1
  26. package/build/esm/core/imports.js +14 -9
  27. package/build/esm/core/imports.js.map +1 -1
  28. package/build/esm/core/slack.js +39 -0
  29. package/build/esm/core/slack.js.map +1 -0
  30. package/build/esm/domain/architecture-config.js +19 -0
  31. package/build/esm/domain/architecture-config.js.map +1 -1
  32. package/build/esm/domain/snapshot.js +95 -0
  33. package/build/esm/domain/snapshot.js.map +1 -0
  34. package/build/esm/index.js +7 -4
  35. package/build/esm/index.js.map +1 -1
  36. package/build/esm/infrastructure/walk.js +57 -1
  37. package/build/esm/infrastructure/walk.js.map +1 -1
  38. package/build/esm/manifest/compile.js +25 -21
  39. package/build/esm/manifest/compile.js.map +1 -1
  40. package/build/esm/manifest/infer.js +455 -0
  41. package/build/esm/manifest/infer.js.map +1 -0
  42. package/package.json +3 -1
  43. package/schema/conformance.schema.json +457 -0
  44. package/src/core/coverage.ts +111 -34
  45. package/src/core/graph.ts +48 -0
  46. package/src/core/imports.ts +29 -13
  47. package/src/core/slack.ts +62 -0
  48. package/src/domain/architecture-config.ts +21 -0
  49. package/src/domain/snapshot.ts +180 -0
  50. package/src/index.ts +38 -1
  51. package/src/infrastructure/walk.ts +70 -1
  52. package/src/manifest/compile.ts +34 -27
  53. package/src/manifest/infer.ts +643 -0
  54. package/src/ports/language.ts +11 -0
@@ -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
+ }
@@ -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 = {
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> = [];