@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,240 @@
1
+ /**
2
+ * Eindeutige Scope-Identitaet fuer die Aggregation (WP-82).
3
+ *
4
+ * ## Warum es dieses Modul gibt
5
+ *
6
+ * spec.md §16.10 verlangt die Deduplizierung ueber `metrics_scope_id`. Das
7
+ * setzt voraus, dass diese Id GENAU EINEN Scope bezeichnet. Der Bezeichner,
8
+ * den die Sprachadapter heute liefern — `SymbolScopeFact.symbolScopeId` — tut
9
+ * das innerhalb einer Datei NICHT: er wird aus dem qualifizierten Namen plus
10
+ * einem Ordinal gebildet, das je Elternscope zaehlt. Zwei anonyme Callbacks
11
+ * unter zwei verschiedenen, ebenfalls anonymen Eltern bekommen damit beide den
12
+ * qualifizierten Namen `<anonymous>.<anonymous>` und beide das Ordinal 0 —
13
+ * dieselbe Id fuer zwei verschiedene Scopes. Am eigenen Repo betrifft das rund
14
+ * ein Fuenftel aller Scopes.
15
+ *
16
+ * Die Folge waere nicht nur eine schiefe Summe, sondern stiller Inhaltsverlust
17
+ * (E4): `aggregate()` verwirft den zweiten Scope als Dublette,
18
+ * `selectSymbolFacts()` liefert die VEREINIGUNG der Facts beider Scopes, und
19
+ * `buildSymbolIR()` baut die IR fuer denjenigen, der die interne Map zuletzt
20
+ * gewonnen hat.
21
+ *
22
+ * ## Was dieses Modul tut
23
+ *
24
+ * `withUniqueScopeIds()` schreibt einen Facts-Satz auf nachweislich eindeutige
25
+ * Scope-Ids um: Byte-Range statt Name plus Ordinal. Jede Fact-Zuordnung wird
26
+ * ueber Byte-Containment neu aufgeloest (der ENGSTE Scope, der die Id des
27
+ * Facts traegt und dessen Range den Fact enthaelt). Die aufgetretenen
28
+ * Kollisionen werden GEZAEHLT UND ZURUECKGEGEBEN, nicht stillschweigend
29
+ * geheilt — wer den Bericht liest, sieht, dass die Adapter-Id das Problem hat,
30
+ * und nicht nur, dass jemand daran vorbeigearbeitet hat.
31
+ *
32
+ * Das ist eine Reparatur an der Datenkante, KEIN Ersatz fuer die Behebung im
33
+ * Adapter. Sobald `symbolScopeId` je Datei eindeutig ist, bleibt `collisions`
34
+ * leer und dieses Modul kann entfallen.
35
+ *
36
+ * ## Grenze (bewusst, nicht verschwiegen)
37
+ *
38
+ * `NodeKindFact` traegt als einzige Fact-Art KEINE `SourceRange` (domain
39
+ * `analysis.ts`); ihre Zuordnung ist ueber Containment daher nicht
40
+ * reparierbar. Sie wird auf den ERSTEN Scope mit der jeweiligen Adapter-Id
41
+ * abgebildet und die Zahl der so behandelten Facts in
42
+ * `nodeKindsAttributedByFallback` ausgewiesen. Betroffen sind nur die
43
+ * syntaktischen Metriken (`computeSyntactic`), nicht Cyclomatic/Cognitive.
44
+ */
45
+ import type {
46
+ ComplexityFacts,
47
+ NodeKindFact,
48
+ RawCall,
49
+ SourceRange,
50
+ SymbolScopeFact,
51
+ } from "@untangle-code/domain";
52
+ import { ID_COMPONENT_SEPARATOR, compareCodepoints } from "@untangle-code/domain";
53
+
54
+ import { containsRange } from "./scope.js";
55
+
56
+ /** Eine Adapter-Id, die mehr als einen Scope bezeichnet. */
57
+ export interface ScopeIdCollisionV1 {
58
+ /** Die mehrdeutige `symbolScopeId` aus dem Sprachadapter. */
59
+ adapterScopeId: string;
60
+ /** Wie viele verschiedene Scopes sie bezeichnet (>= 2). */
61
+ scopeCount: number;
62
+ /** Startzeilen der betroffenen Scopes, aufsteigend — zum Nachschlagen. */
63
+ startLines: number[];
64
+ }
65
+
66
+ export interface UniqueScopeIdsResultV1 {
67
+ /** Facts mit eindeutigen Scope-Ids; sonst strukturgleich zur Eingabe. */
68
+ facts: ComplexityFacts;
69
+ /** Aufrufe mit umgeschriebenem `callerScopeId`. */
70
+ calls: RawCall[];
71
+ /** Abbildung neue Id -> urspruengliche Adapter-Id, fuer Diagnosen. */
72
+ originalScopeIds: Map<string, string>;
73
+ /** Jede mehrdeutige Adapter-Id, nach Codepoints sortiert. Leer heisst: alles war eindeutig. */
74
+ collisions: ScopeIdCollisionV1[];
75
+ /** `NodeKindFact`s ohne Range, die per Fallback zugeordnet wurden (siehe Modul-Doku). */
76
+ nodeKindsAttributedByFallback: number;
77
+ }
78
+
79
+ interface Rewritten {
80
+ scope: SymbolScopeFact;
81
+ uniqueId: string;
82
+ }
83
+
84
+ /** Byte-Range-basierte, innerhalb einer Datei eindeutige Id. */
85
+ function uniqueIdFor(prefix: string, range: SourceRange): string {
86
+ return [prefix, "scope", String(range.startByte), String(range.endByte)].join(
87
+ ID_COMPONENT_SEPARATOR,
88
+ );
89
+ }
90
+
91
+ /** Innerster Scope aus `candidates`, dessen Range `range` enthaelt. */
92
+ function innermostOwner(
93
+ candidates: readonly Rewritten[],
94
+ range: SourceRange,
95
+ ): Rewritten | undefined {
96
+ let best: Rewritten | undefined;
97
+ for (const candidate of candidates) {
98
+ if (!containsRange(candidate.scope.range, range)) continue;
99
+ if (
100
+ best === undefined ||
101
+ candidate.scope.range.endByte - candidate.scope.range.startByte <
102
+ best.scope.range.endByte - best.scope.range.startByte
103
+ ) {
104
+ best = candidate;
105
+ }
106
+ }
107
+ return best;
108
+ }
109
+
110
+ /**
111
+ * Schreibt einen Facts-Satz auf eindeutige Scope-Ids um.
112
+ *
113
+ * @param facts Facts einer Datei, wie der Sprachadapter sie liefert.
114
+ * @param calls Rohaufrufe derselben Datei (`extractCalls`); ihr
115
+ * `callerScopeId` wird mit umgeschrieben, damit die Rekursionsmarkierung in
116
+ * `buildSymbolIR` weiter greift.
117
+ * @param idPrefix Praefix der neuen Ids, ueblicherweise der repo-relative
118
+ * Pfad. Er macht die Ids ueber Dateigrenzen hinweg eindeutig; innerhalb der
119
+ * Datei sorgt die Byte-Range dafuer.
120
+ *
121
+ * Kann ein Fact ueber Containment keinem Scope zugeordnet werden (er laege
122
+ * ausserhalb jeder Range seines eigenen Scopes — ein Adapterfehler anderer
123
+ * Art), bleibt seine Adapter-Id stehen. Die Aggregation sieht dann einen
124
+ * unbekannten Eltern-Scope und meldet ihn als Invariantenverstoss, statt eine
125
+ * saubere Zahl vorzutaeuschen.
126
+ */
127
+ export function withUniqueScopeIds(
128
+ facts: ComplexityFacts,
129
+ calls: readonly RawCall[],
130
+ idPrefix: string,
131
+ ): UniqueScopeIdsResultV1 {
132
+ const rewritten: Rewritten[] = facts.symbolScopes.map((scope) => ({
133
+ scope,
134
+ uniqueId: uniqueIdFor(idPrefix, scope.range),
135
+ }));
136
+
137
+ const byAdapterId = new Map<string, Rewritten[]>();
138
+ for (const entry of rewritten) {
139
+ const bucket = byAdapterId.get(entry.scope.symbolScopeId) ?? [];
140
+ bucket.push(entry);
141
+ byAdapterId.set(entry.scope.symbolScopeId, bucket);
142
+ }
143
+
144
+ const collisions: ScopeIdCollisionV1[] = [];
145
+ for (const [adapterScopeId, bucket] of byAdapterId) {
146
+ if (bucket.length < 2) continue;
147
+ collisions.push({
148
+ adapterScopeId,
149
+ scopeCount: bucket.length,
150
+ startLines: bucket.map((entry) => entry.scope.range.startLine).sort((a, b) => a - b),
151
+ });
152
+ }
153
+ collisions.sort((a, b) => compareCodepoints(a.adapterScopeId, b.adapterScopeId));
154
+
155
+ const originalScopeIds = new Map<string, string>();
156
+ for (const entry of rewritten) originalScopeIds.set(entry.uniqueId, entry.scope.symbolScopeId);
157
+
158
+ /** Neue Id fuer einen Fact mit Range. */
159
+ const resolve = (adapterScopeId: string, range: SourceRange): string => {
160
+ const bucket = byAdapterId.get(adapterScopeId);
161
+ if (bucket === undefined || bucket.length === 0) return adapterScopeId;
162
+ const first = bucket[0] as Rewritten;
163
+ if (bucket.length === 1) return first.uniqueId;
164
+ const owner = innermostOwner(bucket, range);
165
+ return owner === undefined ? adapterScopeId : owner.uniqueId;
166
+ };
167
+
168
+ let nodeKindsAttributedByFallback = 0;
169
+ const mappedNodeKinds: NodeKindFact[] = facts.nodeKinds.map((fact) => {
170
+ const bucket = byAdapterId.get(fact.symbolScopeId);
171
+ if (bucket === undefined || bucket.length === 0) return fact;
172
+ if (bucket.length > 1) nodeKindsAttributedByFallback++;
173
+ return { ...fact, symbolScopeId: (bucket[0] as Rewritten).uniqueId };
174
+ });
175
+
176
+ const mappedScopes: SymbolScopeFact[] = rewritten.map((entry) => {
177
+ const parentAdapterId = entry.scope.parentScopeId;
178
+ if (parentAdapterId === undefined) {
179
+ return { ...entry.scope, symbolScopeId: entry.uniqueId };
180
+ }
181
+ const parentBucket = byAdapterId.get(parentAdapterId);
182
+ let parentId: string;
183
+ if (parentBucket === undefined || parentBucket.length === 0) {
184
+ parentId = parentAdapterId;
185
+ } else if (parentBucket.length === 1) {
186
+ parentId = (parentBucket[0] as Rewritten).uniqueId;
187
+ } else {
188
+ const owner = innermostOwner(
189
+ parentBucket.filter((candidate) => candidate !== entry),
190
+ entry.scope.range,
191
+ );
192
+ parentId = owner === undefined ? parentAdapterId : owner.uniqueId;
193
+ }
194
+ return { ...entry.scope, symbolScopeId: entry.uniqueId, parentScopeId: parentId };
195
+ });
196
+
197
+ const nextFacts: ComplexityFacts = {
198
+ symbolScopes: mappedScopes,
199
+ decisions: facts.decisions.map((fact) => ({
200
+ ...fact,
201
+ symbolScopeId: resolve(fact.symbolScopeId, fact.range),
202
+ })),
203
+ nestingEvents: facts.nestingEvents.map((fact) => ({
204
+ ...fact,
205
+ symbolScopeId: resolve(fact.symbolScopeId, fact.range),
206
+ })),
207
+ operators: facts.operators.map((fact) => ({
208
+ ...fact,
209
+ symbolScopeId: resolve(fact.symbolScopeId, fact.range),
210
+ })),
211
+ operands: facts.operands.map((fact) => ({
212
+ ...fact,
213
+ symbolScopeId: resolve(fact.symbolScopeId, fact.range),
214
+ })),
215
+ nodeKinds: mappedNodeKinds,
216
+ comments: facts.comments,
217
+ identifiers: facts.identifiers.map((fact) => ({
218
+ ...fact,
219
+ symbolScopeId: resolve(fact.symbolScopeId, fact.range),
220
+ })),
221
+ literals: facts.literals.map((fact) => ({
222
+ ...fact,
223
+ symbolScopeId: resolve(fact.symbolScopeId, fact.range),
224
+ })),
225
+ parseDiagnostics: facts.parseDiagnostics,
226
+ };
227
+
228
+ const nextCalls: RawCall[] = calls.map((call) => ({
229
+ ...call,
230
+ callerScopeId: resolve(call.callerScopeId, call.range),
231
+ }));
232
+
233
+ return {
234
+ facts: nextFacts,
235
+ calls: nextCalls,
236
+ originalScopeIds,
237
+ collisions,
238
+ nodeKindsAttributedByFallback,
239
+ };
240
+ }
package/src/scope.ts ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Facts scoping and byte/line utilities shared by every calculator
3
+ * (architecture.md §15.1). Every calculator is scope-agnostic: it operates on
4
+ * whatever `ComplexityFacts` it is handed. `selectSymbolFacts` is how a
5
+ * caller narrows file-level facts down to one symbol's facts before calling
6
+ * a calculator for `scope: "symbol"`; passing the file's facts unfiltered is
7
+ * how a caller computes `scope: "file"`.
8
+ */
9
+ import type { ComplexityFacts, SourceRange } from "@untangle-code/domain";
10
+
11
+ function byScope<T extends { symbolScopeId: string }>(
12
+ items: readonly T[],
13
+ symbolScopeId: string,
14
+ ): T[] {
15
+ return items.filter((item) => item.symbolScopeId === symbolScopeId);
16
+ }
17
+
18
+ /** True iff `inner` lies fully inside `outer` (byte-interval containment, both inclusive/exclusive per domain convention). */
19
+ export function containsRange(outer: SourceRange, inner: SourceRange): boolean {
20
+ return outer.startByte <= inner.startByte && inner.endByte <= outer.endByte;
21
+ }
22
+
23
+ /** True iff `outer` strictly contains `inner` (contains, but is not the identical range). */
24
+ export function strictlyContainsRange(outer: SourceRange, inner: SourceRange): boolean {
25
+ if (!containsRange(outer, inner)) return false;
26
+ return outer.startByte !== inner.startByte || outer.endByte !== inner.endByte;
27
+ }
28
+
29
+ /** Stable identity key for deduplicating facts that reference the same source span. */
30
+ export function rangeKey(range: SourceRange): string {
31
+ return `${range.startByte}:${range.endByte}`;
32
+ }
33
+
34
+ /**
35
+ * Narrows file-level `ComplexityFacts` to the facts belonging to one symbol
36
+ * scope. Fact kinds that carry `symbolScopeId` (decisions, nestingEvents,
37
+ * operators, operands, nodeKinds, identifiers, literals) are filtered by
38
+ * exact id match — a symbol's own facts are attributed directly to it, never
39
+ * inherited from nested named symbols, which get their own `SymbolScopeFact`
40
+ * and their own facts (architecture.md §15.1; Untangle decision, see
41
+ * README "Entscheidungen" #1). `comments` and `parseDiagnostics` carry no
42
+ * `symbolScopeId` and are instead filtered by byte-range containment inside
43
+ * the symbol's own range.
44
+ *
45
+ * Returns empty fact arrays (rather than throwing) when `symbolScopeId` is
46
+ * not present in `facts.symbolScopes` — a caller passing a stale id gets an
47
+ * empty-but-valid `ComplexityFacts`, not a crash.
48
+ */
49
+ export function selectSymbolFacts(facts: ComplexityFacts, symbolScopeId: string): ComplexityFacts {
50
+ const symbolScope = facts.symbolScopes.find((s) => s.symbolScopeId === symbolScopeId);
51
+ const range = symbolScope?.range;
52
+ return {
53
+ symbolScopes: facts.symbolScopes.filter((s) => s.symbolScopeId === symbolScopeId),
54
+ decisions: byScope(facts.decisions, symbolScopeId),
55
+ nestingEvents: byScope(facts.nestingEvents, symbolScopeId),
56
+ operators: byScope(facts.operators, symbolScopeId),
57
+ operands: byScope(facts.operands, symbolScopeId),
58
+ nodeKinds: byScope(facts.nodeKinds, symbolScopeId),
59
+ comments: range ? facts.comments.filter((r) => containsRange(range, r)) : [],
60
+ identifiers: byScope(facts.identifiers, symbolScopeId),
61
+ literals: byScope(facts.literals, symbolScopeId),
62
+ parseDiagnostics: range
63
+ ? facts.parseDiagnostics.filter((d) => containsRange(range, d.range))
64
+ : [],
65
+ };
66
+ }
67
+
68
+ /** Slices `sourceText` by a byte-offset `SourceRange` (UTF-8, per domain convention). */
69
+ export function sliceByRange(sourceText: string, range: SourceRange): string {
70
+ const buf = Buffer.from(sourceText, "utf8");
71
+ return buf.subarray(range.startByte, range.endByte).toString("utf8");
72
+ }
73
+
74
+ /** Splits text into physical lines, normalizing CRLF/CR to LF first. */
75
+ export function splitLines(text: string): string[] {
76
+ return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
77
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * StructuralCalculator (spec.md §16.5).
3
+ *
4
+ * Facts-derivable metrics are computed directly from `ComplexityFacts` (plus
5
+ * `sourceText`/`scopeRange` for the line-based ones). Coupling and API-surface
6
+ * metrics (`fan_in`, `fan_out`, `import_in_degree`, `import_out_degree`,
7
+ * `afferent_coupling`, `efferent_coupling`, `instability`,
8
+ * `inheritance_depth`, `public_api_count`) need whole-repository graph/export
9
+ * knowledge this facts-only package does not have — per the work order they
10
+ * are accepted as `StructuralExternalInput` and are only ever present in the
11
+ * output when the caller actually supplies them; they are never defaulted or
12
+ * guessed (see README "Entscheidungen" #4 for `instability`'s one derived
13
+ * exception).
14
+ *
15
+ * `parameter_count`, `return_count` and `exit_point_count` use a best-effort
16
+ * node-kind classifier (see `node-kind-heuristics.ts`) since `SymbolScopeFact`
17
+ * carries no structured parameter list — see README "Entscheidungen" #5 and
18
+ * `openIssues`.
19
+ */
20
+ import type { ComplexityFacts, SourceRange } from "@untangle-code/domain";
21
+
22
+ import { classifyNodeKind } from "./node-kind-heuristics.js";
23
+ import { strictlyContainsRange } from "./scope.js";
24
+ import { classifyLines } from "./text-lines.js";
25
+ import type { MetricsResult, StructuralExternalInput } from "./types.js";
26
+
27
+ const FUNCTION_KINDS = new Set(["function", "arrow_function", "function_expression"]);
28
+ const METHOD_KINDS = new Set(["method", "constructor"]);
29
+ const CLASS_KINDS = new Set(["class"]);
30
+ const INTERFACE_KINDS = new Set(["interface"]);
31
+
32
+ function countByScopeKind(facts: ComplexityFacts, kinds: Set<string>): number {
33
+ return facts.symbolScopes.filter((s) => kinds.has(s.kind.toLowerCase())).length;
34
+ }
35
+
36
+ /** Interval-nesting depth of `target` among `pool`: 1 + number of other ranges in `pool` that strictly contain it. */
37
+ function nestingDepthOf(target: SourceRange, pool: readonly SourceRange[]): number {
38
+ let depth = 1;
39
+ for (const other of pool) {
40
+ if (other === target) continue;
41
+ if (strictlyContainsRange(other, target)) depth++;
42
+ }
43
+ return depth;
44
+ }
45
+
46
+ function mean(values: readonly number[]): number {
47
+ if (values.length === 0) return 0;
48
+ return values.reduce((sum, v) => sum + v, 0) / values.length;
49
+ }
50
+
51
+ /**
52
+ * @param facts Facts already narrowed to the target scope (see `selectSymbolFacts`).
53
+ * @param sourceText Full original file content (UTF-8) — needed because
54
+ * `SourceRange` byte offsets are file-absolute.
55
+ * @param scopeRange The scope's own range (a `SymbolScopeFact.range` for
56
+ * `scope: "symbol"`, or the whole-file range for `scope: "file"`).
57
+ */
58
+ export function computeStructural(
59
+ facts: ComplexityFacts,
60
+ sourceText: string,
61
+ scopeRange: SourceRange,
62
+ external: StructuralExternalInput = {},
63
+ algorithmVersion = "untangle-structural-v1",
64
+ ): MetricsResult {
65
+ const { physicalLoc, commentLines, docLines, blankLines } = classifyLines(
66
+ sourceText,
67
+ scopeRange,
68
+ facts.comments,
69
+ );
70
+ const sloc = Math.max(physicalLoc - blankLines.size - commentLines.size, 0);
71
+
72
+ const nonContinuationNesting = facts.nestingEvents
73
+ .filter((n) => !n.continuation)
74
+ .map((n) => n.range);
75
+ const nestingDepths = nonContinuationNesting.map((range) =>
76
+ nestingDepthOf(range, nonContinuationNesting),
77
+ );
78
+
79
+ let parameterCount = 0;
80
+ let returnCount = 0;
81
+ let exitPointCount = 0;
82
+ for (const node of facts.nodeKinds) {
83
+ const classified = classifyNodeKind(node.kind);
84
+ if (classified === "parameter") parameterCount++;
85
+ if (classified === "return") {
86
+ returnCount++;
87
+ exitPointCount++;
88
+ }
89
+ if (classified === "exit") exitPointCount++;
90
+ }
91
+
92
+ const metrics: Record<string, number> = {
93
+ physical_loc: physicalLoc,
94
+ sloc,
95
+ blank_lines: blankLines.size,
96
+ comment_lines: commentLines.size,
97
+ doc_lines: docLines.size,
98
+ function_count: countByScopeKind(facts, FUNCTION_KINDS),
99
+ method_count: countByScopeKind(facts, METHOD_KINDS),
100
+ class_count: countByScopeKind(facts, CLASS_KINDS),
101
+ interface_count: countByScopeKind(facts, INTERFACE_KINDS),
102
+ parameter_count: parameterCount,
103
+ return_count: returnCount,
104
+ exit_point_count: exitPointCount,
105
+ max_nesting: nestingDepths.length > 0 ? Math.max(...nestingDepths) : 0,
106
+ avg_nesting: mean(nestingDepths),
107
+ };
108
+
109
+ if (external.fanIn !== undefined) metrics["fan_in"] = external.fanIn;
110
+ if (external.fanOut !== undefined) metrics["fan_out"] = external.fanOut;
111
+ if (external.importInDegree !== undefined) metrics["import_in_degree"] = external.importInDegree;
112
+ if (external.importOutDegree !== undefined)
113
+ metrics["import_out_degree"] = external.importOutDegree;
114
+ if (external.afferentCoupling !== undefined)
115
+ metrics["afferent_coupling"] = external.afferentCoupling;
116
+ if (external.efferentCoupling !== undefined)
117
+ metrics["efferent_coupling"] = external.efferentCoupling;
118
+ if (external.inheritanceDepth !== undefined)
119
+ metrics["inheritance_depth"] = external.inheritanceDepth;
120
+ if (external.publicApiCount !== undefined) metrics["public_api_count"] = external.publicApiCount;
121
+
122
+ if (external.instability !== undefined) {
123
+ metrics["instability"] = external.instability;
124
+ } else if (external.afferentCoupling !== undefined && external.efferentCoupling !== undefined) {
125
+ const denom = external.afferentCoupling + external.efferentCoupling;
126
+ if (denom > 0) {
127
+ metrics["instability"] = external.efferentCoupling / denom;
128
+ }
129
+ }
130
+
131
+ return { metrics, algorithmVersion };
132
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * SyntacticCalculator (spec.md §16.8).
3
+ *
4
+ * `ast_node_count`/`ast_max_depth`/`ast_avg_depth`/`node_kind_entropy` and the
5
+ * construct counters are derived straight from `ComplexityFacts.nodeKinds`
6
+ * (each entry already carries its own AST depth) and the other typed fact
7
+ * arrays.
8
+ *
9
+ * `ast_branching_factor_avg`/`ast_branching_factor_max` (spec.md §16.8,
10
+ * "Mindestens") are reconstructed EXACTLY from the `nodeKinds` sequence:
11
+ * the adapter's single walk emits one `NodeKindFact` per visited node in
12
+ * DFS PRE-ORDER (that ordering is this calculator's input contract for the
13
+ * two branching keys; `selectSymbolFacts` filtering preserves it), and in a
14
+ * pre-order depth sequence every node's parent is the nearest preceding
15
+ * entry with a smaller depth — so per-node child counts, and from them the
16
+ * branching factors, follow without any parent pointers. This is the real
17
+ * AST branching factor, not an approximation (see README "Entscheidungen"
18
+ * #7); the optional `irRoot` is only used as a fallback when `nodeKinds` is
19
+ * empty (e.g. hand-built IR fixtures), because the sparse control-flow IR
20
+ * would otherwise understate branching.
21
+ */
22
+ import type { ComplexityFacts, ComplexityIRNode, NodeKindFact } from "@untangle-code/domain";
23
+
24
+ import { frequenciesOf, shannonEntropy } from "./entropy.js";
25
+ import { classifyNodeKind, isAsyncNodeKind, isExceptionNodeKind } from "./node-kind-heuristics.js";
26
+ import { rangeKey } from "./scope.js";
27
+ import type { MetricsResult } from "./types.js";
28
+
29
+ function mean(values: readonly number[]): number {
30
+ if (values.length === 0) return 0;
31
+ return values.reduce((s, v) => s + v, 0) / values.length;
32
+ }
33
+
34
+ interface BranchingStats {
35
+ avg: number;
36
+ max: number;
37
+ }
38
+
39
+ /**
40
+ * Exact per-node child counts from a DFS pre-order depth sequence (see the
41
+ * module doc): a stack of "open" ancestor indices is maintained; each new
42
+ * node closes every open node with depth >= its own, and the remaining top
43
+ * (nearest preceding smaller depth) is its parent. Works on scope-filtered
44
+ * sequences too — a scope's facts form contiguous pre-order fragments whose
45
+ * missing outer ancestors simply make their subtree roots forest roots, and
46
+ * child counts then cover exactly the scope's OWN nodes, consistent with
47
+ * every other metric computed from scope-narrowed facts.
48
+ */
49
+ function computeBranchingFromPreorderDepths(nodeKinds: readonly NodeKindFact[]): BranchingStats {
50
+ const childCounts = new Array<number>(nodeKinds.length).fill(0);
51
+ const openAncestors: number[] = [];
52
+ for (let i = 0; i < nodeKinds.length; i += 1) {
53
+ const depth = (nodeKinds[i] as NodeKindFact).depth;
54
+ while (
55
+ openAncestors.length > 0 &&
56
+ (nodeKinds[openAncestors[openAncestors.length - 1] as number] as NodeKindFact).depth >= depth
57
+ ) {
58
+ openAncestors.pop();
59
+ }
60
+ const parentIndex = openAncestors[openAncestors.length - 1];
61
+ if (parentIndex !== undefined) {
62
+ childCounts[parentIndex] = (childCounts[parentIndex] as number) + 1;
63
+ }
64
+ openAncestors.push(i);
65
+ }
66
+ let max = 0;
67
+ for (const count of childCounts) {
68
+ if (count > max) max = count;
69
+ }
70
+ return { avg: mean(childCounts), max };
71
+ }
72
+
73
+ function computeBranching(root: ComplexityIRNode): BranchingStats {
74
+ const branchCounts: number[] = [];
75
+ const stack: ComplexityIRNode[] = [root];
76
+ while (stack.length > 0) {
77
+ const node = stack.pop();
78
+ if (!node) continue;
79
+ branchCounts.push(node.children.length);
80
+ for (const child of node.children) stack.push(child);
81
+ }
82
+ return {
83
+ avg: mean(branchCounts),
84
+ max: branchCounts.length > 0 ? Math.max(...branchCounts) : 0,
85
+ };
86
+ }
87
+
88
+ /**
89
+ * @param facts Facts already narrowed to the target scope.
90
+ * @param sloc The scope's own SLOC, as computed by `computeStructural` —
91
+ * passed in rather than recomputed so `ast_nodes_per_sloc` is consistent
92
+ * with the structural snapshot instead of a second, possibly divergent,
93
+ * line-scan.
94
+ * @param irRoot Optional IR subtree for the same scope; only used as the
95
+ * branching-factor source when `facts.nodeKinds` is empty (the pre-order
96
+ * reconstruction over real node-kind facts is exact and takes precedence).
97
+ */
98
+ export function computeSyntactic(
99
+ facts: ComplexityFacts,
100
+ sloc: number,
101
+ irRoot?: ComplexityIRNode,
102
+ algorithmVersion = "untangle-syntactic-v1",
103
+ ): MetricsResult {
104
+ const depths = facts.nodeKinds.map((n) => n.depth);
105
+ const astNodeCount = facts.nodeKinds.length;
106
+
107
+ const controlFlowKeys = new Set<string>();
108
+ for (const d of facts.decisions) controlFlowKeys.add(rangeKey(d.range));
109
+ for (const n of facts.nestingEvents) controlFlowKeys.add(rangeKey(n.range));
110
+ const controlFlowNodeCount = controlFlowKeys.size;
111
+
112
+ // Ranged exception constructs (catch clauses) are deduplicated by range,
113
+ // since adapters may emit both a DecisionFact and a NestingFact for the
114
+ // same `catch`. Node-kind-only signals (e.g. a bare `throw_statement`,
115
+ // which carries no dedicated fact type) have no range to dedupe by and are
116
+ // simply counted in addition.
117
+ const exceptionRangeKeys = new Set<string>();
118
+ for (const n of facts.nestingEvents) {
119
+ if (n.construct === "catch") exceptionRangeKeys.add(rangeKey(n.range));
120
+ }
121
+ for (const d of facts.decisions) {
122
+ if (d.kind === "catch") exceptionRangeKeys.add(rangeKey(d.range));
123
+ }
124
+ const throwLikeNodeCount = facts.nodeKinds.filter(
125
+ (n) => isExceptionNodeKind(n.kind) && !n.kind.toLowerCase().includes("catch"),
126
+ ).length;
127
+ const exceptionConstructCount = exceptionRangeKeys.size + throwLikeNodeCount;
128
+
129
+ const nodeKindFrequencies = frequenciesOf(facts.nodeKinds, (n) => n.kind);
130
+
131
+ const metrics: Record<string, number> = {
132
+ ast_node_count: astNodeCount,
133
+ ast_max_depth: depths.length > 0 ? Math.max(...depths) : 0,
134
+ ast_avg_depth: mean(depths),
135
+ statement_count: facts.nodeKinds.filter((n) => classifyNodeKind(n.kind) === "statement").length,
136
+ expression_count: facts.nodeKinds.filter((n) => classifyNodeKind(n.kind) === "expression")
137
+ .length,
138
+ declaration_count: facts.nodeKinds.filter((n) => classifyNodeKind(n.kind) === "declaration")
139
+ .length,
140
+ control_flow_node_count: controlFlowNodeCount,
141
+ control_flow_density: astNodeCount > 0 ? controlFlowNodeCount / astNodeCount : 0,
142
+ lambda_count: facts.nestingEvents.filter((n) => n.construct === "lambda").length,
143
+ async_construct_count: facts.nodeKinds.filter((n) => isAsyncNodeKind(n.kind)).length,
144
+ exception_construct_count: exceptionConstructCount,
145
+ pattern_match_count: facts.decisions.filter((d) => d.kind === "pattern_alternative").length,
146
+ node_kind_entropy: shannonEntropy(nodeKindFrequencies.values()),
147
+ ast_nodes_per_sloc: astNodeCount / Math.max(sloc, 1),
148
+ parse_error_node_count: facts.parseDiagnostics.filter((d) => d.kind === "error_node").length,
149
+ missing_node_count: facts.parseDiagnostics.filter((d) => d.kind === "missing_node").length,
150
+ // WP-185: DIE ZWEI ZAHLEN OBEN SIND NICHT DIE GANZE WAHRHEIT.
151
+ //
152
+ // `ParseDiagnostic.kind` kennt vier Werte (`error_node`, `missing_node`,
153
+ // `fallback`, `other`). Die beiden Kennzahlen oben zaehlen genau zwei
154
+ // davon — und ein Adapter, dessen Parser keine tree-sitter-Fehlerknoten
155
+ // kennt, meldet seine Syntaxfehler als `"other"`. Gemessen am eigenen
156
+ // Python-Adapter: eine Datei mit `parseQuality: "partial"` und
157
+ // `ParseResult.errorNodeCount: 5` erzeugt fuenf Diagnosen der Art
158
+ // `"other"` — und `parse_error_node_count` stand trotzdem auf 0. Zwei
159
+ // Zeugen desselben Laufs widersprachen einander, und der, der das Haus
160
+ // verlaesst, war der falsche. Wer 0 liest, schliesst auf einen heilen
161
+ // Baum; das ist die Sorte stiller Fehlaussage, die E4 verbietet.
162
+ //
163
+ // Die beiden vorhandenen Kennzahlen bleiben unveraendert — sie bedeuten
164
+ // weiterhin genau, was ihr Name sagt. Ergaenzt werden zwei, die den Rest
165
+ // sichtbar machen, statt ihn unter einer 0 verschwinden zu lassen.
166
+ parse_diagnostic_count: facts.parseDiagnostics.length,
167
+ unclassified_parse_error_count: facts.parseDiagnostics.filter(
168
+ (d) => d.severity === "error" && d.kind !== "error_node" && d.kind !== "missing_node",
169
+ ).length,
170
+ };
171
+
172
+ if (facts.nodeKinds.length > 0) {
173
+ const branching = computeBranchingFromPreorderDepths(facts.nodeKinds);
174
+ metrics["ast_branching_factor_avg"] = branching.avg;
175
+ metrics["ast_branching_factor_max"] = branching.max;
176
+ } else if (irRoot) {
177
+ const branching = computeBranching(irRoot);
178
+ metrics["ast_branching_factor_avg"] = branching.avg;
179
+ metrics["ast_branching_factor_max"] = branching.max;
180
+ }
181
+
182
+ return { metrics, algorithmVersion };
183
+ }