@akanjs/devkit 3.1.1-rc.15 → 3.1.1-rc.16

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 (63) hide show
  1. package/applicationBuildRunner.test.ts +17 -0
  2. package/applicationBuildRunner.ts +2 -1
  3. package/biome.base.json +4 -1
  4. package/commentDensityScanner.test.ts +121 -0
  5. package/commentDensityScanner.ts +101 -0
  6. package/componentSizeScanner.test.ts +36 -0
  7. package/componentSizeScanner.ts +52 -0
  8. package/componentWriteChainScanner.test.ts +137 -0
  9. package/componentWriteChainScanner.ts +104 -0
  10. package/deprecatedCommentScanner.test.ts +54 -0
  11. package/deprecatedCommentScanner.ts +30 -0
  12. package/endpointReach.test.ts +58 -0
  13. package/endpointReach.ts +80 -0
  14. package/index.ts +1 -0
  15. package/kindBranchScanner.test.ts +89 -0
  16. package/kindBranchScanner.ts +158 -0
  17. package/lint/__fixtures__/no-oversized-list-limit/bad.tsx +18 -0
  18. package/lint/__fixtures__/no-oversized-list-limit/good.tsx +23 -0
  19. package/lint/no-oversized-list-limit.grit +73 -0
  20. package/maintainabilityBaseline.test.ts +280 -0
  21. package/maintainabilityBaseline.ts +220 -0
  22. package/maintainabilityContract.test.ts +846 -0
  23. package/maintainabilityContract.ts +404 -0
  24. package/maintainabilityExceptionScanner.test.ts +90 -0
  25. package/maintainabilityExceptionScanner.ts +85 -0
  26. package/maintainabilityLimits.ts +60 -0
  27. package/maintainabilityReport.test.ts +130 -0
  28. package/maintainabilityReport.ts +142 -0
  29. package/package.json +2 -2
  30. package/qualityScanner.test.ts +75 -0
  31. package/qualityScanner.ts +17 -1
  32. package/serviceLoopCallScanner.test.ts +56 -0
  33. package/serviceLoopCallScanner.ts +122 -0
  34. package/sourceComments.test.ts +45 -0
  35. package/sourceComments.ts +70 -0
  36. package/storeAccessScanner.test.ts +84 -0
  37. package/storeAccessScanner.ts +71 -0
  38. package/storeSource.ts +159 -0
  39. package/storeWriteScanner.test.ts +292 -0
  40. package/storeWriteScanner.ts +50 -0
  41. package/testOnlyExportScanner.test.ts +83 -0
  42. package/testOnlyExportScanner.ts +124 -0
  43. package/workflow/artifacts.ts +6 -1
  44. package/workflow/executor.ts +22 -7
  45. package/workflow/guardCatalog.test.ts +59 -0
  46. package/workflow/guardCatalog.ts +108 -0
  47. package/workflow/index.ts +4 -0
  48. package/workflow/interactionScaffold.ts +122 -0
  49. package/workflow/mutationScaffold.ts +519 -0
  50. package/workflow/mutationSource.test.ts +108 -0
  51. package/workflow/mutationSource.ts +219 -0
  52. package/workflow/plan.ts +54 -0
  53. package/workflow/render.ts +15 -1
  54. package/workflow/source.test.ts +130 -0
  55. package/workflow/source.ts +122 -14
  56. package/workflow/types.ts +17 -0
  57. package/workspaceLayout.ts +2 -0
  58. package/writePath.test.ts +109 -0
  59. package/writePath.ts +219 -0
  60. package/writePlacementScanner.test.ts +77 -0
  61. package/writePlacementScanner.ts +50 -0
  62. package/writingActionIndex.test.ts +99 -0
  63. package/writingActionIndex.ts +63 -0
@@ -0,0 +1,280 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { MaintainabilityBaseline } from "./maintainabilityBaseline";
3
+ import { type DebtRuleId, MaintainabilityContract, type MaintainabilityViolation } from "./maintainabilityContract";
4
+
5
+ const sizeOf = (key: string, lineCount: number): MaintainabilityViolation => ({
6
+ kind: "size",
7
+ rule: key.includes("#") ? "akan.size.component" : "akan.size.file",
8
+ key,
9
+ file: key.split("#")[0] ?? key,
10
+ line: 1,
11
+ lineCount,
12
+ maxLines: 400,
13
+ });
14
+ const writeOf = (key: string, writeCount = 2): MaintainabilityViolation => ({
15
+ kind: "multiWrite",
16
+ rule: "akan.store.multi-write",
17
+ key,
18
+ file: key.split("#")[0] ?? key,
19
+ line: 1,
20
+ owner: key.split("#")[1] ?? "",
21
+ writes: [],
22
+ writeCount,
23
+ });
24
+ const accessOf = (key: string, accessCount: number): MaintainabilityViolation => ({
25
+ kind: "storeAccess",
26
+ rule: "akan.component.store-access",
27
+ key,
28
+ file: key.split("#")[0] ?? key,
29
+ line: 1,
30
+ component: key.split("#")[1] ?? "",
31
+ accessCount,
32
+ maxAccesses: 16,
33
+ });
34
+ const placementOf = (file: string, writes: string[]): MaintainabilityViolation => ({
35
+ kind: "writePlacement",
36
+ rule: "akan.write.placement",
37
+ key: file,
38
+ file,
39
+ line: 1,
40
+ writes,
41
+ });
42
+ const baselineOf = (
43
+ sections: { [rule in DebtRuleId]?: Record<string, number> },
44
+ reasons: Record<string, string> = {},
45
+ ) =>
46
+ new MaintainabilityBaseline(
47
+ new Map(Object.entries(sections).map(([rule, counts]) => [rule as DebtRuleId, new Map(Object.entries(counts))])),
48
+ new Map(Object.entries(reasons)),
49
+ );
50
+
51
+ describe("MaintainabilityBaseline", () => {
52
+ test("keeps one section per rule the gate can fail, the growth check aside", () => {
53
+ expect(new Set(MaintainabilityBaseline.debtRules)).toEqual(
54
+ new Set(MaintainabilityContract.ruleIds.filter((rule) => rule !== "akan.baseline.growth")),
55
+ );
56
+ });
57
+
58
+ test("allows a recorded entry up to its count, writes included, and nothing it does not record", () => {
59
+ const baseline = MaintainabilityBaseline.parse(
60
+ JSON.stringify({
61
+ "akan.size.file": { "lib/a/A.Zone.tsx": 900 },
62
+ "akan.store.multi-write": { "lib/a/a.store.ts#run": 2 },
63
+ "akan.write.placement": { "ui/Board.tsx": 1 },
64
+ }),
65
+ );
66
+ expect(baseline.allows(sizeOf("lib/a/A.Zone.tsx", 900))).toBe(true);
67
+ expect(baseline.allows(sizeOf("lib/a/A.Zone.tsx", 901))).toBe(false);
68
+ expect(baseline.allows(sizeOf("lib/b/B.Zone.tsx", 401))).toBe(false);
69
+ expect(baseline.allows(writeOf("lib/a/a.store.ts#run"))).toBe(true);
70
+ expect(baseline.allows(writeOf("lib/a/a.store.ts#run", 3))).toBe(false);
71
+ expect(baseline.allows(writeOf("lib/a/a.store.ts#other"))).toBe(false);
72
+ expect(baseline.allows(placementOf("ui/Board.tsx", ["st.do.removeBoard"]))).toBe(true);
73
+ expect(baseline.allows(placementOf("ui/Board.tsx", ["st.do.removeBoard", "fetch.placeBoard"]))).toBe(false);
74
+ });
75
+
76
+ test("records the busiest of several breaches under one key", () => {
77
+ const chainOf = (writeCount: number): MaintainabilityViolation => ({
78
+ kind: "multiWrite",
79
+ rule: "akan.component.write-chain",
80
+ key: "lib/a/A.Zone.tsx#Canvas",
81
+ file: "lib/a/A.Zone.tsx",
82
+ line: 1,
83
+ owner: "Canvas",
84
+ writes: [],
85
+ writeCount,
86
+ });
87
+ const baseline = MaintainabilityBaseline.of([chainOf(2), chainOf(4)]);
88
+ expect([...(baseline.sections.get("akan.component.write-chain") ?? [])]).toEqual([["lib/a/A.Zone.tsx#Canvas", 4]]);
89
+ });
90
+
91
+ test("tightens to what still breaks: lowers a shrunk entry, keeps a grown one, drops the rest", () => {
92
+ const baseline = baselineOf({
93
+ "akan.size.file": { "lib/a/A.Zone.tsx": 900, "lib/b/B.Zone.tsx": 700, "lib/gone/Gone.Zone.tsx": 600 },
94
+ "akan.store.multi-write": { "lib/a/a.store.ts#run": 3, "lib/a/a.store.ts#fixed": 2 },
95
+ });
96
+ const tightened = baseline.tightenedBy([
97
+ sizeOf("lib/a/A.Zone.tsx", 850),
98
+ sizeOf("lib/b/B.Zone.tsx", 720),
99
+ sizeOf("lib/c/C.Zone.tsx", 500),
100
+ writeOf("lib/a/a.store.ts#run", 2),
101
+ ]);
102
+ expect([...(tightened.sections.get("akan.size.file") ?? [])]).toEqual([
103
+ ["lib/a/A.Zone.tsx", 850],
104
+ ["lib/b/B.Zone.tsx", 700],
105
+ ]);
106
+ expect([...(tightened.sections.get("akan.store.multi-write") ?? [])]).toEqual([["lib/a/a.store.ts#run", 2]]);
107
+ expect(baseline.tightenedBy([]).isEmpty).toBe(true);
108
+ });
109
+
110
+ test("names every entry a working baseline added or raised over the reference one, per rule", () => {
111
+ const committed = baselineOf({
112
+ "akan.size.file": { "lib/a/A.Zone.tsx": 900 },
113
+ "akan.store.multi-write": { "a.store.ts#run": 2 },
114
+ });
115
+ const working = baselineOf({
116
+ "akan.size.file": { "lib/a/A.Zone.tsx": 950, "lib/b/B.Zone.tsx": 500 },
117
+ "akan.store.multi-write": { "a.store.ts#run": 3, "a.store.ts#sneak": 2 },
118
+ });
119
+ expect(working.grownFrom(committed)).toEqual([
120
+ { rule: "akan.size.file", key: "lib/a/A.Zone.tsx", count: 950, committed: 900 },
121
+ { rule: "akan.size.file", key: "lib/b/B.Zone.tsx", count: 500, committed: undefined },
122
+ { rule: "akan.store.multi-write", key: "a.store.ts#run", count: 3, committed: 2 },
123
+ { rule: "akan.store.multi-write", key: "a.store.ts#sneak", count: 2, committed: undefined },
124
+ ]);
125
+ expect(committed.tightenedBy([sizeOf("lib/a/A.Zone.tsx", 800)]).grownFrom(committed)).toEqual([]);
126
+ });
127
+
128
+ test("ratchets a component's store accesses like a size: allows up to its entry, lowers it, refuses a raise", () => {
129
+ const baseline = MaintainabilityBaseline.parse(
130
+ JSON.stringify({
131
+ "akan.component.store-access": { "lib/a/A.Zone.tsx#Canvas": 40, "lib/gone/G.Zone.tsx#Old": 30 },
132
+ }),
133
+ );
134
+ expect(baseline.allows(accessOf("lib/a/A.Zone.tsx#Canvas", 40))).toBe(true);
135
+ expect(baseline.allows(accessOf("lib/a/A.Zone.tsx#Canvas", 41))).toBe(false);
136
+ expect(baseline.allows(accessOf("lib/b/B.Zone.tsx#Board", 17))).toBe(false);
137
+ expect(baseline.recordedOf(accessOf("lib/a/A.Zone.tsx#Canvas", 41))).toBe(40);
138
+
139
+ const tightened = baseline.tightenedBy([accessOf("lib/a/A.Zone.tsx#Canvas", 33)]);
140
+ expect([...(tightened.sections.get("akan.component.store-access") ?? [])]).toEqual([
141
+ ["lib/a/A.Zone.tsx#Canvas", 33],
142
+ ]);
143
+ expect(baseline.grownFrom(tightened)).toEqual([
144
+ { rule: "akan.component.store-access", key: "lib/a/A.Zone.tsx#Canvas", count: 40, committed: 33 },
145
+ { rule: "akan.component.store-access", key: "lib/gone/G.Zone.tsx#Old", count: 30, committed: undefined },
146
+ ]);
147
+ expect(tightened.grownFrom(baseline)).toEqual([]);
148
+ });
149
+
150
+ test("lets a shape entry grow only with a reason of ten non-space characters, a write entry never", () => {
151
+ const committed = baselineOf({
152
+ "akan.size.file": { "lib/a/A.Zone.tsx": 900 },
153
+ "akan.store.multi-write": { "a.store.ts#run": 2 },
154
+ });
155
+ const reason = "The canvas is one pointer state machine.";
156
+ const working = baselineOf(
157
+ {
158
+ "akan.size.file": { "lib/a/A.Zone.tsx": 950, "ui/Board.tsx": 450, "lib/b/B.Zone.tsx": 500 },
159
+ "akan.size.component": { "lib/c/C.Zone.tsx#Grid": 300 },
160
+ "akan.component.store-access": { "lib/d/D.Zone.tsx#Canvas": 30 },
161
+ "akan.store.multi-write": { "a.store.ts#run": 2, "a.store.ts#sneak": 2 },
162
+ "akan.component.write-chain": { "lib/c/C.Zone.tsx#Grid": 2 },
163
+ "akan.write.placement": { "ui/Board.tsx": 1 },
164
+ "akan.service.call-in-loop": { "lib/e/e.service.ts#sync": 1 },
165
+ },
166
+ {
167
+ "lib/a/A.Zone.tsx": reason,
168
+ "ui/Board.tsx": reason,
169
+ "lib/b/B.Zone.tsx": "a b c d e f g h i",
170
+ "lib/c/C.Zone.tsx#Grid": reason,
171
+ "lib/d/D.Zone.tsx#Canvas": reason,
172
+ "a.store.ts#sneak": reason,
173
+ "lib/e/e.service.ts#sync": reason,
174
+ },
175
+ );
176
+
177
+ expect(working.grownFrom(committed)).toEqual([
178
+ { rule: "akan.size.file", key: "lib/b/B.Zone.tsx", count: 500, committed: undefined },
179
+ { rule: "akan.store.multi-write", key: "a.store.ts#sneak", count: 2, committed: undefined },
180
+ { rule: "akan.component.write-chain", key: "lib/c/C.Zone.tsx#Grid", count: 2, committed: undefined },
181
+ { rule: "akan.write.placement", key: "ui/Board.tsx", count: 1, committed: undefined },
182
+ ]);
183
+ expect(working.reasonOf("lib/b/B.Zone.tsx")).toBeUndefined();
184
+ expect(working.reasonOf("a.store.ts#sneak")).toBeUndefined();
185
+ expect(working.reasonOf("lib/d/D.Zone.tsx#Canvas")).toBe(reason);
186
+ expect(working.reasonOf("lib/e/e.service.ts#sync")).toBe(reason);
187
+ });
188
+
189
+ test("keeps a reason only beside a shape entry it still describes", () => {
190
+ const reason = "The canvas is one pointer state machine.";
191
+ const baseline = baselineOf(
192
+ {
193
+ "akan.size.file": { "lib/a/A.Zone.tsx": 900, "lib/gone/Gone.Zone.tsx": 600 },
194
+ "akan.component.store-access": { "lib/a/A.Zone.tsx#Canvas": 30 },
195
+ "akan.store.multi-write": { "lib/a/a.store.ts#run": 2 },
196
+ },
197
+ {
198
+ "lib/a/A.Zone.tsx": reason,
199
+ "lib/gone/Gone.Zone.tsx": reason,
200
+ "lib/a/A.Zone.tsx#Canvas": reason,
201
+ "lib/a/a.store.ts#run": reason,
202
+ "lib/never/Recorded.tsx": reason,
203
+ },
204
+ );
205
+
206
+ const tightened = baseline.tightenedBy([
207
+ sizeOf("lib/a/A.Zone.tsx", 850),
208
+ accessOf("lib/a/A.Zone.tsx#Canvas", 20),
209
+ writeOf("lib/a/a.store.ts#run"),
210
+ ]);
211
+
212
+ expect([...(tightened.sections.get("akan.size.file") ?? [])]).toEqual([["lib/a/A.Zone.tsx", 850]]);
213
+ expect([...tightened.reasons]).toEqual([
214
+ ["lib/a/A.Zone.tsx", reason],
215
+ ["lib/a/A.Zone.tsx#Canvas", reason],
216
+ ]);
217
+ expect(baseline.tightenedBy([]).reasons.size).toBe(0);
218
+ });
219
+
220
+ test("prints the rule sections in rule order with sorted keys, as Biome would, and reprints them unchanged", () => {
221
+ const baseline = MaintainabilityBaseline.of([
222
+ writeOf("lib/z/z.store.ts#run"),
223
+ sizeOf("lib/court/Court.Zone.tsx#Canvas", 640),
224
+ sizeOf("lib/court/Court.Zone.tsx", 947),
225
+ writeOf("lib/court/court.store.ts#assignCourtPcOnCourt", 3),
226
+ placementOf("ui/Board.tsx", ["st.do.removeBoard"]),
227
+ ]);
228
+ const text = baseline.stringify();
229
+ expect(text).toBe(`{
230
+ "akan.size.file": {
231
+ "lib/court/Court.Zone.tsx": 947
232
+ },
233
+ "akan.size.component": {
234
+ "lib/court/Court.Zone.tsx#Canvas": 640
235
+ },
236
+ "akan.store.multi-write": {
237
+ "lib/court/court.store.ts#assignCourtPcOnCourt": 3,
238
+ "lib/z/z.store.ts#run": 2
239
+ },
240
+ "akan.write.placement": {
241
+ "ui/Board.tsx": 1
242
+ }
243
+ }
244
+ `);
245
+ expect(MaintainabilityBaseline.parse(text).stringify()).toBe(text);
246
+ });
247
+
248
+ test("prints the reasons last and only once there are some, reprinting them unchanged", () => {
249
+ const text = `{
250
+ "akan.size.file": {
251
+ "lib/a/A.Zone.tsx": 950,
252
+ "ui/Board.tsx": 450
253
+ },
254
+ "reasons": {
255
+ "lib/a/A.Zone.tsx": "The canvas is one pointer state machine.",
256
+ "ui/Board.tsx": "Generated SVG path table for the court layout."
257
+ }
258
+ }
259
+ `;
260
+ const reasonless = '{\n "akan.size.file": {\n "lib/a/A.Zone.tsx": 950\n }\n}\n';
261
+
262
+ expect(MaintainabilityBaseline.parse(text).stringify()).toBe(text);
263
+ expect(MaintainabilityBaseline.parse(reasonless).stringify()).toBe(reasonless);
264
+ expect(MaintainabilityBaseline.parse('{ "reasons": {} }').stringify()).not.toContain("reasons");
265
+ });
266
+
267
+ test("refuses a file that is not the baseline shape, naming it", () => {
268
+ expect(() => MaintainabilityBaseline.parse("{", "apps/demo/akan.baseline.json")).toThrow(
269
+ "apps/demo/akan.baseline.json is not valid JSON.",
270
+ );
271
+ expect(() => MaintainabilityBaseline.parse("[]")).toThrow("holds an object");
272
+ expect(() => MaintainabilityBaseline.parse('{ "akan.size.file": { "a": "900" } }')).toThrow('"akan.size.file"');
273
+ expect(() => MaintainabilityBaseline.parse('{ "akan.store.multi-write": ["a#b"] }')).toThrow(
274
+ '"akan.store.multi-write"',
275
+ );
276
+ expect(() => MaintainabilityBaseline.parse('{ "maxLines": {} }')).toThrow("akan quality baseline --force");
277
+ expect(() => MaintainabilityBaseline.parse('{ "reasons": { "ui/Board.tsx": 1 } }')).toThrow('"reasons"');
278
+ expect(MaintainabilityBaseline.parse("{}").isEmpty).toBe(true);
279
+ });
280
+ });
@@ -0,0 +1,220 @@
1
+ import { JsonFormatter } from "./jsonFormatter";
2
+ import type { DebtRuleId, MaintainabilityViolation } from "./maintainabilityContract";
3
+
4
+ export interface BaselineEntryGrowth {
5
+ rule: DebtRuleId;
6
+ key: string;
7
+ count: number;
8
+ committed?: number;
9
+ }
10
+
11
+ export interface BaselineEntry {
12
+ key: string;
13
+ count: number;
14
+ reason?: string;
15
+ }
16
+
17
+ type Counts = ReadonlyMap<string, number>;
18
+
19
+ //* One section per rule, each key holding what the gate measured: lines, store accesses, writes or calls. A count
20
+ //* only falls, so a recorded entry cannot take on more of the debt it records.
21
+ export class MaintainabilityBaseline {
22
+ static readonly fileName = "akan.baseline.json";
23
+ static readonly reasonMinChars = 10;
24
+ static readonly debtRules = [
25
+ "akan.size.file",
26
+ "akan.size.component",
27
+ "akan.component.store-access",
28
+ "akan.store.multi-write",
29
+ "akan.store.root-write-chain",
30
+ "akan.component.write-chain",
31
+ "akan.write.placement",
32
+ "akan.service.call-in-loop",
33
+ ] as const satisfies readonly DebtRuleId[];
34
+ static readonly reasonedRules: ReadonlySet<DebtRuleId> = new Set([
35
+ "akan.size.file",
36
+ "akan.size.component",
37
+ "akan.component.store-access",
38
+ "akan.service.call-in-loop",
39
+ ]);
40
+
41
+ static countOf(violation: MaintainabilityViolation) {
42
+ if (violation.kind === "size") return violation.lineCount;
43
+ if (violation.kind === "storeAccess") return violation.accessCount;
44
+ if (violation.kind === "multiWrite") return violation.writeCount;
45
+ if (violation.kind === "writePlacement") return violation.writes.length;
46
+ return violation.calls.length;
47
+ }
48
+
49
+ static isDebtRule(name: string): name is DebtRuleId {
50
+ return (MaintainabilityBaseline.debtRules as readonly string[]).includes(name);
51
+ }
52
+
53
+ //? `lenient` reads another commit's copy, whose sections an older or newer CLI may have named differently.
54
+ static parse(
55
+ text: string,
56
+ source = MaintainabilityBaseline.fileName,
57
+ { lenient = false }: { lenient?: boolean } = {},
58
+ ) {
59
+ let json: unknown;
60
+ try {
61
+ json = JSON.parse(text);
62
+ } catch {
63
+ throw new Error(`${source} is not valid JSON.`);
64
+ }
65
+ if (typeof json !== "object" || json === null || Array.isArray(json))
66
+ throw new Error(`${source} holds an object keyed by rule id.`);
67
+ const sections = new Map<DebtRuleId, Counts>();
68
+ let reasons = new Map<string, string>();
69
+ for (const [name, value] of Object.entries(json)) {
70
+ if (name === "reasons") {
71
+ const entries = MaintainabilityBaseline.#reasonsOf(value);
72
+ if (!entries) throw new Error(`${source}: "reasons" maps an entry's key to a sentence.`);
73
+ reasons = new Map(entries);
74
+ continue;
75
+ }
76
+ if (!MaintainabilityBaseline.isDebtRule(name)) {
77
+ if (lenient) continue;
78
+ throw new Error(
79
+ `${source}: "${name}" is not a rule id. Its sections are ${MaintainabilityBaseline.debtRules.join(", ")} and "reasons"; rewrite it with \`akan quality baseline --force\`.`,
80
+ );
81
+ }
82
+ const counts = MaintainabilityBaseline.#countsOf(value);
83
+ if (!counts) throw new Error(`${source}: "${name}" maps each key to a whole-number count.`);
84
+ sections.set(name, new Map(counts));
85
+ }
86
+ return new MaintainabilityBaseline(sections, reasons);
87
+ }
88
+
89
+ static of(violations: MaintainabilityViolation[], reasons: ReadonlyMap<string, string> = new Map()) {
90
+ const sections = new Map<DebtRuleId, Map<string, number>>();
91
+ for (const violation of violations) {
92
+ const counts = sections.get(violation.rule) ?? new Map<string, number>();
93
+ const count = MaintainabilityBaseline.countOf(violation);
94
+ counts.set(violation.key, Math.max(counts.get(violation.key) ?? 0, count));
95
+ sections.set(violation.rule, counts);
96
+ }
97
+ return new MaintainabilityBaseline(sections).#withReasonsOf(reasons);
98
+ }
99
+
100
+ constructor(
101
+ readonly sections: ReadonlyMap<DebtRuleId, Counts> = new Map(),
102
+ readonly reasons: ReadonlyMap<string, string> = new Map(),
103
+ ) {}
104
+
105
+ get isEmpty() {
106
+ return [...this.sections.values()].every((counts) => !counts.size);
107
+ }
108
+
109
+ allows(violation: MaintainabilityViolation) {
110
+ const recorded = this.recordedOf(violation);
111
+ return recorded !== undefined && MaintainabilityBaseline.countOf(violation) <= recorded;
112
+ }
113
+
114
+ recordedOf(violation: MaintainabilityViolation) {
115
+ return this.sections.get(violation.rule)?.get(violation.key);
116
+ }
117
+
118
+ reasonOf(key: string) {
119
+ const reason = this.#holdsReasoned(key) ? this.reasons.get(key) : undefined;
120
+ return reason && reason.replace(/\s/g, "").length >= MaintainabilityBaseline.reasonMinChars ? reason : undefined;
121
+ }
122
+
123
+ entriesByRule() {
124
+ return new Map(
125
+ [...this.sections]
126
+ .filter(([, counts]) => counts.size)
127
+ .map(([rule, counts]): [DebtRuleId, BaselineEntry[]] => [
128
+ rule,
129
+ [...counts].map(([key, count]) => ({
130
+ key,
131
+ count,
132
+ reason: MaintainabilityBaseline.reasonedRules.has(rule) ? this.reasonOf(key) : undefined,
133
+ })),
134
+ ]),
135
+ );
136
+ }
137
+
138
+ tightenedBy(violations: MaintainabilityViolation[]) {
139
+ const current = MaintainabilityBaseline.of(violations);
140
+ const sections = new Map(
141
+ [...this.sections].map(([rule, counts]) => [
142
+ rule,
143
+ MaintainabilityBaseline.#lowered(counts, current.sections.get(rule) ?? new Map()),
144
+ ]),
145
+ );
146
+ return new MaintainabilityBaseline(sections).#withReasonsOf(this.reasons);
147
+ }
148
+
149
+ #withReasonsOf(reasons: ReadonlyMap<string, string>) {
150
+ return new MaintainabilityBaseline(
151
+ this.sections,
152
+ new Map([...reasons].filter(([key]) => this.#holdsReasoned(key))),
153
+ );
154
+ }
155
+
156
+ //* Only a shape entry grows with a reason: a write rule guards correctness, whatever the reason.
157
+ grownFrom(committed: MaintainabilityBaseline): BaselineEntryGrowth[] {
158
+ return [...this.sections].flatMap(([rule, counts]) =>
159
+ MaintainabilityBaseline.#raised(counts, committed.sections.get(rule) ?? new Map())
160
+ .filter(([key]) => !(MaintainabilityBaseline.reasonedRules.has(rule) && this.reasonOf(key)))
161
+ .map(([key, count, recorded]) => ({ rule, key, count, committed: recorded })),
162
+ );
163
+ }
164
+
165
+ stringify() {
166
+ const sections = MaintainabilityBaseline.debtRules.flatMap((rule) => {
167
+ const counts = this.sections.get(rule);
168
+ return counts?.size ? [[rule, MaintainabilityBaseline.#sortedObjectOf(counts)] as const] : [];
169
+ });
170
+ return JsonFormatter.stringify(
171
+ {
172
+ ...Object.fromEntries(sections),
173
+ ...(this.reasons.size ? { reasons: MaintainabilityBaseline.#sortedObjectOf(this.reasons) } : {}),
174
+ },
175
+ MaintainabilityBaseline.fileName,
176
+ );
177
+ }
178
+
179
+ #holdsReasoned(key: string) {
180
+ return [...this.sections].some(
181
+ ([rule, counts]) => MaintainabilityBaseline.reasonedRules.has(rule) && counts.has(key),
182
+ );
183
+ }
184
+
185
+ static #countsOf(value: unknown) {
186
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
187
+ const entries = Object.entries(value);
188
+ return entries.every(([, count]) => Number.isInteger(count)) ? (entries as [string, number][]) : null;
189
+ }
190
+
191
+ static #reasonsOf(value: unknown) {
192
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
193
+ const entries = Object.entries(value);
194
+ return entries.every(([, reason]) => typeof reason === "string") ? (entries as [string, string][]) : null;
195
+ }
196
+
197
+ static #lowered(recorded: Counts, current: Counts) {
198
+ return new Map(
199
+ [...recorded].flatMap(([key, count]) => {
200
+ const measured = current.get(key);
201
+ return measured === undefined ? [] : [[key, Math.min(count, measured)] as const];
202
+ }),
203
+ );
204
+ }
205
+
206
+ static #raised(working: Counts, committed: Counts) {
207
+ return [...working].flatMap(([key, count]) => {
208
+ const recorded = committed.get(key);
209
+ return recorded !== undefined && count <= recorded ? [] : [[key, count, recorded] as const];
210
+ });
211
+ }
212
+
213
+ static #sortedObjectOf<T>(values: ReadonlyMap<string, T>) {
214
+ return Object.fromEntries([...values].sort(([a], [b]) => MaintainabilityBaseline.#byKey(a, b)));
215
+ }
216
+
217
+ static #byKey(a: string, b: string) {
218
+ return a < b ? -1 : a > b ? 1 : 0;
219
+ }
220
+ }