@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.
Files changed (164) hide show
  1. package/build/dts/core/campaigns.d.ts +141 -0
  2. package/build/dts/core/campaigns.d.ts.map +1 -0
  3. package/build/dts/core/coverage.d.ts +20 -0
  4. package/build/dts/core/coverage.d.ts.map +1 -1
  5. package/build/dts/core/graph.d.ts +2 -0
  6. package/build/dts/core/graph.d.ts.map +1 -1
  7. package/build/dts/core/imports.d.ts +3 -1
  8. package/build/dts/core/imports.d.ts.map +1 -1
  9. package/build/dts/core/ledger.d.ts +61 -0
  10. package/build/dts/core/ledger.d.ts.map +1 -0
  11. package/build/dts/core/slack.d.ts +22 -0
  12. package/build/dts/core/slack.d.ts.map +1 -0
  13. package/build/dts/core/structure.d.ts +1 -0
  14. package/build/dts/core/structure.d.ts.map +1 -1
  15. package/build/dts/domain/architecture-config.d.ts +324 -1
  16. package/build/dts/domain/architecture-config.d.ts.map +1 -1
  17. package/build/dts/domain/architecture-error.d.ts +8 -0
  18. package/build/dts/domain/architecture-error.d.ts.map +1 -1
  19. package/build/dts/domain/facts.d.ts +1 -0
  20. package/build/dts/domain/facts.d.ts.map +1 -1
  21. package/build/dts/domain/manifest-location.d.ts +9 -0
  22. package/build/dts/domain/manifest-location.d.ts.map +1 -0
  23. package/build/dts/domain/report.d.ts +16 -0
  24. package/build/dts/domain/report.d.ts.map +1 -0
  25. package/build/dts/domain/snapshot.d.ts +224 -0
  26. package/build/dts/domain/snapshot.d.ts.map +1 -0
  27. package/build/dts/domain/violation.d.ts +1 -1
  28. package/build/dts/domain/violation.d.ts.map +1 -1
  29. package/build/dts/index.d.ts +24 -9
  30. package/build/dts/index.d.ts.map +1 -1
  31. package/build/dts/infrastructure/campaign-functions.d.ts +7 -0
  32. package/build/dts/infrastructure/campaign-functions.d.ts.map +1 -0
  33. package/build/dts/infrastructure/manifest-file.d.ts +11 -2
  34. package/build/dts/infrastructure/manifest-file.d.ts.map +1 -1
  35. package/build/dts/infrastructure/manifest-include.d.ts +16 -0
  36. package/build/dts/infrastructure/manifest-include.d.ts.map +1 -0
  37. package/build/dts/infrastructure/report-source-fake.d.ts +4 -0
  38. package/build/dts/infrastructure/report-source-fake.d.ts.map +1 -0
  39. package/build/dts/infrastructure/report-source-live.d.ts +3 -0
  40. package/build/dts/infrastructure/report-source-live.d.ts.map +1 -0
  41. package/build/dts/infrastructure/syntax-matcher-fake.d.ts +10 -0
  42. package/build/dts/infrastructure/syntax-matcher-fake.d.ts.map +1 -0
  43. package/build/dts/infrastructure/walk.d.ts +6 -0
  44. package/build/dts/infrastructure/walk.d.ts.map +1 -1
  45. package/build/dts/load/policy.d.ts +16 -0
  46. package/build/dts/load/policy.d.ts.map +1 -1
  47. package/build/dts/manifest/compile.d.ts +7 -2
  48. package/build/dts/manifest/compile.d.ts.map +1 -1
  49. package/build/dts/manifest/expand.d.ts +27 -0
  50. package/build/dts/manifest/expand.d.ts.map +1 -0
  51. package/build/dts/manifest/infer.d.ts +55 -0
  52. package/build/dts/manifest/infer.d.ts.map +1 -0
  53. package/build/dts/manifest/json-schema.d.ts +10 -0
  54. package/build/dts/manifest/json-schema.d.ts.map +1 -0
  55. package/build/dts/manifest/manifest.d.ts +381 -1
  56. package/build/dts/manifest/manifest.d.ts.map +1 -1
  57. package/build/dts/ports/campaign-predicate.d.ts +24 -0
  58. package/build/dts/ports/campaign-predicate.d.ts.map +1 -0
  59. package/build/dts/ports/language.d.ts +4 -0
  60. package/build/dts/ports/language.d.ts.map +1 -1
  61. package/build/dts/ports/report-source.d.ts +14 -0
  62. package/build/dts/ports/report-source.d.ts.map +1 -0
  63. package/build/dts/ports/syntax-matcher.d.ts +21 -0
  64. package/build/dts/ports/syntax-matcher.d.ts.map +1 -0
  65. package/build/dts/testing.d.ts +2 -0
  66. package/build/dts/testing.d.ts.map +1 -1
  67. package/build/esm/core/campaigns.js +778 -0
  68. package/build/esm/core/campaigns.js.map +1 -0
  69. package/build/esm/core/coverage.js +107 -32
  70. package/build/esm/core/coverage.js.map +1 -1
  71. package/build/esm/core/graph.js +45 -0
  72. package/build/esm/core/graph.js.map +1 -1
  73. package/build/esm/core/imports.js +14 -9
  74. package/build/esm/core/imports.js.map +1 -1
  75. package/build/esm/core/ledger.js +171 -0
  76. package/build/esm/core/ledger.js.map +1 -0
  77. package/build/esm/core/slack.js +76 -0
  78. package/build/esm/core/slack.js.map +1 -0
  79. package/build/esm/core/structure.js +5 -2
  80. package/build/esm/core/structure.js.map +1 -1
  81. package/build/esm/domain/architecture-config.js +158 -1
  82. package/build/esm/domain/architecture-config.js.map +1 -1
  83. package/build/esm/domain/architecture-error.js +27 -0
  84. package/build/esm/domain/architecture-error.js.map +1 -1
  85. package/build/esm/domain/manifest-location.js +21 -0
  86. package/build/esm/domain/manifest-location.js.map +1 -0
  87. package/build/esm/domain/report.js +168 -0
  88. package/build/esm/domain/report.js.map +1 -0
  89. package/build/esm/domain/snapshot.js +141 -0
  90. package/build/esm/domain/snapshot.js.map +1 -0
  91. package/build/esm/domain/violation.js.map +1 -1
  92. package/build/esm/index.js +21 -8
  93. package/build/esm/index.js.map +1 -1
  94. package/build/esm/infrastructure/campaign-functions.js +67 -0
  95. package/build/esm/infrastructure/campaign-functions.js.map +1 -0
  96. package/build/esm/infrastructure/manifest-file.js +158 -7
  97. package/build/esm/infrastructure/manifest-file.js.map +1 -1
  98. package/build/esm/infrastructure/manifest-include.js +187 -0
  99. package/build/esm/infrastructure/manifest-include.js.map +1 -0
  100. package/build/esm/infrastructure/report-source-fake.js +6 -0
  101. package/build/esm/infrastructure/report-source-fake.js.map +1 -0
  102. package/build/esm/infrastructure/report-source-live.js +165 -0
  103. package/build/esm/infrastructure/report-source-live.js.map +1 -0
  104. package/build/esm/infrastructure/syntax-matcher-fake.js +33 -0
  105. package/build/esm/infrastructure/syntax-matcher-fake.js.map +1 -0
  106. package/build/esm/infrastructure/walk.js +57 -1
  107. package/build/esm/infrastructure/walk.js.map +1 -1
  108. package/build/esm/load/policy.js +123 -3
  109. package/build/esm/load/policy.js.map +1 -1
  110. package/build/esm/manifest/compile.js +228 -26
  111. package/build/esm/manifest/compile.js.map +1 -1
  112. package/build/esm/manifest/expand.js +116 -0
  113. package/build/esm/manifest/expand.js.map +1 -0
  114. package/build/esm/manifest/infer.js +455 -0
  115. package/build/esm/manifest/infer.js.map +1 -0
  116. package/build/esm/manifest/json-schema.js +135 -0
  117. package/build/esm/manifest/json-schema.js.map +1 -0
  118. package/build/esm/manifest/manifest.js +243 -5
  119. package/build/esm/manifest/manifest.js.map +1 -1
  120. package/build/esm/ports/campaign-predicate.js +2 -0
  121. package/build/esm/ports/campaign-predicate.js.map +1 -0
  122. package/build/esm/ports/report-source.js +7 -0
  123. package/build/esm/ports/report-source.js.map +1 -0
  124. package/build/esm/ports/syntax-matcher.js +12 -0
  125. package/build/esm/ports/syntax-matcher.js.map +1 -0
  126. package/build/esm/testing.js +2 -0
  127. package/build/esm/testing.js.map +1 -1
  128. package/package.json +9 -3
  129. package/schema/architecture-node.schema.json +2405 -0
  130. package/schema/architecture.schema.json +2825 -0
  131. package/schema/conformance.schema.json +775 -0
  132. package/src/core/campaigns.ts +1056 -0
  133. package/src/core/coverage.ts +164 -34
  134. package/src/core/graph.ts +48 -0
  135. package/src/core/imports.ts +29 -13
  136. package/src/core/ledger.ts +242 -0
  137. package/src/core/slack.ts +135 -0
  138. package/src/core/structure.ts +10 -5
  139. package/src/domain/architecture-config.ts +205 -1
  140. package/src/domain/architecture-error.ts +30 -0
  141. package/src/domain/facts.ts +9 -4
  142. package/src/domain/manifest-location.ts +41 -0
  143. package/src/domain/report.ts +203 -0
  144. package/src/domain/snapshot.ts +302 -0
  145. package/src/domain/violation.ts +4 -1
  146. package/src/index.ts +172 -3
  147. package/src/infrastructure/campaign-functions.ts +98 -0
  148. package/src/infrastructure/manifest-file.ts +204 -8
  149. package/src/infrastructure/manifest-include.ts +318 -0
  150. package/src/infrastructure/report-source-fake.ts +10 -0
  151. package/src/infrastructure/report-source-live.ts +192 -0
  152. package/src/infrastructure/syntax-matcher-fake.ts +51 -0
  153. package/src/infrastructure/walk.ts +70 -1
  154. package/src/load/policy.ts +193 -3
  155. package/src/manifest/compile.ts +290 -28
  156. package/src/manifest/expand.ts +183 -0
  157. package/src/manifest/infer.ts +643 -0
  158. package/src/manifest/json-schema.ts +168 -0
  159. package/src/manifest/manifest.ts +339 -11
  160. package/src/ports/campaign-predicate.ts +30 -0
  161. package/src/ports/language.ts +17 -0
  162. package/src/ports/report-source.ts +35 -0
  163. package/src/ports/syntax-matcher.ts +42 -0
  164. package/src/testing.ts +2 -0
@@ -59,52 +59,129 @@ const selects = (
59
59
  file: string,
60
60
  ) => firstFromMatch(rule, file) !== null;
61
61
 
62
- export const coverageOf = (policy: CoverageInputs, files: ReadonlyArray<string>): Coverage => {
63
- const allowlists = policy.importRules.filter(isAllowlist);
64
- let imports = 0;
65
- let enumerated = 0;
66
- let open = 0;
67
- let members = 0;
68
- let surface = 0;
69
- let graph = 0;
70
-
71
- for (const file of files) {
72
- if (allowlists.some((rule) => selects(rule, file))) imports += 1;
62
+ // Which families reach one file. The one loop both `coverageOf` and
63
+ // `residueOf` run: the first counts, the second keeps the names.
64
+ export type Reach = {
65
+ readonly file: string;
66
+ readonly imports: boolean;
67
+ // Enumerated and open are told apart, as `coverageOf` counts them.
68
+ readonly structure: "enumerated" | "open" | null;
69
+ readonly members: boolean;
70
+ readonly surface: boolean;
71
+ readonly graph: boolean;
72
+ };
73
73
 
74
+ export const reachOf = (
75
+ policy: CoverageInputs,
76
+ files: ReadonlyArray<string>,
77
+ ): ReadonlyArray<Reach> => {
78
+ const allowlists = policy.importRules.filter(isAllowlist);
79
+ return files.map((file) => {
74
80
  const folder = dirnameOf(file);
75
81
  const governing = policy.structure.folders.filter((rule) =>
76
82
  rule.folder.some((pattern) => pattern.test(folder)),
77
83
  );
78
- if (governing.length > 0) {
79
- if (governing.every((rule) => rule.files.some((pattern) => pattern.source === OPEN_LAYOUT))) {
80
- open += 1;
81
- } else {
82
- enumerated += 1;
83
- }
84
- }
85
-
86
- if (policy.memberRules.some((rule) => selects(rule, file))) members += 1;
87
- if (policy.surfaceRules.some((rule) => selects(rule, file))) surface += 1;
88
-
89
- const scoped = [...policy.graph.cycles, ...policy.graph.orphans].some(
90
- (rule) =>
91
- rule.within.some((pattern) => pattern.test(file)) &&
92
- !rule.withinNot.some((pattern) => pattern.test(file)),
93
- );
94
- if (scoped) graph += 1;
95
- }
84
+ const structure =
85
+ governing.length === 0
86
+ ? null
87
+ : governing.every((rule) => rule.files.some((pattern) => pattern.source === OPEN_LAYOUT))
88
+ ? "open"
89
+ : "enumerated";
90
+ return {
91
+ file,
92
+ imports: allowlists.some((rule) => selects(rule, file)),
93
+ structure,
94
+ members: policy.memberRules.some((rule) => selects(rule, file)),
95
+ surface: policy.surfaceRules.some((rule) => selects(rule, file)),
96
+ graph: [...policy.graph.cycles, ...policy.graph.orphans].some(
97
+ (rule) =>
98
+ rule.within.some((pattern) => pattern.test(file)) &&
99
+ !rule.withinNot.some((pattern) => pattern.test(file)),
100
+ ),
101
+ };
102
+ });
103
+ };
96
104
 
105
+ export const coverageOf = (policy: CoverageInputs, files: ReadonlyArray<string>): Coverage => {
106
+ const reach = reachOf(policy, files);
107
+ const count = (is: (one: Reach) => boolean): number => reach.filter(is).length;
97
108
  const total = files.length;
98
109
  return {
99
110
  files: total,
100
- imports: { covered: imports, total },
101
- structure: { enumerated, open, total },
102
- members: { covered: members, total },
103
- surface: { covered: surface, total },
104
- graph: { covered: graph, total },
111
+ imports: { covered: count((one) => one.imports), total },
112
+ structure: {
113
+ enumerated: count((one) => one.structure === "enumerated"),
114
+ open: count((one) => one.structure === "open"),
115
+ total,
116
+ },
117
+ members: { covered: count((one) => one.members), total },
118
+ surface: { covered: count((one) => one.surface), total },
119
+ graph: { covered: count((one) => one.graph), total },
105
120
  };
106
121
  };
107
122
 
123
+ // The files no family reaches — counted by no family's coverage, so a file in
124
+ // an open folder under no allowlist is residue: claimed, not policed — and
125
+ // the folders wholly made of them, each the topmost such folder. A policy
126
+ // that is 40% silence looks exactly like one that is 100% enforced, until
127
+ // counted; this is what the silence is made of.
128
+ export type Residue = {
129
+ readonly files: ReadonlyArray<string>;
130
+ readonly folders: ReadonlyArray<string>;
131
+ };
132
+
133
+ export const residueOf = (policy: CoverageInputs, files: ReadonlyArray<string>): Residue => {
134
+ const unreached = reachOf(policy, files)
135
+ .filter(
136
+ (one) =>
137
+ !one.imports &&
138
+ one.structure !== "enumerated" &&
139
+ !one.members &&
140
+ !one.surface &&
141
+ !one.graph,
142
+ )
143
+ .map((one) => one.file)
144
+ .sort();
145
+ return { files: unreached, folders: foldersWhollyIn(unreached, files) };
146
+ };
147
+
148
+ // Every ancestor folder of a file, nearest first, the root (`""`) excluded.
149
+ const ancestorsOf = (file: string): ReadonlyArray<string> => {
150
+ const folders: Array<string> = [];
151
+ let folder = dirnameOf(file);
152
+ while (folder !== "") {
153
+ folders.push(folder);
154
+ folder = dirnameOf(folder);
155
+ }
156
+ return folders;
157
+ };
158
+
159
+ // The topmost folders every walked file of which is in `subset`. Each is
160
+ // reported once, with none of its subfolders, and a lone file's folder counts
161
+ // — a folder with one file the policy ignores is a folder the policy ignores.
162
+ const foldersWhollyIn = (
163
+ subset: ReadonlyArray<string>,
164
+ files: ReadonlyArray<string>,
165
+ ): ReadonlyArray<string> => {
166
+ const walked = new Map<string, number>();
167
+ for (const file of files) {
168
+ for (const folder of ancestorsOf(file)) walked.set(folder, (walked.get(folder) ?? 0) + 1);
169
+ }
170
+ const inSubset = new Map<string, number>();
171
+ for (const file of subset) {
172
+ for (const folder of ancestorsOf(file)) {
173
+ inSubset.set(folder, (inSubset.get(folder) ?? 0) + 1);
174
+ }
175
+ }
176
+ const whole = [...inSubset.entries()]
177
+ .filter(([folder, count]) => walked.get(folder) === count)
178
+ .map(([folder]) => folder)
179
+ .sort();
180
+ return whole.filter(
181
+ (folder) => !whole.some((other) => other !== folder && folder.startsWith(`${other}/`)),
182
+ );
183
+ };
184
+
108
185
  // A floor the policy states for itself, per family, as a fraction. Structure
109
186
  // counts enumerated folders only: an open one is claimed, not policed by name.
110
187
  export type CoverageFloors = {
@@ -117,6 +194,59 @@ export type CoverageFloors = {
117
194
 
118
195
  export type CoverageFamily = keyof CoverageFloors;
119
196
 
197
+ // Residue is files no node reaches; this is nodes no file reaches. A node
198
+ // that states an import allowlist and selects no walked file grants
199
+ // permission to nothing: every allowance on it is unused by construction,
200
+ // which is not slack — there is no line to delete, only a node that is a
201
+ // tier declared ahead of its first file, or a pattern that no longer
202
+ // matches. Which of the two, the reader decides; the report tells both
203
+ // apart from slack so nothing has to be read twice.
204
+ export type Vacancy = ReadonlyArray<{
205
+ readonly node: string;
206
+ // Distinct entries the node wrote, `allow` and `external` together.
207
+ readonly allowances: number;
208
+ }>;
209
+
210
+ // The nodes whose allowances no live rule carries. A node's allowlist is
211
+ // inherited by every descendant's rule until one `reset`s, so a node whose
212
+ // own rule steps aside for an overriding child still reaches that child's
213
+ // files through the child's rule — and is not vacant while the child has any.
214
+ export const vacantNodesOf = (
215
+ rules: ReadonlyArray<CompiledImportRule>,
216
+ files: ReadonlyArray<string>,
217
+ ): ReadonlySet<string> => {
218
+ const declaring = new Set<string>();
219
+ const reached = new Set<string>();
220
+ for (const rule of rules) {
221
+ if (rule.allowances.length === 0) continue;
222
+ const live = files.some((file) => selects(rule, file));
223
+ for (const { node } of rule.allowances) {
224
+ declaring.add(node);
225
+ if (live) reached.add(node);
226
+ }
227
+ }
228
+ return new Set([...declaring].filter((node) => !reached.has(node)));
229
+ };
230
+
231
+ // Every vacant node with how many entries it wrote, in the order the
232
+ // allowlists declared them.
233
+ export const vacancyOf = (
234
+ rules: ReadonlyArray<CompiledImportRule>,
235
+ files: ReadonlyArray<string>,
236
+ ): Vacancy => {
237
+ const vacant = vacantNodesOf(rules, files);
238
+ const entries = new Map<string, Set<string>>();
239
+ for (const rule of rules) {
240
+ for (const { entry, kind, node } of rule.allowances) {
241
+ if (!vacant.has(node)) continue;
242
+ const written = entries.get(node) ?? new Set<string>();
243
+ written.add(`${kind} ${entry}`);
244
+ entries.set(node, written);
245
+ }
246
+ }
247
+ return [...entries.entries()].map(([node, written]) => ({ node, allowances: written.size }));
248
+ };
249
+
120
250
  export const fractionOf = (covered: number, total: number): number =>
121
251
  total === 0 ? 1 : covered / total;
122
252
 
package/src/core/graph.ts CHANGED
@@ -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> = [];
@@ -1,6 +1,11 @@
1
1
  import * as Result from "effect/Result";
2
2
 
3
- import type { ImportProbe, ImportProbeTarget, ImportRule } from "../domain/architecture-config.js";
3
+ import type {
4
+ Allowance,
5
+ ImportProbe,
6
+ ImportProbeTarget,
7
+ ImportRule,
8
+ } from "../domain/architecture-config.js";
4
9
  import type { ImportUnresolved, PatternInvalid } from "../domain/architecture-error.js";
5
10
  import type { Violation } from "../domain/violation.js";
6
11
  import type { DependencyKind, ModuleResolver, ResolvedTarget } from "../ports/module-resolver.js";
@@ -25,6 +30,9 @@ export type CompiledImportRule = {
25
30
  // Third-party packages the rule permits, by name. Judged before the path
26
31
  // patterns, so where a language keeps its packages never reaches a rule.
27
32
  readonly externals: ReadonlySet<string>;
33
+ // Where each `toNot` and external came from, when lowered from a manifest;
34
+ // empty for a hand-written rule. What `slackOf` reads.
35
+ readonly allowances: ReadonlyArray<Allowance>;
28
36
  readonly dependencyKind: DependencyKind | null;
29
37
  readonly probe: ImportProbe;
30
38
  };
@@ -52,6 +60,7 @@ export const compileImportRule = (
52
60
  to: sourcesOf(rule.to),
53
61
  toNot: sourcesOf(rule.toNot),
54
62
  externals: new Set(rule.externals ?? []),
63
+ allowances: rule.allowances ?? [],
55
64
  dependencyKind: rule.dependencyKind ?? null,
56
65
  });
57
66
  };
@@ -105,17 +114,13 @@ const reports = (rule: CompiledImportRule, captures: RegExpExecArray, target: Re
105
114
  return targetAllowed(rule, captures, target.path);
106
115
  };
107
116
 
108
- export const evaluateSelectedEdge = (
117
+ // The same judgement over a target the host has already resolved — for a host
118
+ // that resolves each edge once and wants the target for something else too.
119
+ export const evaluateResolvedEdge = (
109
120
  selected: ReadonlyArray<SelectedRule>,
110
- resolver: ModuleResolver,
111
- edge: ImportEdge,
112
- ): Result.Result<ReadonlyArray<Violation>, ImportUnresolved> => {
113
- if (selected.length === 0) return Result.succeed([]);
114
-
115
- const resolved = resolver.resolve(edge.importer, edge.specifier);
116
- if (Result.isFailure(resolved)) return Result.fail(resolved.failure);
117
- const target = resolved.success;
118
-
121
+ importer: string,
122
+ target: ResolvedTarget,
123
+ ): ReadonlyArray<Violation> => {
119
124
  const violations: Array<Violation> = [];
120
125
  for (const [rule, captures] of selected) {
121
126
  if (reports(rule, captures, target)) {
@@ -123,13 +128,24 @@ export const evaluateSelectedEdge = (
123
128
  kind: "import",
124
129
  ruleName: rule.name,
125
130
  message: rule.message,
126
- file: edge.importer,
131
+ file: importer,
127
132
  subject: target.path,
128
133
  });
129
134
  }
130
135
  }
136
+ return violations;
137
+ };
138
+
139
+ export const evaluateSelectedEdge = (
140
+ selected: ReadonlyArray<SelectedRule>,
141
+ resolver: ModuleResolver,
142
+ edge: ImportEdge,
143
+ ): Result.Result<ReadonlyArray<Violation>, ImportUnresolved> => {
144
+ if (selected.length === 0) return Result.succeed([]);
131
145
 
132
- return Result.succeed(violations);
146
+ const resolved = resolver.resolve(edge.importer, edge.specifier);
147
+ if (Result.isFailure(resolved)) return Result.fail(resolved.failure);
148
+ return Result.succeed(evaluateResolvedEdge(selected, edge.importer, resolved.success));
133
149
  };
134
150
 
135
151
  export const evaluateImportEdge = (
@@ -0,0 +1,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;