@untangle-code/complexity 0.0.0-stage → 0.1.1

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 (105) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE +90 -0
  3. package/README.md +235 -2
  4. package/THIRD_PARTY_LICENSES.md +24 -0
  5. package/dist/aggregator.d.ts +28 -0
  6. package/dist/aggregator.d.ts.map +1 -0
  7. package/dist/aggregator.js +77 -0
  8. package/dist/aggregator.js.map +1 -0
  9. package/dist/assemble.d.ts +50 -0
  10. package/dist/assemble.d.ts.map +1 -0
  11. package/dist/assemble.js +43 -0
  12. package/dist/assemble.js.map +1 -0
  13. package/dist/cognitive.d.ts +62 -0
  14. package/dist/cognitive.d.ts.map +1 -0
  15. package/dist/cognitive.js +62 -0
  16. package/dist/cognitive.js.map +1 -0
  17. package/dist/cyclomatic.d.ts +29 -0
  18. package/dist/cyclomatic.d.ts.map +1 -0
  19. package/dist/cyclomatic.js +52 -0
  20. package/dist/cyclomatic.js.map +1 -0
  21. package/dist/entropy.d.ts +5 -0
  22. package/dist/entropy.d.ts.map +1 -0
  23. package/dist/entropy.js +29 -0
  24. package/dist/entropy.js.map +1 -0
  25. package/dist/explain.d.ts +22 -0
  26. package/dist/explain.d.ts.map +1 -0
  27. package/dist/explain.js +35 -0
  28. package/dist/explain.js.map +1 -0
  29. package/dist/halstead.d.ts +23 -0
  30. package/dist/halstead.d.ts.map +1 -0
  31. package/dist/halstead.js +24 -0
  32. package/dist/halstead.js.map +1 -0
  33. package/dist/index.d.ts +35 -0
  34. package/dist/index.d.ts.map +1 -0
  35. package/dist/index.js +34 -0
  36. package/dist/index.js.map +1 -0
  37. package/dist/ir/build-ir.d.ts +105 -0
  38. package/dist/ir/build-ir.d.ts.map +1 -0
  39. package/dist/ir/build-ir.js +267 -0
  40. package/dist/ir/build-ir.js.map +1 -0
  41. package/dist/maintainability.d.ts +15 -0
  42. package/dist/maintainability.d.ts.map +1 -0
  43. package/dist/maintainability.js +10 -0
  44. package/dist/maintainability.js.map +1 -0
  45. package/dist/node-kind-heuristics.d.ts +24 -0
  46. package/dist/node-kind-heuristics.d.ts.map +1 -0
  47. package/dist/node-kind-heuristics.js +37 -0
  48. package/dist/node-kind-heuristics.js.map +1 -0
  49. package/dist/prose/metrics.d.ts +81 -0
  50. package/dist/prose/metrics.d.ts.map +1 -0
  51. package/dist/prose/metrics.js +103 -0
  52. package/dist/prose/metrics.js.map +1 -0
  53. package/dist/scope-aggregate.d.ts +229 -0
  54. package/dist/scope-aggregate.d.ts.map +1 -0
  55. package/dist/scope-aggregate.js +588 -0
  56. package/dist/scope-aggregate.js.map +1 -0
  57. package/dist/scope-identity.d.ts +85 -0
  58. package/dist/scope-identity.d.ts.map +1 -0
  59. package/dist/scope-identity.js +144 -0
  60. package/dist/scope-identity.js.map +1 -0
  61. package/dist/scope.d.ts +36 -0
  62. package/dist/scope.d.ts.map +1 -0
  63. package/dist/scope.js +60 -0
  64. package/dist/scope.js.map +1 -0
  65. package/dist/structural.d.ts +30 -0
  66. package/dist/structural.d.ts.map +1 -0
  67. package/dist/structural.js +98 -0
  68. package/dist/structural.js.map +1 -0
  69. package/dist/syntactic.d.ts +35 -0
  70. package/dist/syntactic.d.ts.map +1 -0
  71. package/dist/syntactic.js +143 -0
  72. package/dist/syntactic.js.map +1 -0
  73. package/dist/text-lines.d.ts +34 -0
  74. package/dist/text-lines.d.ts.map +1 -0
  75. package/dist/text-lines.js +60 -0
  76. package/dist/text-lines.js.map +1 -0
  77. package/dist/textual.d.ts +28 -0
  78. package/dist/textual.d.ts.map +1 -0
  79. package/dist/textual.js +75 -0
  80. package/dist/textual.js.map +1 -0
  81. package/dist/types.d.ts +112 -0
  82. package/dist/types.d.ts.map +1 -0
  83. package/dist/types.js +2 -0
  84. package/dist/types.js.map +1 -0
  85. package/package.json +60 -3
  86. package/src/aggregator.ts +105 -0
  87. package/src/assemble.ts +109 -0
  88. package/src/cognitive.ts +127 -0
  89. package/src/cyclomatic.ts +78 -0
  90. package/src/entropy.ts +30 -0
  91. package/src/explain.ts +64 -0
  92. package/src/halstead.ts +54 -0
  93. package/src/index.ts +85 -0
  94. package/src/ir/build-ir.ts +411 -0
  95. package/src/maintainability.ts +29 -0
  96. package/src/node-kind-heuristics.ts +55 -0
  97. package/src/prose/metrics.ts +201 -0
  98. package/src/scope-aggregate.ts +775 -0
  99. package/src/scope-identity.ts +240 -0
  100. package/src/scope.ts +77 -0
  101. package/src/structural.ts +132 -0
  102. package/src/syntactic.ts +183 -0
  103. package/src/text-lines.ts +90 -0
  104. package/src/textual.ts +119 -0
  105. package/src/types.ts +121 -0
@@ -0,0 +1,775 @@
1
+ /**
2
+ * Aggregation ueber einen Scope-BAUM, ohne Doppelzaehlung (WP-82; spec.md
3
+ * §16.10, docs/BUILD_PLAN.md E4).
4
+ *
5
+ * ## Das Problem
6
+ *
7
+ * `aggregate()` in `aggregator.ts` dedupliziert ueber `metricsScopeId` und
8
+ * loest damit genau einen Fall: dasselbe Retrieval-Fragment zweimal in der
9
+ * Eingabe. Es loest NICHT den Fall, um den es hier geht — verschachtelte
10
+ * Scopes. `buildSymbolIR()` bezieht bewusst jeden Nachfahren-Scope in die IR
11
+ * des Zielscopes ein (siehe `ir/build-ir.ts`, "documented nesting context"
12
+ * nach spec.md §16.4): der `file`-Scope traegt also den Kontrollfluss ALLER
13
+ * Klassen, Methoden und Closures der Datei, die Methode den ihrer Closures.
14
+ * Jeder dieser Scopes hat eine eigene, gueltige `metricsScopeId` — die
15
+ * Deduplizierung greift nicht, und `sum` zaehlt denselben Quelltext einmal
16
+ * je umschliessendem Scope.
17
+ *
18
+ * Am eigenen Repo gemessen (siehe `scripts/complexity-report.mjs`): naiv
19
+ * summiert ergibt cognitive rund das Dreifache der Summe, die jeden Scope
20
+ * genau einmal zaehlt. Eine Hotspot-Liste auf dieser Basis steuert in die
21
+ * falsche Richtung.
22
+ *
23
+ * ## Die drei Lesarten
24
+ *
25
+ * "Summe ueber ein Paket" ist ohne Angabe der Lesart bedeutungslos. Dieses
26
+ * Modul kennt genau drei, alle drei zaehlen jeden Scope GENAU EINMAL, und
27
+ * sie unterscheiden sich ausschliesslich darin, wie viel Nesting-Kontext ein
28
+ * Konstrukt mitbekommt:
29
+ *
30
+ * - `"top_level"` (Datei-Lesart): Summe der `inclusiveValue` ueber die
31
+ * Wurzel-Scopes. Jedes Konstrukt zaehlt einmal, mit dem VOLLEN
32
+ * Nesting-Kontext bis zur Wurzel — jede umschliessende Funktion zaehlt als
33
+ * Nesting-Ebene. Beantwortet: "wie schwer ist diese Datei am Stueck zu
34
+ * lesen".
35
+ * - `"execution_unit"` (Funktions-Lesart, DEFAULT): Summe der
36
+ * `inclusiveValue` ueber die AEUSSERSTEN Ausfuehrungseinheiten (Funktion,
37
+ * Methode, Konstruktor) plus die `selfValue` aller Scopes, die selbst keine
38
+ * Ausfuehrungseinheit sind und auch keine ueber sich haben (Datei, Klasse,
39
+ * Interface, Namespace). Der Nesting-Zaehler startet bei jeder aeussersten
40
+ * Funktion neu; verschachtelte Closures bleiben — wie von spec.md §16.4
41
+ * verlangt — Teil ihrer umschliessenden Funktion. Das ist die Lesart, die
42
+ * der verbreiteten "cognitive complexity je Funktion, danach aufsummiert"
43
+ * Konvention entspricht und damit die einzige, die einer Gegenprobe gegen
44
+ * ein etabliertes Werkzeug (X8) standhaelt.
45
+ * - `"self"` (Eigenanteil-Lesart): Summe der `selfValue` ueber ALLE Scopes.
46
+ * Der Nesting-Zaehler startet in jedem Scope neu, auch in einer Closure.
47
+ * Untere Schranke; nuetzlich, um den Anteil sichtbar zu machen, den reines
48
+ * Nesting am Gesamtwert hat.
49
+ *
50
+ * Fuer eine Metrik OHNE Sockel je Scope (`perScopeBase === 0`, also cognitive)
51
+ * gilt strukturell `self <= execution_unit <= top_level`, und zwar als Folge
52
+ * der Kerninvariante: die Differenzen sind nicht Rauschen, sondern genau der
53
+ * Nesting-Aufschlag der jeweils zusaetzlich beruecksichtigten Ebenen (belegt
54
+ * in `scope-aggregate.test.ts`).
55
+ *
56
+ * ## Woraus die Doppelzaehlungsfreiheit tatsaechlich folgt
57
+ *
58
+ * Nicht aus `checkScopeTreeInvariants`. Deren Kernpruefung
59
+ * `children_exceed_parent` ist eine EINSEITIGE Schranke
60
+ * (`self + Summe Kinder <= inclusive`): sie faengt einen zu KLEINEN
61
+ * Elternwert, also fehlenden Inhalt. Ein zu GROSSER Elternwert — der Fall, in
62
+ * dem ein Kindbeitrag zweimal im Elternwert steckt — erfuellt sie erst recht
63
+ * und bleibt damit unentdeckt. Aus Werten allein ist er auch nicht
64
+ * entscheidbar: ein aufgeblaehter `inclusiveValue` sieht genauso aus wie ein
65
+ * Scope, der im eigenen Koerper einfach viel Kontrollfluss traegt.
66
+ *
67
+ * Was dieses Modul stattdessen ZUSICHERT und mit `checkScopeCoverage`
68
+ * NACHPRUEFT, ist die strukturelle Aussage: in jeder der drei Lesarten geht
69
+ * jeder Scope in GENAU EINEN Summanden ein — entweder als eigener Summand
70
+ * oder als Nachfahre genau eines Summanden, der mit `inclusiveValue` zaehlt.
71
+ * Damit kann keine Lesart denselben Quelltext zweimal aufaddieren. Das ist
72
+ * die Zusicherung, die die Summen tragen; die Werttreue der einzelnen
73
+ * `inclusiveValue`s liegt beim Aufrufer, der sie berechnet hat.
74
+ *
75
+ * Fuer eine Metrik MIT Sockel (`perScopeBase > 0`, also cyclomatic:
76
+ * `M = 1 + decision_points`) kehrt sich die Reihenfolge um, und das ist
77
+ * korrekt: die `self`-Lesart zaehlt den Sockel einmal JE SCOPE, die
78
+ * `top_level`-Lesart nur einmal je Datei. Die drei Zahlen bleiben
79
+ * doppelzaehlungsfrei — sie beantworten nur verschiedene Fragen. Wer sie
80
+ * nebeneinander zeigt, muss den Sockel dazusagen.
81
+ *
82
+ * ## Warum es KEINE Blatt-Lesart gibt
83
+ *
84
+ * "Summe der Blatt-Scopes" waere die naheliegende vierte Lesart und ist
85
+ * FALSCH: der Kontrollfluss, der direkt im Koerper eines Scopes MIT Kindern
86
+ * steht (eine `if`-Kette in einer Methode, die ausserdem eine Closure
87
+ * definiert), gehoert keinem Blatt und faellt ersatzlos weg. Das ist kein
88
+ * Deduplizieren, sondern stilles Verlieren von Inhalt — genau das, was E4
89
+ * verbietet. `ScopeTreeAggregateV1.leafOnlySum` weist die Zahl trotzdem aus,
90
+ * aber ausdruecklich als Diagnose zusammen mit `leafOnlyDroppedAmount`, dem
91
+ * Betrag, den sie verschluckt.
92
+ *
93
+ * ## Determinismus
94
+ *
95
+ * Kein `localeCompare`, kein `Date.now()`, kein `Math.random()`. Reihenfolgen
96
+ * werden ueber `compareCodepoints` aus `@untangle-code/domain` hergestellt.
97
+ */
98
+ import { compareCodepoints, UntangleError } from "@untangle-code/domain";
99
+
100
+ import type { AggregationEntry } from "./types.js";
101
+
102
+ /**
103
+ * Ein Scope mit beiden Lesarten desselben Metrikwerts.
104
+ *
105
+ * Der Aufrufer erzeugt die Werte mit demselben Calculator, nur mit
106
+ * unterschiedlich zugeschnittenen Facts:
107
+ * - `inclusiveValue`: Calculator auf `buildSymbolIR(facts, id)` — die
108
+ * ungefilterten Datei-Facts, der Builder zieht alle Nachfahren-Scopes mit
109
+ * hinein.
110
+ * - `selfValue`: Calculator auf
111
+ * `buildSymbolIR(selectSymbolFacts(facts, id), id)` — nur die eigenen
112
+ * Facts des Scopes, ohne Nachfahren.
113
+ */
114
+ export interface ScopeMetricNodeV1 {
115
+ /** `metrics_scope_id` nach spec.md §16.10; hier die `symbolScopeId`. */
116
+ metricsScopeId: string;
117
+ /** Eltern-Scope; fehlt genau bei einem Wurzel-Scope (typisch: der Datei-Scope). */
118
+ parentScopeId?: string;
119
+ /** Wert EINSCHLIESSLICH aller Nachfahren-Scopes. */
120
+ inclusiveValue: number;
121
+ /** Wert allein aus den eigenen Facts des Scopes, OHNE Nachfahren. */
122
+ selfValue: number;
123
+ /**
124
+ * True, wenn der Scope eine eigene Ausfuehrungseinheit ist (Funktion,
125
+ * Methode, Konstruktor, Lambda) — false bei Datei, Klasse, Interface,
126
+ * Namespace, Typ, Property-Gruppe. Steuert ausschliesslich die Lesart
127
+ * `"execution_unit"`; die Zuordnung trifft der Aufrufer, weil
128
+ * `SymbolScopeFact.kind` ein adapterspezifischer String ist und dieses
129
+ * Paket keine Sprach-Taxonomie kennt.
130
+ */
131
+ executionUnit: boolean;
132
+ /** SLOC des Scopes, fuer den SLOC-gewichteten Mittelwert in `aggregate()`. */
133
+ sloc?: number;
134
+ }
135
+
136
+ /** Die drei definierten Lesarten einer Scope-Summe. */
137
+ export const SCOPE_SUM_READINGS = ["top_level", "execution_unit", "self"] as const;
138
+
139
+ export type ScopeSumReading = (typeof SCOPE_SUM_READINGS)[number];
140
+
141
+ /** Geschlossene Menge der Invariantenverstoesse. Ein Verstoss ist ein Fehler, kein Hinweis. */
142
+ export const SCOPE_INVARIANT_CODES = [
143
+ "duplicate_scope_id",
144
+ "unknown_parent",
145
+ "parent_cycle",
146
+ "negative_value",
147
+ "self_exceeds_inclusive",
148
+ "children_exceed_parent",
149
+ "leaf_self_differs_from_inclusive",
150
+ "scope_counted_twice",
151
+ "scope_uncounted",
152
+ ] as const;
153
+
154
+ export type ScopeInvariantCode = (typeof SCOPE_INVARIANT_CODES)[number];
155
+
156
+ /** Ein Invariantenverstoss mit Pflicht-Detailtext (E4). */
157
+ export interface ScopeInvariantViolationV1 {
158
+ code: ScopeInvariantCode;
159
+ metricsScopeId: string;
160
+ /** Beobachteter Wert (Bedeutung je nach `code`). */
161
+ observed: number;
162
+ /** Obergrenze/Erwartungswert, gegen den `observed` geprueft wurde. */
163
+ limit: number;
164
+ /** Menschenlesbare Begruendung; nie leer. */
165
+ detail: string;
166
+ }
167
+
168
+ export interface ScopeTreeOptions {
169
+ /**
170
+ * Konstanter Sockel, den JEDER gemessene Scope traegt, unabhaengig von
171
+ * seinem Inhalt. Cognitive: `0` (Default). Cyclomatic classic/extended:
172
+ * `1`, weil `computeCyclomatic` immer den `M = 1 + decision_points`-Sockel
173
+ * anlegt — ohne diese Angabe meldet die Eltern/Kind-Pruefung fuer
174
+ * Cyclomatic Scheinverstoesse, sobald ein Scope mehr als ein Kind hat.
175
+ * Die Pruefung rechnet mit `wert - perScopeBase`; die ausgewiesenen Summen
176
+ * bleiben unveraendert.
177
+ */
178
+ perScopeBase?: number;
179
+ /** Toleranz fuer Fliesskomma-Vergleiche. Default 1e-9. */
180
+ epsilon?: number;
181
+ }
182
+
183
+ /** Ergebnis von `aggregateScopeTree`. */
184
+ export interface ScopeTreeAggregateV1 {
185
+ /** Scopes nach Dedupe ueber `metricsScopeId`. */
186
+ scopeCount: number;
187
+ /** Wegen wiederholter `metricsScopeId` verworfene Eingaben (spec.md §16.10). */
188
+ duplicatesDropped: number;
189
+ rootCount: number;
190
+ /** Anzahl der aeussersten Ausfuehrungseinheiten (Basis der Funktions-Lesart). */
191
+ executionUnitRootCount: number;
192
+ leafCount: number;
193
+ /** Lesart `"top_level"`. */
194
+ topLevelSum: number;
195
+ /** Lesart `"execution_unit"` — die Vorgabezahl. */
196
+ executionUnitSum: number;
197
+ /** Lesart `"self"`. */
198
+ selfSum: number;
199
+ /**
200
+ * NUR DIAGNOSE: Summe der `inclusiveValue` ueber alle Scopes — die naiv
201
+ * doppelt zaehlende Zahl. Nie als Kennzahl ausgeben.
202
+ */
203
+ naiveSum: number;
204
+ /** `naiveSum - topLevelSum`: der Betrag, den eine naive Summe doppelt zaehlt. */
205
+ doubleCountedAmount: number;
206
+ /** NUR DIAGNOSE: Summe ueber die Blatt-Scopes (siehe Modul-Doku). */
207
+ leafOnlySum: number;
208
+ /** `selfSum - leafOnlySum`: der Betrag, den eine Blatt-Lesart verschlucken wuerde. */
209
+ leafOnlyDroppedAmount: number;
210
+ }
211
+
212
+ interface ResolvedTree {
213
+ /** Deduplizierte Knoten, stabil nach `metricsScopeId` sortiert. */
214
+ nodes: ScopeMetricNodeV1[];
215
+ byId: Map<string, ScopeMetricNodeV1>;
216
+ childrenByParent: Map<string, ScopeMetricNodeV1[]>;
217
+ duplicatesDropped: number;
218
+ duplicateConflicts: ScopeInvariantViolationV1[];
219
+ }
220
+
221
+ /**
222
+ * Die Felder, in denen sich zwei Eintraege mit derselben `metricsScopeId`
223
+ * unterscheiden — als LISTE, nicht als Ja/Nein.
224
+ *
225
+ * WP-186: Die Begruendung im Verstoss darf nicht behaupten, „die Werte
226
+ * weichen ab", und dann zweimal dieselbe Zahl drucken. Genau das geschah,
227
+ * sobald der Unterschied in `parentScopeId` oder `executionUnit` lag: der
228
+ * Text nannte `inclusive`/`self`, die dort identisch waren, und das
229
+ * tatsaechlich abweichende Feld kam nirgends vor. Wer so etwas liest, sucht
230
+ * einen Fehler in einer Zahl, die stimmt.
231
+ *
232
+ * WP-200: `sloc` gehoert dazu. Es blieb ausserhalb des Vergleichs, obwohl es
233
+ * das GEWICHT im SLOC-gewichteten Mittel von `aggregate()` stellt: zwei
234
+ * Eintraege mit derselben Id und verschiedenem `sloc` galten als identisch,
235
+ * der zweite wurde ohne Verstoss verworfen — und mit ihm sein Gewicht. Der
236
+ * Mittelwert verschob sich, und nichts sagte es.
237
+ */
238
+ function differingFields(a: ScopeMetricNodeV1, b: ScopeMetricNodeV1): string[] {
239
+ const differing: string[] = [];
240
+ if (a.inclusiveValue !== b.inclusiveValue) {
241
+ differing.push(`inclusive ${String(b.inclusiveValue)} vs. ${String(a.inclusiveValue)}`);
242
+ }
243
+ if (a.selfValue !== b.selfValue) {
244
+ differing.push(`self ${String(b.selfValue)} vs. ${String(a.selfValue)}`);
245
+ }
246
+ if (a.executionUnit !== b.executionUnit) {
247
+ differing.push(`executionUnit ${String(b.executionUnit)} vs. ${String(a.executionUnit)}`);
248
+ }
249
+ if (a.parentScopeId !== b.parentScopeId) {
250
+ differing.push(
251
+ `parentScopeId ${b.parentScopeId ?? "(none)"} vs. ${a.parentScopeId ?? "(none)"}`,
252
+ );
253
+ }
254
+ if (a.sloc !== b.sloc) {
255
+ differing.push(`sloc ${String(b.sloc ?? "(none)")} vs. ${String(a.sloc ?? "(none)")}`);
256
+ }
257
+ return differing;
258
+ }
259
+
260
+ /**
261
+ * Dedupliziert nach `metricsScopeId` (erstes Vorkommen gewinnt, wie
262
+ * `aggregate()`, spec.md §16.10: doppelte Retrieval-Fragmente MUESSEN ueber
263
+ * `metrics_scope_id` dedupliziert werden) und meldet dabei jede
264
+ * WIDERSPRUECHLICHE Wiederholung als Verstoss: zwei gleiche Ids mit
265
+ * verschiedenen Werten sind keine harmlose Dublette, sondern ein Zeichen,
266
+ * dass die Ids nicht das identifizieren, was sie sollen.
267
+ *
268
+ * ACHTUNG FUER AUFRUFER — was `duplicatesDropped` bedeutet: eine
269
+ * Wiederholung mit ZUFAELLIG gleichen Werten ist von einer echten Dublette
270
+ * nicht zu unterscheiden und wird deshalb ohne Verstoss verworfen. Wer keine
271
+ * wiederholten Retrieval-Fragmente in der Eingabe hat — wer also einen ECHTEN
272
+ * Scope-Baum aggregiert, in dem jede Id genau einen Scope bezeichnen MUSS —
273
+ * fuer den ist jedes `duplicatesDropped > 0` verlorener Inhalt und muss als
274
+ * Fehler behandelt werden. Die Zahl steht deshalb in
275
+ * `ScopeTreeAggregateV1.duplicatesDropped`; sie zu ignorieren waere ein
276
+ * stilles Ueberspringen (E4). `scripts/complexity-report.mjs` bricht darauf
277
+ * ab.
278
+ */
279
+ function resolveTree(nodes: readonly ScopeMetricNodeV1[]): ResolvedTree {
280
+ const byId = new Map<string, ScopeMetricNodeV1>();
281
+ const deduped: ScopeMetricNodeV1[] = [];
282
+ const duplicateConflicts: ScopeInvariantViolationV1[] = [];
283
+ let duplicatesDropped = 0;
284
+ for (const node of nodes) {
285
+ const existing = byId.get(node.metricsScopeId);
286
+ if (existing !== undefined) {
287
+ duplicatesDropped++;
288
+ const differing = differingFields(existing, node);
289
+ if (differing.length > 0) {
290
+ duplicateConflicts.push({
291
+ code: "duplicate_scope_id",
292
+ metricsScopeId: node.metricsScopeId,
293
+ observed: node.inclusiveValue,
294
+ limit: existing.inclusiveValue,
295
+ detail:
296
+ `metricsScopeId "${node.metricsScopeId}" appears more than once with differing ` +
297
+ `${differing.length === 1 ? "field" : "fields"}: ` +
298
+ `${differing.join(", ")}. The ID therefore does not identify exactly one scope; ` +
299
+ `a total over this set would be meaningless.`,
300
+ });
301
+ }
302
+ continue;
303
+ }
304
+ byId.set(node.metricsScopeId, node);
305
+ deduped.push(node);
306
+ }
307
+ deduped.sort((a, b) => compareCodepoints(a.metricsScopeId, b.metricsScopeId));
308
+
309
+ const childrenByParent = new Map<string, ScopeMetricNodeV1[]>();
310
+ for (const node of deduped) {
311
+ if (node.parentScopeId === undefined) continue;
312
+ const siblings = childrenByParent.get(node.parentScopeId) ?? [];
313
+ siblings.push(node);
314
+ childrenByParent.set(node.parentScopeId, siblings);
315
+ }
316
+ return { nodes: deduped, byId, childrenByParent, duplicatesDropped, duplicateConflicts };
317
+ }
318
+
319
+ /** True, wenn `node` in einer Elternkette liegt, die sich schliesst oder ins Leere laeuft. */
320
+ function ancestryProblem(
321
+ node: ScopeMetricNodeV1,
322
+ byId: ReadonlyMap<string, ScopeMetricNodeV1>,
323
+ ): "cycle" | "unknown_parent" | null {
324
+ const seen = new Set<string>([node.metricsScopeId]);
325
+ let current = node;
326
+ for (;;) {
327
+ const parentId = current.parentScopeId;
328
+ if (parentId === undefined) return null;
329
+ if (seen.has(parentId)) return "cycle";
330
+ const parent = byId.get(parentId);
331
+ if (parent === undefined) return "unknown_parent";
332
+ seen.add(parentId);
333
+ current = parent;
334
+ }
335
+ }
336
+
337
+ /**
338
+ * Prueft alle Invarianten des Scope-Baums und liefert JEDEN Verstoss mit
339
+ * Begruendung. Leere Liste heisst: der Baum traegt eine sinnvolle Summe.
340
+ *
341
+ * Geprueft wird:
342
+ * 1. `duplicate_scope_id` — dieselbe Id mit abweichenden Werten.
343
+ * 2. `unknown_parent` — `parentScopeId` zeigt auf einen Scope, der nicht in
344
+ * der Menge ist. Ihn stillschweigend als Wurzel zu behandeln waere ein
345
+ * E4-Verstoss: der Elternwert, gegen den geprueft werden muesste, fehlt.
346
+ * 3. `parent_cycle` — die Elternkette schliesst sich.
347
+ * 4. `negative_value` — negative Metrikwerte.
348
+ * 5. `self_exceeds_inclusive` — der Eigenanteil ist groesser als der Wert
349
+ * mit Nachfahren. Der Eigenanteil ist per Definition ein Teil davon.
350
+ * 6. `children_exceed_parent` — der Eigenanteil eines Scopes plus die Summe
351
+ * seiner direkten Kinder darf seinen eigenen Wert nicht ueberschreiten.
352
+ *
353
+ * WAS DIESE PRUEFUNG LEISTET UND WAS NICHT — sie ist eine EINSEITIGE
354
+ * Schranke (`self + Summe Kinder <= inclusive`) und darf deshalb nicht als
355
+ * Doppelzaehlungspruefung gelesen werden:
356
+ * - Sie schlaegt an, wenn der Elternwert ZU KLEIN ist, dem Elternwert
357
+ * also Inhalt seiner Kinder FEHLT (oder ein Kindwert zu gross ist).
358
+ * Ein Verstoss heisst: der Baum verliert Inhalt, nicht: er zaehlt
359
+ * doppelt.
360
+ * - Sie schlaegt NICHT an, wenn der Elternwert ZU GROSS ist — genau der
361
+ * Fall, in dem ein Kindbeitrag zweimal im Elternwert steckt. Ein
362
+ * aufgeblaehter `inclusiveValue` ist aus Werten allein nicht von einem
363
+ * Scope zu unterscheiden, der im eigenen Koerper viel Kontrollfluss
364
+ * traegt.
365
+ *
366
+ * Die Zusicherung "jeder Beitrag zaehlt genau einmal" leistet
367
+ * `checkScopeCoverage` auf der Struktur der jeweiligen Lesart, nicht
368
+ * diese Wertschranke.
369
+ * 7. `leaf_self_differs_from_inclusive` — ein Scope ohne Kinder hat keine
370
+ * Nachfahren, sein Eigenanteil MUSS also sein Gesamtwert sein. Weicht er
371
+ * ab, hat der Aufrufer die beiden Werte nicht aus demselben Scope
372
+ * berechnet.
373
+ *
374
+ * NICHT geprueft (und mit Absicht hier benannt statt verschwiegen): ein zu
375
+ * grosser `inclusiveValue` eines Scopes MIT Kindern. Siehe Punkt 6 und die
376
+ * Modul-Doku, Abschnitt "Woraus die Doppelzaehlungsfreiheit tatsaechlich
377
+ * folgt".
378
+ *
379
+ * Alle Wertvergleiche laufen auf `wert - perScopeBase` (siehe
380
+ * `ScopeTreeOptions.perScopeBase`).
381
+ */
382
+ export function checkScopeTreeInvariants(
383
+ nodes: readonly ScopeMetricNodeV1[],
384
+ options: ScopeTreeOptions = {},
385
+ ): ScopeInvariantViolationV1[] {
386
+ const perScopeBase = options.perScopeBase ?? 0;
387
+ const epsilon = options.epsilon ?? 1e-9;
388
+ const tree = resolveTree(nodes);
389
+ const violations: ScopeInvariantViolationV1[] = [...tree.duplicateConflicts];
390
+
391
+ for (const node of tree.nodes) {
392
+ if (node.inclusiveValue < 0 || node.selfValue < 0) {
393
+ violations.push({
394
+ code: "negative_value",
395
+ metricsScopeId: node.metricsScopeId,
396
+ observed: Math.min(node.inclusiveValue, node.selfValue),
397
+ limit: 0,
398
+ detail:
399
+ `Scope "${node.metricsScopeId}" has a negative metric value ` +
400
+ `(inclusive ${String(node.inclusiveValue)}, self ${String(node.selfValue)}). ` +
401
+ `Complexity metrics are non-negative.`,
402
+ });
403
+ }
404
+
405
+ const problem = ancestryProblem(node, tree.byId);
406
+ if (problem === "unknown_parent") {
407
+ violations.push({
408
+ code: "unknown_parent",
409
+ metricsScopeId: node.metricsScopeId,
410
+ observed: 0,
411
+ limit: 0,
412
+ detail:
413
+ `Scope "${node.metricsScopeId}" references a scope through its parent chain ` +
414
+ `that is absent from the set. It is not treated as a root: without the parent value, ` +
415
+ `we cannot determine whether its contribution is already included there (E4).`,
416
+ });
417
+ } else if (problem === "cycle") {
418
+ violations.push({
419
+ code: "parent_cycle",
420
+ metricsScopeId: node.metricsScopeId,
421
+ observed: 0,
422
+ limit: 0,
423
+ detail:
424
+ `The parent chain of scope "${node.metricsScopeId}" closes a cycle. ` +
425
+ `A scope tree with a cycle has no root or well-defined total.`,
426
+ });
427
+ }
428
+
429
+ const inclusive = node.inclusiveValue - perScopeBase;
430
+ const own = node.selfValue - perScopeBase;
431
+ if (own > inclusive + epsilon) {
432
+ violations.push({
433
+ code: "self_exceeds_inclusive",
434
+ metricsScopeId: node.metricsScopeId,
435
+ observed: own,
436
+ limit: inclusive,
437
+ detail:
438
+ `Scope "${node.metricsScopeId}": self contribution ${String(node.selfValue)} exceeds ` +
439
+ `the inclusive value ${String(node.inclusiveValue)}. The self contribution is part ` +
440
+ `of the total and cannot exceed it.`,
441
+ });
442
+ }
443
+
444
+ const children = tree.childrenByParent.get(node.metricsScopeId) ?? [];
445
+ if (children.length === 0) {
446
+ if (Math.abs(node.inclusiveValue - node.selfValue) > epsilon) {
447
+ violations.push({
448
+ code: "leaf_self_differs_from_inclusive",
449
+ metricsScopeId: node.metricsScopeId,
450
+ observed: node.selfValue,
451
+ limit: node.inclusiveValue,
452
+ detail:
453
+ `Scope "${node.metricsScopeId}" has no child scopes, but its self contribution ` +
454
+ `${String(node.selfValue)} differs from the inclusive value ${String(node.inclusiveValue)}. ` +
455
+ `Without descendants, both values must be identical.`,
456
+ });
457
+ }
458
+ continue;
459
+ }
460
+ const childrenSum = children.reduce(
461
+ (sum, child) => sum + (child.inclusiveValue - perScopeBase),
462
+ 0,
463
+ );
464
+ if (own + childrenSum > inclusive + epsilon) {
465
+ const childList = children
466
+ .map((child) => `${child.metricsScopeId}=${String(child.inclusiveValue)}`)
467
+ .join(", ");
468
+ violations.push({
469
+ code: "children_exceed_parent",
470
+ metricsScopeId: node.metricsScopeId,
471
+ observed: own + childrenSum,
472
+ limit: inclusive,
473
+ detail:
474
+ `Scope "${node.metricsScopeId}": self contribution ${String(own)} plus the sum of ` +
475
+ `${String(children.length)} direct children ${String(childrenSum)} equals ` +
476
+ `${String(own + childrenSum)} and exceeds the parent value ${String(inclusive)} ` +
477
+ `(values after subtracting perScopeBase=${String(perScopeBase)}). Children: ${childList}. ` +
478
+ `The parent value is missing child content, or a child value is too large; ` +
479
+ `a root total would be too small. This one-sided bound cannot detect ` +
480
+ `a parent value that is too large; checkScopeCoverage checks that separately.`,
481
+ });
482
+ }
483
+ }
484
+
485
+ violations.sort(
486
+ (a, b) =>
487
+ compareCodepoints(a.metricsScopeId, b.metricsScopeId) || compareCodepoints(a.code, b.code),
488
+ );
489
+ return violations;
490
+ }
491
+
492
+ /**
493
+ * Wirft bei jedem Invariantenverstoss. Die Meldung nennt Anzahl und die
494
+ * ersten Verstoesse im Klartext; `context.violations` traegt die volle Liste.
495
+ *
496
+ * Geprueft wird in ZWEI Stufen: erst die Wert- und Bauminvarianten
497
+ * (`checkScopeTreeInvariants`), danach — nur wenn der Baum ueberhaupt
498
+ * wohlgeformt ist — die strukturelle Zaehlpruefung ueber alle drei Lesarten
499
+ * (`checkAllScopeCoverage`). Erst beide zusammen tragen die Aussage, dass die
500
+ * ausgegebenen Summen jeden Scope genau einmal enthalten; die Wertschranke
501
+ * allein taete es nicht (siehe Punkt 6 oben).
502
+ */
503
+ export function assertScopeTreeInvariants(
504
+ nodes: readonly ScopeMetricNodeV1[],
505
+ options: ScopeTreeOptions = {},
506
+ ): void {
507
+ const treeViolations = checkScopeTreeInvariants(nodes, options);
508
+ const violations = treeViolations.length > 0 ? treeViolations : checkAllScopeCoverage(nodes);
509
+ if (violations.length === 0) return;
510
+ const preview = violations
511
+ .slice(0, 3)
512
+ .map((violation) => `${violation.code}: ${violation.detail}`)
513
+ .join(" | ");
514
+ throw new UntangleError(
515
+ "ANALYSIS_INCOMPLETE",
516
+ `scope tree invariants violated (${String(violations.length)} violation(s)): ${preview}`,
517
+ { operation: "complexity.aggregateScopeTree", context: { violations } },
518
+ );
519
+ }
520
+
521
+ /**
522
+ * Liefert genau die Eintraege, die eine Lesart zaehlt — jeder Scope hoechstens
523
+ * einmal, jeweils mit dem fuer die Lesart richtigen Wert. Das Ergebnis geht
524
+ * unveraendert in `aggregate()`; damit gelten count/mean/median/p90/p95/max/
525
+ * Schwellenzaehlung (spec.md §16.10) auf einer doppelzaehlungsfreien Basis.
526
+ *
527
+ * Prueft KEINE Invarianten — dafuer ist `checkScopeTreeInvariants` da. Ein
528
+ * `unknown_parent` wird hier zwangslaeufig wie eine Wurzel behandelt; genau
529
+ * deshalb meldet die Pruefung ihn als Fehler, statt ihn zu tolerieren.
530
+ */
531
+ export function selectScopeEntries(
532
+ nodes: readonly ScopeMetricNodeV1[],
533
+ reading: ScopeSumReading,
534
+ ): AggregationEntry[] {
535
+ const tree = resolveTree(nodes);
536
+ const selection = selectionFor(tree, reading);
537
+ const entries: AggregationEntry[] = [];
538
+ for (const node of tree.nodes) {
539
+ const kind = selection.get(node.metricsScopeId);
540
+ if (kind === undefined) continue;
541
+ const entry: AggregationEntry = {
542
+ metricsScopeId: node.metricsScopeId,
543
+ value: kind === "inclusive" ? node.inclusiveValue : node.selfValue,
544
+ };
545
+ if (node.sloc !== undefined) entry.sloc = node.sloc;
546
+ entries.push(entry);
547
+ }
548
+ return entries;
549
+ }
550
+
551
+ /**
552
+ * Mit welchem Wert ein ausgewaehlter Scope in seine Lesart eingeht — und
553
+ * damit auch, WIE WEIT sein Summand reicht: `"inclusive"` deckt den Scope
554
+ * und alle seine Nachfahren ab, `"self"` nur ihn selbst.
555
+ */
556
+ export type ScopeSelectionKind = "inclusive" | "self";
557
+
558
+ /**
559
+ * Welche Scopes eine Lesart als Summanden zaehlt und mit welcher Reichweite.
560
+ * Was nicht in der Abbildung steht, ist kein eigener Summand.
561
+ */
562
+ export type ScopeSelectionV1 = ReadonlyMap<string, ScopeSelectionKind>;
563
+
564
+ /**
565
+ * Die Auswahl einer Lesart. Einzige Quelle der Wahrheit fuer
566
+ * `selectScopeEntries` UND `checkScopeCoverage`: die Pruefung soll dieselbe
567
+ * Auswahl sehen, die auch summiert wird, sonst prueft sie etwas anderes als
568
+ * das, was ausgegeben wird.
569
+ */
570
+ export function selectionForReading(
571
+ nodes: readonly ScopeMetricNodeV1[],
572
+ reading: ScopeSumReading,
573
+ ): ScopeSelectionV1 {
574
+ return selectionFor(resolveTree(nodes), reading);
575
+ }
576
+
577
+ function selectionFor(
578
+ tree: ResolvedTree,
579
+ reading: ScopeSumReading,
580
+ ): Map<string, ScopeSelectionKind> {
581
+ const selection = new Map<string, ScopeSelectionKind>();
582
+ for (const node of tree.nodes) {
583
+ switch (reading) {
584
+ case "self":
585
+ selection.set(node.metricsScopeId, "self");
586
+ break;
587
+ case "top_level":
588
+ if (node.parentScopeId === undefined || !tree.byId.has(node.parentScopeId)) {
589
+ selection.set(node.metricsScopeId, "inclusive");
590
+ }
591
+ break;
592
+ case "execution_unit":
593
+ // Ein Scope MIT umschliessender Ausfuehrungseinheit ist bereits in
594
+ // deren `inclusiveValue` enthalten und darf nicht erneut zaehlen.
595
+ if (enclosingExecutionUnit(node, tree.byId) === null) {
596
+ selection.set(node.metricsScopeId, node.executionUnit ? "inclusive" : "self");
597
+ }
598
+ break;
599
+ }
600
+ }
601
+ return selection;
602
+ }
603
+
604
+ /**
605
+ * Prueft STRUKTURELL, dass eine Lesart jeden Scope genau einmal zaehlt — die
606
+ * Zusicherung, die `children_exceed_parent` NICHT leisten kann (siehe die
607
+ * Modul-Doku, Abschnitt "Woraus die Doppelzaehlungsfreiheit tatsaechlich
608
+ * folgt", und Punkt 6 von `checkScopeTreeInvariants`).
609
+ *
610
+ * Fuer jeden Scope wird gezaehlt, wie viele Summanden der Lesart seinen
611
+ * Beitrag tragen: er selbst, falls er ausgewaehlt ist, plus jeder echte
612
+ * Vorfahre, der mit `inclusiveValue` ausgewaehlt ist (dessen Summand deckt
613
+ * den ganzen Teilbaum ab). Genau EINER muss es sein.
614
+ *
615
+ * Was die Pruefung NICHT leisten kann: sie sieht die Struktur, nicht die
616
+ * Werte. Ein `inclusiveValue`, der den Beitrag eines Kindes intern doppelt
617
+ * enthaelt, ist aus Zahlen allein nicht erkennbar — dafuer haftet der
618
+ * Aufrufer, der die Werte berechnet hat.
619
+ */
620
+ export function checkScopeCoverage(
621
+ nodes: readonly ScopeMetricNodeV1[],
622
+ reading: ScopeSumReading,
623
+ ): ScopeInvariantViolationV1[] {
624
+ return checkSelectionCoverage(nodes, selectionForReading(nodes, reading), reading);
625
+ }
626
+
627
+ /**
628
+ * Dieselbe Zaehlpruefung wie `checkScopeCoverage`, aber gegen eine BELIEBIGE
629
+ * Auswahl. Sie ist der eigentliche Kern; `checkScopeCoverage` ist nur die
630
+ * Anwendung auf die Auswahl, die eine der drei Lesarten trifft.
631
+ *
632
+ * Getrennt exportiert, weil die Zusicherung sonst nicht vorfuehrbar waere:
633
+ * die Auswahlen der drei Lesarten koennen `scope_counted_twice` per
634
+ * Konstruktion nicht ausloesen (dort ist ein Scope ausgewaehlt GENAU DANN,
635
+ * wenn er keinen ausgewaehlten `inclusive`-Vorfahren hat). Eine Pruefung,
636
+ * deren Alarmfall ueber die oeffentliche Schnittstelle nicht erreichbar ist,
637
+ * waere eine Behauptung statt einer Pruefung — mit dieser Funktion laesst er
638
+ * sich zeigen, und der Waechter greift, sobald jemand `selectionFor` aendert.
639
+ *
640
+ * @param label Erscheint im Detailtext an der Stelle der Lesart.
641
+ */
642
+ export function checkSelectionCoverage(
643
+ nodes: readonly ScopeMetricNodeV1[],
644
+ selection: ScopeSelectionV1,
645
+ label: string,
646
+ ): ScopeInvariantViolationV1[] {
647
+ const tree = resolveTree(nodes);
648
+ const violations: ScopeInvariantViolationV1[] = [];
649
+
650
+ for (const node of tree.nodes) {
651
+ const carriers: string[] = [];
652
+ if (selection.has(node.metricsScopeId)) carriers.push(node.metricsScopeId);
653
+ const seen = new Set<string>([node.metricsScopeId]);
654
+ let parentId = node.parentScopeId;
655
+ while (parentId !== undefined && !seen.has(parentId)) {
656
+ seen.add(parentId);
657
+ const parent = tree.byId.get(parentId);
658
+ if (parent === undefined) break;
659
+ if (selection.get(parentId) === "inclusive") carriers.push(parentId);
660
+ parentId = parent.parentScopeId;
661
+ }
662
+
663
+ if (carriers.length === 1) continue;
664
+ if (carriers.length > 1) {
665
+ violations.push({
666
+ code: "scope_counted_twice",
667
+ metricsScopeId: node.metricsScopeId,
668
+ observed: carriers.length,
669
+ limit: 1,
670
+ detail:
671
+ `Selection "${label}": the contribution of scope "${node.metricsScopeId}" appears in ` +
672
+ `${String(carriers.length)} contributing terms simultaneously (${carriers.join(", ")}). ` +
673
+ `The total counts the same source text more than once.`,
674
+ });
675
+ continue;
676
+ }
677
+ violations.push({
678
+ code: "scope_uncounted",
679
+ metricsScopeId: node.metricsScopeId,
680
+ observed: 0,
681
+ limit: 1,
682
+ detail:
683
+ `Selection "${label}": the contribution of scope "${node.metricsScopeId}" belongs to no ` +
684
+ `contributing term. Its content is lost from the total without a replacement; ` +
685
+ `silently losing content is forbidden by E4.`,
686
+ });
687
+ }
688
+
689
+ violations.sort(
690
+ (a, b) =>
691
+ compareCodepoints(a.metricsScopeId, b.metricsScopeId) || compareCodepoints(a.code, b.code),
692
+ );
693
+ return violations;
694
+ }
695
+
696
+ /**
697
+ * `checkScopeCoverage` fuer ALLE drei Lesarten, Verstoesse deterministisch
698
+ * nach Lesart und Scope sortiert.
699
+ */
700
+ export function checkAllScopeCoverage(
701
+ nodes: readonly ScopeMetricNodeV1[],
702
+ ): ScopeInvariantViolationV1[] {
703
+ return SCOPE_SUM_READINGS.flatMap((reading) => checkScopeCoverage(nodes, reading));
704
+ }
705
+
706
+ /** Naechster Vorfahre, der Ausfuehrungseinheit ist; `null`, wenn es keinen gibt. */
707
+ function enclosingExecutionUnit(
708
+ node: ScopeMetricNodeV1,
709
+ byId: ReadonlyMap<string, ScopeMetricNodeV1>,
710
+ ): ScopeMetricNodeV1 | null {
711
+ const seen = new Set<string>([node.metricsScopeId]);
712
+ let parentId = node.parentScopeId;
713
+ while (parentId !== undefined && !seen.has(parentId)) {
714
+ seen.add(parentId);
715
+ const parent = byId.get(parentId);
716
+ if (parent === undefined) return null;
717
+ if (parent.executionUnit) return parent;
718
+ parentId = parent.parentScopeId;
719
+ }
720
+ return null;
721
+ }
722
+
723
+ /**
724
+ * Summiert einen Scope-Baum in allen drei Lesarten, ohne Doppelzaehlung.
725
+ *
726
+ * WIRFT bei jedem Invariantenverstoss (`UntangleError`, Code
727
+ * `ANALYSIS_INCOMPLETE`): eine Summe ueber einen widerspruechlichen Baum ist
728
+ * keine Zahl mit Bedeutung, und ein Hinweis waere hier zu wenig. Wer die
729
+ * Verstoesse anzeigen statt abbrechen will, ruft vorher
730
+ * `checkScopeTreeInvariants` auf.
731
+ */
732
+ export function aggregateScopeTree(
733
+ nodes: readonly ScopeMetricNodeV1[],
734
+ options: ScopeTreeOptions = {},
735
+ ): ScopeTreeAggregateV1 {
736
+ assertScopeTreeInvariants(nodes, options);
737
+ const tree = resolveTree(nodes);
738
+
739
+ const sumOf = (reading: ScopeSumReading): number =>
740
+ selectScopeEntries(nodes, reading).reduce((sum, entry) => sum + entry.value, 0);
741
+
742
+ let rootCount = 0;
743
+ let executionUnitRootCount = 0;
744
+ let leafCount = 0;
745
+ let naiveSum = 0;
746
+ let leafOnlySum = 0;
747
+ for (const node of tree.nodes) {
748
+ naiveSum += node.inclusiveValue;
749
+ if (node.parentScopeId === undefined) rootCount++;
750
+ if (node.executionUnit && enclosingExecutionUnit(node, tree.byId) === null) {
751
+ executionUnitRootCount++;
752
+ }
753
+ if ((tree.childrenByParent.get(node.metricsScopeId) ?? []).length === 0) {
754
+ leafCount++;
755
+ leafOnlySum += node.inclusiveValue;
756
+ }
757
+ }
758
+
759
+ const topLevelSum = sumOf("top_level");
760
+ const selfSum = sumOf("self");
761
+ return {
762
+ scopeCount: tree.nodes.length,
763
+ duplicatesDropped: tree.duplicatesDropped,
764
+ rootCount,
765
+ executionUnitRootCount,
766
+ leafCount,
767
+ topLevelSum,
768
+ executionUnitSum: sumOf("execution_unit"),
769
+ selfSum,
770
+ naiveSum,
771
+ doubleCountedAmount: naiveSum - topLevelSum,
772
+ leafOnlySum,
773
+ leafOnlyDroppedAmount: selfSum - leafOnlySum,
774
+ };
775
+ }