@untangle-code/complexity 0.0.0-stage → 0.1.0

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,411 @@
1
+ /**
2
+ * IR producer: the bridge `ComplexityFacts` -> `ComplexityIRNode`
3
+ * (architecture.md §6.7, spec.md §16.4).
4
+ *
5
+ * architecture.md §6.7 says "Sprachadapter uebersetzen AST-Nodes in diese
6
+ * IR", but the `LanguageAdapter` port (domain `analysis.ts`) deliberately
7
+ * has no IR method — adapters emit flat, language-neutral `ComplexityFacts`.
8
+ * This module closes that gap INSIDE the complexity package: it rebuilds the
9
+ * `ComplexityIRNode` tree `computeCognitive` (and the branching metrics of
10
+ * `computeSyntactic`) require from the byte-ranges the facts already carry,
11
+ * so no language adapter needs to know the IR shape at all.
12
+ *
13
+ * INPUT CONTRACT (facts, as produced by parser-registry's single walk):
14
+ * - every `DecisionFact`/`NestingFact` range is the source range of the AST
15
+ * node that caused it; ranges of distinct facts never partially overlap
16
+ * (they are AST node ranges: equal, disjoint, or fully nested);
17
+ * - an `if`/`loop`/`catch`/`ternary` emits BOTH a `DecisionFact` and a
18
+ * `NestingFact` with the identical range (they are merged into one IR
19
+ * node here);
20
+ * - an `else if` is a `DecisionFact` of kind `"else_if"` plus a
21
+ * `NestingFact` with `continuation: true`, and its range lies INSIDE the
22
+ * range of the `if` it continues (the raw AST "alternate" shape).
23
+ *
24
+ * OUTPUT CONTRACT (what `computeCognitive` requires — see cognitive.ts):
25
+ * - an `if` / `else if` / `else if` chain becomes a FLAT run of sibling IR
26
+ * nodes under the parent of the first `if`, NEVER the raw AST's nested
27
+ * alternate shape. This is what makes "`else if` erhaelt keinen
28
+ * vollstaendigen neuen Nesting-Aufschlag" (spec.md §16.4) fall out of the
29
+ * traversal: siblings share the ambient depth. Implemented via the
30
+ * attachment rule `finalParent(continuationNode) =
31
+ * finalParent(itsContainmentParent)`, which flattens arbitrarily long
32
+ * chains while keeping a genuinely nested `if` (inside another `if`'s
33
+ * BODY) at its deeper level;
34
+ * - each branch keeps its own consequent content as `children` (content
35
+ * ranges nest inside the branch's own range, so containment placement
36
+ * already produces that).
37
+ *
38
+ * Documented mapping decisions (Untangle decisions, mirrored in the README):
39
+ * - nesting constructs map to IR kinds: if->"decision", loop->"loop",
40
+ * catch->"catch", ternary->"ternary", switch->"switch", lambda->"lambda";
41
+ * all of them are nesting boundaries (`nestingBoundary: true`), matching
42
+ * the boundary list documented in cognitive.ts;
43
+ * - a `case` decision (no nesting fact of its own) becomes a `"case"` node
44
+ * that is ALSO a nesting boundary — case bodies are one level deeper than
45
+ * the switch head, per cognitive.ts's documented boundary list;
46
+ * - logical-operator decisions (`logical_and`/`logical_or`/`short_circuit`)
47
+ * become non-boundary `"logical_sequence"` nodes carrying their
48
+ * `DecisionFact` (grouping happens in the consumers via `logicalGroupId`);
49
+ * - CONDITION HOISTING: a logical node whose direct IR parent is a
50
+ * condition-bearing boundary (`decision`/`loop`/`ternary`/`switch`) is
51
+ * hoisted to a sibling placed immediately BEFORE that boundary node. This
52
+ * encodes the untangle-cognitive-v1 convention that a guard's condition
53
+ * operators score at the guard's own ambient depth (see the golden case in
54
+ * cognitive.test.ts), and is exact for `switch` (everything nested in a
55
+ * switch body sits inside a `case` node). For `if`/loops/ternaries it is a
56
+ * documented approximation: facts carry no condition/body split, so a
57
+ * logical group written as a DIRECT body statement of the guard (e.g.
58
+ * `if (x) y = a && b;`) is hoisted too and scores one nesting level lower
59
+ * than a hypothetical exact split would give. Making this exact needs a
60
+ * `conditionRange` on `DecisionFact` (or a `bodyRange` on `NestingFact`)
61
+ * from the adapters — tracked as an open issue, not guessed here;
62
+ * - every descendant symbol scope of the target symbol is included: its
63
+ * facts participate in the target's IR, and the scope itself becomes a
64
+ * `"lambda"` boundary node when it is function-like (function / method /
65
+ * constructor — deduplicated by range against the adapter's own `lambda`
66
+ * nesting events) or a transparent non-boundary `"block"` node otherwise
67
+ * (class/interface/enum/namespace/type/property). This is the "documented
68
+ * nesting context" spec.md §16.4 demands for local functions/lambdas: a
69
+ * symbol's cognitive value includes the control flow of the lambdas it
70
+ * defines, one nesting level deeper — while those lambdas still get their
71
+ * own, separately-scoped snapshot when measured directly;
72
+ * - recursion (spec.md §16.4 "Rekursion wird markiert und KANN einen
73
+ * Beitrag erzeugen"): when the caller supplies the file's `RawCall`s
74
+ * (adapters expose them via `extractCalls`, but they are not part of
75
+ * `ComplexityFacts`), a call site inside the subtree whose unqualified
76
+ * callee name (or `this.`-qualified name, for methods) equals the name of
77
+ * its OWN enclosing scope becomes a `"recursion_marker"` node — direct
78
+ * self-recursion only; mutual recursion needs the call graph and is out of
79
+ * scope here.
80
+ */
81
+ import type {
82
+ ComplexityFacts,
83
+ ComplexityIRNode,
84
+ DecisionFact,
85
+ IRKind,
86
+ RawCall,
87
+ SymbolScopeFact,
88
+ } from "@untangle-code/domain";
89
+
90
+ import { containsRange, rangeKey } from "../scope.js";
91
+
92
+ export interface BuildSymbolIROptions {
93
+ /**
94
+ * Raw call sites of the same file (from `LanguageAdapter.extractCalls`).
95
+ * When given, direct self-recursion is marked with `"recursion_marker"`
96
+ * nodes; when omitted, no recursion contributions are produced (allowed:
97
+ * spec.md §16.4 makes the recursion contribution optional).
98
+ */
99
+ calls?: readonly RawCall[];
100
+ }
101
+
102
+ const FUNCTION_LIKE_SCOPE_KINDS: ReadonlySet<string> = new Set([
103
+ "function",
104
+ "method",
105
+ "constructor",
106
+ ]);
107
+
108
+ const LOGICAL_DECISION_KINDS: ReadonlySet<string> = new Set([
109
+ "logical_and",
110
+ "logical_or",
111
+ "short_circuit",
112
+ ]);
113
+
114
+ /** Boundary IR kinds whose head expression (condition) can carry logical groups. */
115
+ const CONDITION_BEARING_KINDS: ReadonlySet<IRKind> = new Set([
116
+ "decision",
117
+ "loop",
118
+ "ternary",
119
+ "switch",
120
+ ]);
121
+
122
+ function irKindForConstruct(construct: string): IRKind {
123
+ switch (construct) {
124
+ case "if":
125
+ return "decision";
126
+ case "loop":
127
+ return "loop";
128
+ case "catch":
129
+ return "catch";
130
+ case "ternary":
131
+ return "ternary";
132
+ case "switch":
133
+ return "switch";
134
+ case "lambda":
135
+ return "lambda";
136
+ default:
137
+ // Unknown future constructs still nest conservatively (they were
138
+ // emitted as nesting events, so they ARE nesting constructs).
139
+ return "other";
140
+ }
141
+ }
142
+
143
+ interface ProtoNode {
144
+ node: ComplexityIRNode;
145
+ /** From `NestingFact.continuation` — drives the else-if sibling flattening. */
146
+ continuation: boolean;
147
+ }
148
+
149
+ function isLogicalDecisionNode(node: ComplexityIRNode): boolean {
150
+ return (
151
+ node.decision !== undefined &&
152
+ LOGICAL_DECISION_KINDS.has(node.decision.kind) &&
153
+ node.nestingBoundary !== true
154
+ );
155
+ }
156
+
157
+ function isConditionBearingBoundary(node: ComplexityIRNode): boolean {
158
+ return node.nestingBoundary === true && CONDITION_BEARING_KINDS.has(node.kind);
159
+ }
160
+
161
+ /**
162
+ * Condition hoisting (see "CONDITION HOISTING" above): a logical node that
163
+ * is a direct child of a condition-bearing boundary moves up EXACTLY ONE
164
+ * level, inserted immediately before its former parent — that places the
165
+ * group at the guard's own ambient depth (the guard's children sit one
166
+ * boundary deeper than the guard itself). Never more than one level: the
167
+ * sweep for `parent` runs before recursing into its children, so a node
168
+ * hoisted out of a grandchild boundary is not swept again by any ancestor.
169
+ */
170
+ function hoistConditionGroups(parent: ComplexityIRNode): void {
171
+ const reordered: ComplexityIRNode[] = [];
172
+ for (const child of parent.children) {
173
+ if (isConditionBearingBoundary(child)) {
174
+ const staying: ComplexityIRNode[] = [];
175
+ for (const grandchild of child.children) {
176
+ if (isLogicalDecisionNode(grandchild)) {
177
+ reordered.push(grandchild);
178
+ } else {
179
+ staying.push(grandchild);
180
+ }
181
+ }
182
+ child.children = staying;
183
+ }
184
+ reordered.push(child);
185
+ }
186
+ parent.children = reordered;
187
+ for (const child of parent.children) {
188
+ hoistConditionGroups(child);
189
+ }
190
+ }
191
+
192
+ function isDirectSelfRecursion(call: RawCall, enclosingScope: SymbolScopeFact): boolean {
193
+ if (enclosingScope.name.length === 0) return false;
194
+ if (call.calleeName !== enclosingScope.name) return false;
195
+ // `foo()` inside function foo, or `this.foo()` inside method foo.
196
+ return call.qualifier === undefined || call.qualifier === "this";
197
+ }
198
+
199
+ /**
200
+ * Builds the `ComplexityIRNode` subtree for one symbol scope (kind
201
+ * `"symbol"`, `nestingBoundary` unset, traversal depth 0 — exactly the root
202
+ * shape `computeCognitive` expects), or the whole-file tree (kind `"root"`)
203
+ * when `symbolScopeId` is the file scope's id.
204
+ *
205
+ * Returns `undefined` when `symbolScopeId` does not exist in
206
+ * `facts.symbolScopes` (mirrors `selectSymbolFacts`' stale-id tolerance).
207
+ *
208
+ * Pass the UNFILTERED file facts, not `selectSymbolFacts` output: the
209
+ * builder selects the symbol's own facts AND its descendant scopes' facts
210
+ * itself (a symbol's lambdas nest into its IR — see the module doc).
211
+ */
212
+ export function buildSymbolIR(
213
+ facts: ComplexityFacts,
214
+ symbolScopeId: string,
215
+ options: BuildSymbolIROptions = {},
216
+ ): ComplexityIRNode | undefined {
217
+ const scopesById = new Map<string, SymbolScopeFact>();
218
+ for (const scope of facts.symbolScopes) scopesById.set(scope.symbolScopeId, scope);
219
+ const targetScope = scopesById.get(symbolScopeId);
220
+ if (targetScope === undefined) return undefined;
221
+
222
+ // Target scope plus all transitive descendants (lambdas, local functions,
223
+ // nested classes/methods, ...).
224
+ const childScopesByParent = new Map<string, SymbolScopeFact[]>();
225
+ for (const scope of facts.symbolScopes) {
226
+ if (scope.parentScopeId === undefined) continue;
227
+ const siblings = childScopesByParent.get(scope.parentScopeId) ?? [];
228
+ siblings.push(scope);
229
+ childScopesByParent.set(scope.parentScopeId, siblings);
230
+ }
231
+ const relevantScopeIds = new Set<string>([symbolScopeId]);
232
+ const descendantScopes: SymbolScopeFact[] = [];
233
+ const pending = [symbolScopeId];
234
+ while (pending.length > 0) {
235
+ const current = pending.pop() as string;
236
+ for (const child of childScopesByParent.get(current) ?? []) {
237
+ if (relevantScopeIds.has(child.symbolScopeId)) continue;
238
+ relevantScopeIds.add(child.symbolScopeId);
239
+ descendantScopes.push(child);
240
+ pending.push(child.symbolScopeId);
241
+ }
242
+ }
243
+
244
+ // ---- Proto nodes, deduplicated by range (an if/loop/catch/ternary emits
245
+ // a DecisionFact AND a NestingFact for the same AST node). ----
246
+ const protoByRange = new Map<string, ProtoNode>();
247
+
248
+ const ensureNode = (
249
+ key: string,
250
+ create: () => ComplexityIRNode,
251
+ continuation: boolean,
252
+ ): ProtoNode => {
253
+ const existing = protoByRange.get(key);
254
+ if (existing !== undefined) return existing;
255
+ const created: ProtoNode = { node: create(), continuation };
256
+ protoByRange.set(key, created);
257
+ return created;
258
+ };
259
+
260
+ for (const nesting of facts.nestingEvents) {
261
+ if (!relevantScopeIds.has(nesting.symbolScopeId)) continue;
262
+ ensureNode(
263
+ rangeKey(nesting.range),
264
+ () => ({
265
+ kind: irKindForConstruct(nesting.construct),
266
+ range: nesting.range,
267
+ children: [],
268
+ nestingBoundary: true,
269
+ symbolScopeId: nesting.symbolScopeId,
270
+ }),
271
+ nesting.continuation,
272
+ );
273
+ }
274
+
275
+ for (const decision of facts.decisions) {
276
+ if (!relevantScopeIds.has(decision.symbolScopeId)) continue;
277
+ const proto = ensureNode(rangeKey(decision.range), () => decisionOnlyNode(decision), false);
278
+ if (proto.node.decision === undefined) proto.node.decision = decision;
279
+ }
280
+
281
+ for (const scope of descendantScopes) {
282
+ const key = rangeKey(scope.range);
283
+ if (FUNCTION_LIKE_SCOPE_KINDS.has(scope.kind)) {
284
+ // Deduplicates against the adapter's own "lambda" nesting event (same
285
+ // AST node, same range) when the enclosing scope was executable.
286
+ ensureNode(
287
+ key,
288
+ () => ({
289
+ kind: "lambda",
290
+ range: scope.range,
291
+ children: [],
292
+ nestingBoundary: true,
293
+ symbolScopeId: scope.symbolScopeId,
294
+ }),
295
+ false,
296
+ );
297
+ } else {
298
+ ensureNode(
299
+ key,
300
+ () => ({
301
+ kind: "block",
302
+ range: scope.range,
303
+ children: [],
304
+ symbolScopeId: scope.symbolScopeId,
305
+ }),
306
+ false,
307
+ );
308
+ }
309
+ }
310
+
311
+ for (const call of options.calls ?? []) {
312
+ if (!relevantScopeIds.has(call.callerScopeId)) continue;
313
+ const enclosingScope = scopesById.get(call.callerScopeId);
314
+ if (enclosingScope === undefined) continue;
315
+ if (!isDirectSelfRecursion(call, enclosingScope)) continue;
316
+ ensureNode(
317
+ rangeKey(call.range),
318
+ () => ({
319
+ kind: "recursion_marker",
320
+ range: call.range,
321
+ children: [],
322
+ symbolScopeId: call.callerScopeId,
323
+ }),
324
+ false,
325
+ );
326
+ }
327
+
328
+ // ---- Containment placement with else-if sibling flattening. ----
329
+ const root: ComplexityIRNode = {
330
+ kind: targetScope.kind === "file" ? "root" : "symbol",
331
+ range: targetScope.range,
332
+ children: [],
333
+ symbolScopeId,
334
+ };
335
+
336
+ const sorted = [...protoByRange.values()].sort(
337
+ (a, b) =>
338
+ a.node.range.startByte - b.node.range.startByte ||
339
+ b.node.range.endByte - a.node.range.endByte,
340
+ );
341
+
342
+ const containmentStack: ProtoNode[] = [];
343
+ // The node each proto was ATTACHED to (differs from the containment parent
344
+ // for continuation nodes — that difference IS the flattening).
345
+ const attachedTo = new Map<ProtoNode, ComplexityIRNode>();
346
+
347
+ for (const proto of sorted) {
348
+ if (!containsRange(root.range, proto.node.range)) continue; // defensive: stray fact outside the symbol
349
+ while (
350
+ containmentStack.length > 0 &&
351
+ !containsRange(
352
+ (containmentStack[containmentStack.length - 1] as ProtoNode).node.range,
353
+ proto.node.range,
354
+ )
355
+ ) {
356
+ containmentStack.pop();
357
+ }
358
+ const containmentParent = containmentStack[containmentStack.length - 1];
359
+ let parent: ComplexityIRNode;
360
+ if (proto.continuation && containmentParent !== undefined) {
361
+ // finalParent(else_if) = finalParent(the if it continues); the owning
362
+ // if is exactly the containment parent (nothing sits between an
363
+ // if_statement and the if_statement in its else-clause).
364
+ parent = attachedTo.get(containmentParent) ?? root;
365
+ } else {
366
+ parent = containmentParent?.node ?? root;
367
+ }
368
+ parent.children.push(proto.node);
369
+ attachedTo.set(proto, parent);
370
+ containmentStack.push(proto);
371
+ }
372
+
373
+ // ---- Condition hoisting (one level; see module doc). ----
374
+ hoistConditionGroups(root);
375
+
376
+ return root;
377
+ }
378
+
379
+ function decisionOnlyNode(decision: DecisionFact): ComplexityIRNode {
380
+ if (decision.kind === "case") {
381
+ // Case bodies nest one level below the switch head (cognitive.ts's
382
+ // documented boundary list includes "case").
383
+ return {
384
+ kind: "case",
385
+ range: decision.range,
386
+ children: [],
387
+ nestingBoundary: true,
388
+ decision,
389
+ symbolScopeId: decision.symbolScopeId,
390
+ };
391
+ }
392
+ if (LOGICAL_DECISION_KINDS.has(decision.kind)) {
393
+ return {
394
+ kind: "logical_sequence",
395
+ range: decision.range,
396
+ children: [],
397
+ decision,
398
+ symbolScopeId: decision.symbolScopeId,
399
+ };
400
+ }
401
+ // guard/if/loop/... decisions normally merge with their nesting event by
402
+ // range; a decision kind without one (e.g. "pattern_alternative") becomes
403
+ // a plain non-boundary decision node.
404
+ return {
405
+ kind: "decision",
406
+ range: decision.range,
407
+ children: [],
408
+ decision,
409
+ symbolScopeId: decision.symbolScopeId,
410
+ };
411
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * MaintainabilityCalculator (spec.md §16.9). Exact default formula, no
3
+ * comment bonus (v1 deliberately omits it per spec.md §16.9, for
4
+ * cross-language stability), clamped to [0, 100]. The formula string itself
5
+ * is always returned, as required by the work order.
6
+ */
7
+ import type { MaintainabilityResult } from "./types.js";
8
+
9
+ export const MAINTAINABILITY_FORMULA =
10
+ "MI = clamp((171 - 5.2*ln(max(HalsteadVolume,1)) - 0.23*CyclomaticClassic - 16.2*ln(max(SLOC,1))) * 100 / 171, 0, 100)";
11
+
12
+ export interface MaintainabilityInput {
13
+ halsteadVolume: number;
14
+ cyclomaticClassic: number;
15
+ sloc: number;
16
+ }
17
+
18
+ export function computeMaintainability(
19
+ input: MaintainabilityInput,
20
+ algorithmVersion = "untangle-maintainability-v1",
21
+ ): MaintainabilityResult {
22
+ const volumeTerm = Math.log(Math.max(input.halsteadVolume, 1));
23
+ const slocTerm = Math.log(Math.max(input.sloc, 1));
24
+ const raw = 171 - 5.2 * volumeTerm - 0.23 * input.cyclomaticClassic - 16.2 * slocTerm;
25
+ const scaled = (raw * 100) / 171;
26
+ const value = Math.min(100, Math.max(0, scaled));
27
+
28
+ return { value, raw, formula: MAINTAINABILITY_FORMULA, algorithmVersion };
29
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Best-effort classification of raw, adapter-specific AST node kind strings
3
+ * (`NodeKindFact.kind`, `ComplexityFacts.nodeKinds`) into the language-neutral
4
+ * buckets `StructuralCalculator`/`SyntacticCalculator` need for
5
+ * `parameter_count`, `return_count`, `exit_point_count`, `statement_count`,
6
+ * `expression_count` and `declaration_count` (spec.md §16.5, §16.8).
7
+ *
8
+ * `ComplexityFacts` deliberately keeps `kind` as an opaque, grammar-specific
9
+ * string (e.g. tree-sitter's `"required_parameter"`, `"return_statement"`) —
10
+ * domain does not define a normalized node-kind taxonomy. This module is
11
+ * Untangle's own, documented, versioned answer to that gap (see README
12
+ * "Entscheidungen" #5); it is intentionally conservative (substring match on
13
+ * common, cross-grammar naming conventions) and is flagged in `openIssues` as
14
+ * a candidate for an additive domain extension once real adapter output
15
+ * exists to validate against.
16
+ */
17
+ export type NodeKindClass =
18
+ "parameter" | "return" | "exit" | "statement" | "expression" | "declaration" | "other";
19
+
20
+ const RETURN_MARKERS = ["return_statement", "return"];
21
+ const EXIT_MARKERS = ["throw_statement", "throw", "break_statement", "continue_statement"];
22
+ const PARAMETER_MARKERS = ["parameter"];
23
+ const DECLARATION_MARKERS = ["declaration", "declarator"];
24
+ const STATEMENT_MARKERS = ["statement"];
25
+ const EXPRESSION_MARKERS = ["expression"];
26
+
27
+ function matches(normalizedKind: string, markers: readonly string[]): boolean {
28
+ return markers.some((marker) => normalizedKind === marker || normalizedKind.includes(marker));
29
+ }
30
+
31
+ /** Classifies one raw node-kind string. Order matters: more specific buckets are checked first. */
32
+ export function classifyNodeKind(kind: string): NodeKindClass {
33
+ const normalized = kind.toLowerCase();
34
+ if (matches(normalized, RETURN_MARKERS)) return "return";
35
+ if (matches(normalized, EXIT_MARKERS)) return "exit";
36
+ if (matches(normalized, PARAMETER_MARKERS)) return "parameter";
37
+ if (matches(normalized, DECLARATION_MARKERS)) return "declaration";
38
+ if (matches(normalized, STATEMENT_MARKERS)) return "statement";
39
+ if (matches(normalized, EXPRESSION_MARKERS)) return "expression";
40
+ return "other";
41
+ }
42
+
43
+ const ASYNC_MARKERS = ["async", "await"];
44
+
45
+ /** True when a raw node-kind string looks like an async construct (best-effort, see module docs). */
46
+ export function isAsyncNodeKind(kind: string): boolean {
47
+ return matches(kind.toLowerCase(), ASYNC_MARKERS);
48
+ }
49
+
50
+ const EXCEPTION_MARKERS = ["throw_statement", "throw", "try_statement", "catch_clause"];
51
+
52
+ /** True when a raw node-kind string looks like an exception construct (best-effort, see module docs). */
53
+ export function isExceptionNodeKind(kind: string): boolean {
54
+ return matches(kind.toLowerCase(), EXCEPTION_MARKERS);
55
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Prosa-Metriken (vision.md §1c/§1d, WP-217).
3
+ *
4
+ * WARUM ES DIESE DATEI GIBT
5
+ *
6
+ * Alles andere in diesem Paket rechnet ueber `ComplexityFacts`: Scopes,
7
+ * Entscheidungspunkte, Verschachtelung, Operatoren. Ein Markdown-Dokument
8
+ * hat davon NICHTS — nicht wenig, sondern nichts. Der Prosa-Adapter liefert
9
+ * deshalb leere Facts, und Prosa wird hier ueber ihre EIGENEN Groessen
10
+ * gemessen: Struktur- und Verweislast.
11
+ *
12
+ * Die Trennung ist strukturell, nicht per Konvention: jeder Rueckgabewert
13
+ * ist ein {@link ProseNumberV1}, also ein Objekt. Ein Prosa-Messwert kann
14
+ * dadurch an keiner Stelle stehen, an der `computeCognitive` oder
15
+ * `aggregate` eine Zahl erwarten — der Uebersetzer lehnt es ab, und
16
+ * `assertUntangleMetricNumber` lehnt es zur Laufzeit ab. Vorbild ist
17
+ * `ExternalNumberV1` (domain `types/extensions.ts`), das externe Befunde
18
+ * genauso von Untangle-Metriken fernhaelt.
19
+ *
20
+ * DIE FUENF MASSE, UND WARUM GENAU DIESE
21
+ *
22
+ * Jedes Mass muss ohne Modellannahme lesbar sein — sonst ist es eine
23
+ * erfundene Zahl mit Nachkommastellen. Was hier steht, laesst sich am
24
+ * Dokument nachzaehlen:
25
+ *
26
+ * 1. `sectionDepth` — tiefste benutzte Ueberschriftenebene.
27
+ * 2. `maxSectionLines` — laengster Abschnitt in Zeilen.
28
+ * 3. `linkDensity` — Verweise je 100 Inhaltszeilen.
29
+ * 4. `unresolvedLinkRatio` — Anteil toter relativer Dateiverweise, oder
30
+ * `null`, wenn niemand aufgeloest hat.
31
+ * 5. `repetitionRatio` — Anteil woertlich wiederholter Inhaltszeilen.
32
+ *
33
+ * Dazu der abgeleitete Ordnungsschluessel `structureLoad`: die Summe der
34
+ * Ueberschriftenebenen aller Abschnitte. Bewusst keine gewichtete Mischung
35
+ * ungleicher Groessen — das waere der eine „Quality Score", den vision.md
36
+ * §7.6 ablehnt —, sondern eine abzaehlbare Groesse: die Gesamtzahl der
37
+ * Gliederungsstufen, die das Dokument aufspannt. Ein Dokument mit 40
38
+ * Abschnitten auf Ebene 3 traegt mehr Gliederung als eines mit 40
39
+ * Abschnitten auf Ebene 1, und beide mehr als eines mit vier.
40
+ *
41
+ * DER AUFLOESER
42
+ *
43
+ * `unresolvedLinkRatio` ist der einzige Wert, der etwas ausserhalb des
44
+ * Dokuments braucht. Ohne `resolveLink` bleibt er `null` — ein
45
+ * Zustandsvermerk, kein Messwert von 0. vision.md §1d ist an genau dieser
46
+ * Stelle deutlich: „niemand hat gesucht" darf nie wie ein Befund ueber den
47
+ * Inhalt aussehen. Deshalb ist der Aufloeser ein Parameter und keine
48
+ * Dateizugriffsfunktion in diesem Paket: `@untangle-code/complexity` bleibt
49
+ * frei von I/O.
50
+ */
51
+ import type {
52
+ ProseLinkV1,
53
+ ProseMetricsV1,
54
+ ProseNumberV1,
55
+ ProseSectionMetricV1,
56
+ } from "@untangle-code/domain";
57
+ import {
58
+ PROSE_METRICS_VERSION,
59
+ classifyLinkTarget,
60
+ countContentLines,
61
+ findMarkdownLinks,
62
+ linkFilePath,
63
+ makeProseNumber,
64
+ normalizedContentLines,
65
+ outlineMarkdown,
66
+ } from "@untangle-code/domain";
67
+
68
+ /**
69
+ * Beantwortet, ob das Ziel eines relativen Dateiverweises existiert.
70
+ * `documentPath` ist der Pfad des VERWEISENDEN Dokuments, damit der
71
+ * Aufloeser relativ dazu suchen kann.
72
+ */
73
+ export type ProseLinkResolver = (documentPath: string, targetFilePath: string) => boolean;
74
+
75
+ export interface ProseMetricsOptions {
76
+ /**
77
+ * Ohne Aufloeser bleibt `unresolvedLinkRatio` `null`. Das ist Absicht:
78
+ * ein ungeprueftes 0 waere die Behauptung, alle Verweise seien heil.
79
+ */
80
+ resolveLink?: ProseLinkResolver;
81
+ }
82
+
83
+ /** Physische Zeilen ohne Zeilenende; ein abschliessendes `\r` faellt weg. */
84
+ function splitLines(content: string): string[] {
85
+ return content.split("\n").map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line));
86
+ }
87
+
88
+ /**
89
+ * Rundet auf eine Nachkommastelle. Verhaeltniswerte werden gespeichert und
90
+ * verglichen; ohne feste Rundung waeren zwei Laeufe ueber dieselbe Datei
91
+ * durch Gleitkommarauschen nicht bitgleich.
92
+ */
93
+ function round(value: number, digits: number): number {
94
+ const factor = 10 ** digits;
95
+ return Math.round(value * factor) / factor;
96
+ }
97
+
98
+ /**
99
+ * Prosa-Metriken eines Dokuments.
100
+ *
101
+ * `path` ist der "/"-normalisierte, repo-relative Pfad; er wird nur
102
+ * durchgereicht (an den Aufloeser und in das Ergebnis), nie geoeffnet.
103
+ */
104
+ export function computeProseMetrics(
105
+ path: string,
106
+ content: string,
107
+ options: ProseMetricsOptions = {},
108
+ ): ProseMetricsV1 {
109
+ const lines = splitLines(content);
110
+ const outline = outlineMarkdown(lines);
111
+
112
+ const sections: ProseSectionMetricV1[] = outline.map((section) => ({
113
+ breadcrumb: section.breadcrumb,
114
+ level: section.level,
115
+ startLine: section.startLine,
116
+ endLine: section.endLine,
117
+ lines: Math.max(0, section.endLine - section.startLine + 1),
118
+ }));
119
+
120
+ const contentLines = countContentLines(lines);
121
+ const rawLinks = findMarkdownLinks(lines);
122
+
123
+ const resolver = options.resolveLink;
124
+ const links: ProseLinkV1[] = rawLinks.map((link) => {
125
+ const kind = classifyLinkTarget(link.target);
126
+ let resolved: boolean | null = null;
127
+ if (kind === "relative_file" && resolver !== undefined) {
128
+ const target = linkFilePath(link.target);
129
+ // Ein Verweis, der nur auf einen Anker im selben Dokument zeigt, hat
130
+ // keinen Dateiteil; er ist nicht "tot", er ist keine Dateikante.
131
+ resolved = target === "" ? true : resolver(path, target);
132
+ }
133
+ return { target: link.target, text: link.text, line: link.line, kind, resolved };
134
+ });
135
+
136
+ const resolvable = links.filter((link) => link.resolved !== null);
137
+ const unresolved = resolvable.filter((link) => link.resolved === false);
138
+
139
+ const normalized = normalizedContentLines(lines);
140
+ const seen = new Set<string>();
141
+ let repeated = 0;
142
+ for (const line of normalized) {
143
+ if (seen.has(line)) repeated += 1;
144
+ else seen.add(line);
145
+ }
146
+
147
+ const depth = sections.reduce((max, section) => Math.max(max, section.level), 0);
148
+ const maxLines = sections.reduce((max, section) => Math.max(max, section.lines), 0);
149
+ const structureLoad = sections.reduce((sum, section) => sum + section.level, 0);
150
+
151
+ return {
152
+ schemaVersion: 1,
153
+ metricsVersion: PROSE_METRICS_VERSION,
154
+ path,
155
+ sectionDepth: makeProseNumber(depth, "levels"),
156
+ maxSectionLines: makeProseNumber(maxLines, "lines"),
157
+ linkDensity: makeProseNumber(
158
+ contentLines === 0 ? 0 : round((links.length / contentLines) * 100, 1),
159
+ "per_100_lines",
160
+ ),
161
+ unresolvedLinkRatio:
162
+ resolvable.length === 0
163
+ ? null
164
+ : makeProseNumber(round(unresolved.length / resolvable.length, 4), "ratio"),
165
+ repetitionRatio: makeProseNumber(
166
+ normalized.length === 0 ? 0 : round(repeated / normalized.length, 4),
167
+ "ratio",
168
+ ),
169
+ structureLoad: makeProseNumber(structureLoad, "count"),
170
+ sectionCount: makeProseNumber(sections.length, "count"),
171
+ contentLines: makeProseNumber(contentLines, "count"),
172
+ sections,
173
+ links,
174
+ };
175
+ }
176
+
177
+ /**
178
+ * Ordnet zwei Dokumente nach Strukturlast, absteigend, mit vollstaendig
179
+ * deterministischem Gleichstandsbruch (nie `localeCompare`).
180
+ *
181
+ * `compare` liefert eine Ordnung, keine Bewertung: das Dokument mit der
182
+ * hoechsten Strukturlast ist nicht „das schlechteste", sondern das mit der
183
+ * meisten Gliederung — die Deutung gehoert dem Leser, die Zahl dem Werkzeug.
184
+ */
185
+ export function compareProseByStructureLoad(a: ProseMetricsV1, b: ProseMetricsV1): number {
186
+ const byLoad = valueOf(b.structureLoad) - valueOf(a.structureLoad);
187
+ if (byLoad !== 0) return byLoad;
188
+ const byDepth = valueOf(b.sectionDepth) - valueOf(a.sectionDepth);
189
+ if (byDepth !== 0) return byDepth;
190
+ return a.path < b.path ? -1 : a.path > b.path ? 1 : 0;
191
+ }
192
+
193
+ /**
194
+ * Der Rohwert eines Prosa-Messwerts fuer die SORTIERUNG innerhalb dieser
195
+ * Familie. Bewusst paketlokal und nicht exportiert: eine allgemein
196
+ * verfuegbare `number`-Entpackung waere genau das Loch, das
197
+ * {@link ProseNumberV1} zumauert.
198
+ */
199
+ function valueOf(value: ProseNumberV1): number {
200
+ return value.value;
201
+ }