@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,105 @@
1
+ /**
2
+ * Aggregator (spec.md §16.10). Aggregates one metric's values across many
3
+ * scopes (a file's symbols, a repo's files, ...).
4
+ *
5
+ * Percentile method (Untangle decision, documented per the work order):
6
+ * linear interpolation between closest ranks, i.e. for a sorted array `s` of
7
+ * length `n` and percentile `p` in [0, 1], `index = p * (n - 1)`,
8
+ * `result = s[floor(index)] + (s[ceil(index)] - s[floor(index)]) * frac(index)`.
9
+ * This is the same convention as NumPy's default `linear` interpolation and
10
+ * Excel's `PERCENTILE.INC`. `median` uses the same formula at `p = 0.5`, so
11
+ * it agrees with `p90`/`p95` on the same interpolation rule rather than using
12
+ * a different odd/even-length special case.
13
+ *
14
+ * Deduplication: entries are deduplicated by `metricsScopeId` (first
15
+ * occurrence wins) before any statistic is computed, per spec.md §16.10
16
+ * ("Doppelte Retrieval-Fragmente MÜSSEN über metrics_scope_id dedupliziert
17
+ * werden").
18
+ */
19
+ import type { AggregationEntry, AggregationResult } from "./types.js";
20
+
21
+ function percentile(sorted: readonly number[], p: number): number {
22
+ if (sorted.length === 0) return 0;
23
+ if (sorted.length === 1) return sorted[0] as number;
24
+ const index = p * (sorted.length - 1);
25
+ const lower = Math.floor(index);
26
+ const upper = Math.ceil(index);
27
+ const lowerValue = sorted[lower] as number;
28
+ const upperValue = sorted[upper] as number;
29
+ if (lower === upper) return lowerValue;
30
+ const fraction = index - lower;
31
+ return lowerValue + (upperValue - lowerValue) * fraction;
32
+ }
33
+
34
+ function dedupeByScopeId(entries: readonly AggregationEntry[]): {
35
+ deduped: AggregationEntry[];
36
+ duplicatesDropped: number;
37
+ } {
38
+ const seen = new Set<string>();
39
+ const deduped: AggregationEntry[] = [];
40
+ let duplicatesDropped = 0;
41
+ for (const entry of entries) {
42
+ if (seen.has(entry.metricsScopeId)) {
43
+ duplicatesDropped++;
44
+ continue;
45
+ }
46
+ seen.add(entry.metricsScopeId);
47
+ deduped.push(entry);
48
+ }
49
+ return { deduped, duplicatesDropped };
50
+ }
51
+
52
+ /**
53
+ * @param entries Raw values, one per scope. Duplicate `metricsScopeId`s are
54
+ * dropped (first occurrence wins) before aggregating.
55
+ * @param thresholds Threshold values; `thresholdCounts` counts entries whose
56
+ * (deduplicated) value is strictly greater than each threshold, keyed by
57
+ * the threshold's own decimal string (e.g. `"10"`, `"15.5"`).
58
+ */
59
+ export function aggregate(
60
+ entries: readonly AggregationEntry[],
61
+ thresholds: readonly number[] = [],
62
+ ): AggregationResult {
63
+ const { deduped, duplicatesDropped } = dedupeByScopeId(entries);
64
+ const values = deduped.map((e) => e.value);
65
+ const sorted = [...values].sort((a, b) => a - b);
66
+
67
+ const count = sorted.length;
68
+ const sum = sorted.reduce((s, v) => s + v, 0);
69
+ const mean = count > 0 ? sum / count : 0;
70
+ const median = percentile(sorted, 0.5);
71
+ const max = count > 0 ? (sorted[count - 1] as number) : 0;
72
+ const p90 = percentile(sorted, 0.9);
73
+ const p95 = percentile(sorted, 0.95);
74
+
75
+ const weighted = deduped.filter((e) => e.sloc !== undefined);
76
+ let slocWeightedMean: number | undefined;
77
+ if (weighted.length > 0) {
78
+ const totalSloc = weighted.reduce((s, e) => s + (e.sloc as number), 0);
79
+ slocWeightedMean =
80
+ totalSloc > 0
81
+ ? weighted.reduce((s, e) => s + e.value * (e.sloc as number), 0) / totalSloc
82
+ : 0;
83
+ }
84
+
85
+ const thresholdCounts: Record<string, number> = {};
86
+ for (const threshold of thresholds) {
87
+ thresholdCounts[String(threshold)] = values.filter((v) => v > threshold).length;
88
+ }
89
+
90
+ const result: AggregationResult = {
91
+ count,
92
+ sum,
93
+ mean,
94
+ median,
95
+ max,
96
+ p90,
97
+ p95,
98
+ thresholdCounts,
99
+ duplicatesDropped,
100
+ };
101
+ if (slocWeightedMean !== undefined) {
102
+ result.slocWeightedMean = slocWeightedMean;
103
+ }
104
+ return result;
105
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Folds calculator outputs into the domain-stored `ComplexitySnapshotV1`
3
+ * shape (spec.md §16, architecture.md §10.5). Pure function of its inputs —
4
+ * `measuredAt`/`snapshotId` etc. are supplied by the caller (who owns the
5
+ * `Clock`/`Random`/identity ports); this package never calls `Date.now()` or
6
+ * generates ids itself.
7
+ *
8
+ * Mapping decisions (README "Entscheidungen" #2):
9
+ * - `cyclomatic.contributors` takes the calculator's superset contribution
10
+ * list as-is (each `CyclomaticContribution` is structurally a
11
+ * `MetricContribution` plus the two membership flags, which are dropped —
12
+ * TypeScript's structural typing lets extra fields pass through, but they
13
+ * are stripped explicitly here so the stored payload never leaks
14
+ * calculator-internal fields).
15
+ * - `cognitive.contributors` stores the calculator's
16
+ * `CognitiveContributionV1[]` UNCHANGED: spec.md §16.4 requires every
17
+ * persisted contribution to carry source position, kind, base and nesting
18
+ * share as structured fields ("jeder Beitrag MUSS Quellposition, Art,
19
+ * Basis und Nesting-Anteil enthalten"), so `ComplexitySnapshotV1` keeps
20
+ * the full shape instead of flattening `base`/`nestingPenalty` into a
21
+ * free-text note.
22
+ * - `maintainabilityFormula` records the exact formula string the
23
+ * maintainability calculator used (spec.md §16.9: "Untangle MUSS die
24
+ * verwendete Formel explizit ausgeben").
25
+ */
26
+ import type {
27
+ ComplexityScope,
28
+ ComplexitySnapshotV1,
29
+ MetricContribution,
30
+ ParseQuality,
31
+ } from "@untangle-code/domain";
32
+
33
+ import type { CognitiveResult, CyclomaticResult, MaintainabilityResult } from "./types.js";
34
+
35
+ export interface SnapshotContext {
36
+ snapshotId: string;
37
+ scope: ComplexityScope;
38
+ scopeId: string;
39
+ repoId: string;
40
+ refId: string;
41
+ commitSha?: string;
42
+ metricsVersion: string;
43
+ /** ISO-8601, obtained by the caller via the `Clock` port. */
44
+ measuredAt: string;
45
+ parseQuality: ParseQuality;
46
+ }
47
+
48
+ export interface SnapshotParts {
49
+ cyclomatic: CyclomaticResult;
50
+ cognitive: CognitiveResult;
51
+ structural: Record<string, number>;
52
+ textual: Record<string, number>;
53
+ syntactic: Record<string, number>;
54
+ halstead: Record<string, number>;
55
+ maintainability: MaintainabilityResult;
56
+ }
57
+
58
+ function toMetricContribution(
59
+ line: number,
60
+ kind: string,
61
+ value: number,
62
+ note?: string,
63
+ ): MetricContribution {
64
+ const contribution: MetricContribution = { line, kind, value };
65
+ if (note !== undefined) contribution.note = note;
66
+ return contribution;
67
+ }
68
+
69
+ export function assembleComplexitySnapshot(
70
+ context: SnapshotContext,
71
+ parts: SnapshotParts,
72
+ ): ComplexitySnapshotV1 {
73
+ const snapshot: ComplexitySnapshotV1 = {
74
+ snapshotId: context.snapshotId,
75
+ scope: context.scope,
76
+ scopeId: context.scopeId,
77
+ repoId: context.repoId,
78
+ refId: context.refId,
79
+ metricsVersion: context.metricsVersion,
80
+ measuredAt: context.measuredAt,
81
+ parseQuality: context.parseQuality,
82
+ cyclomatic: {
83
+ classic: parts.cyclomatic.classic,
84
+ extended: parts.cyclomatic.extended,
85
+ contributors: parts.cyclomatic.contributions.map((c) =>
86
+ toMetricContribution(c.line, c.kind, c.value, c.note),
87
+ ),
88
+ },
89
+ cognitive: {
90
+ value: parts.cognitive.value,
91
+ algorithm: parts.cognitive.algorithm as ComplexitySnapshotV1["cognitive"]["algorithm"],
92
+ // Stored structurally, spec.md §16.4 (base/nestingPenalty/total are
93
+ // required fields of every persisted contribution). Copied so the
94
+ // snapshot never aliases the calculator's arrays.
95
+ contributors: parts.cognitive.contributions.map((c) => ({ ...c })),
96
+ },
97
+ structural: parts.structural,
98
+ textual: parts.textual,
99
+ syntactic: parts.syntactic,
100
+ halstead: parts.halstead,
101
+ maintainabilityIndex: parts.maintainability.value,
102
+ // spec.md §16.9: the formula actually used MUST be output explicitly.
103
+ maintainabilityFormula: parts.maintainability.formula,
104
+ };
105
+ if (context.commitSha !== undefined) {
106
+ snapshot.commitSha = context.commitSha;
107
+ }
108
+ return snapshot;
109
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * CognitiveCalculator, algorithm id `untangle-cognitive-v1` (spec.md §16.4,
3
+ * architecture.md §15.3).
4
+ *
5
+ * Traverses a `ComplexityIRNode` subtree (rooted at a `"symbol"` node) with
6
+ * an explicit depth-tracking stack, exactly as architecture.md §15.3
7
+ * prescribes ("current symbol, nesting depth, logical sequence state,
8
+ * recursion state"). Every contribution is `base + nestingPenalty = total`,
9
+ * and `value` is defined as `sum(contributions[].total)` — so the
10
+ * "sum of contributions equals the total" invariant (required by the work
11
+ * order) holds *by construction*; `assertCognitiveInvariant` is the
12
+ * belt-and-suspenders runtime guard the work order also asks for, exercised
13
+ * both indirectly (every golden test) and directly (a deliberately corrupted
14
+ * contribution list in cognitive.test.ts).
15
+ *
16
+ * REQUIRED INPUT CONTRACT (Untangle decision — architecture.md §15.3 leaves
17
+ * the exact IR shape for if/else-if/else chains unspecified; see README
18
+ * "Entscheidungen" #2 for the full rationale): a language adapter/IR builder
19
+ * feeding this calculator MUST represent an `if` / `else if` / `else` chain
20
+ * as a flat run of *sibling* IR nodes (one per branch) under the same
21
+ * parent, each carrying its own consequent block as `children` — never as
22
+ * the raw AST's nested "alternate" shape. This is what makes "`else if` gets
23
+ * no full new nesting increment" fall out of the traversal for free: siblings
24
+ * share the same ambient depth, so an `else if` scores exactly what its
25
+ * originating `if` scores, never more.
26
+ *
27
+ * Nesting rules (this calculator's own, documented decisions):
28
+ * - A node contributes when it carries a `decision` fact, or is itself kind
29
+ * `"logical_sequence"` (a pre-grouped `&&`/`||` run) or `"recursion_marker"`.
30
+ * - `base` is always 1. `nestingPenalty` is the ambient depth *before*
31
+ * entering the node (0 for a top-level construct), except recursion, whose
32
+ * penalty is always 0 (recursion is flagged flatly, not nesting-scaled —
33
+ * spec.md §16.4 only says it "KANN einen Beitrag erzeugen").
34
+ * - A node with `nestingBoundary: true` increments depth by exactly 1 for
35
+ * its own children (this covers `if`/`else_if`/loops/`catch`/`ternary`/
36
+ * `switch`/`case`/lambdas alike — lambdas use this same mechanism for the
37
+ * "documented nesting context" spec.md §16.4 asks for). `logical_sequence`
38
+ * and `recursion_marker` nodes are not nesting boundaries.
39
+ * - Decisions that share a `logicalGroupId` (an independent `&&`/`||` group
40
+ * spanning multiple facts) contribute once, at the first occurrence
41
+ * encountered during traversal — mirrors the cyclomatic-extended grouping
42
+ * rule in cyclomatic.ts.
43
+ */
44
+ import type { CognitiveContributionV1, ComplexityIRNode } from "@untangle-code/domain";
45
+
46
+ import type { CognitiveResult } from "./types.js";
47
+
48
+ interface WalkState {
49
+ depth: number;
50
+ }
51
+
52
+ function contributionKind(node: ComplexityIRNode): string {
53
+ if (node.decision) return node.decision.kind;
54
+ if (node.kind === "recursion_marker") return "recursion";
55
+ return node.kind;
56
+ }
57
+
58
+ function isContributing(node: ComplexityIRNode): boolean {
59
+ return (
60
+ node.decision != null || node.kind === "logical_sequence" || node.kind === "recursion_marker"
61
+ );
62
+ }
63
+
64
+ function walk(
65
+ node: ComplexityIRNode,
66
+ state: WalkState,
67
+ contributions: CognitiveContributionV1[],
68
+ seenGroups: Set<string>,
69
+ ): void {
70
+ if (isContributing(node)) {
71
+ const groupId = node.decision?.logicalGroupId;
72
+ const isDuplicateGroupMember = groupId != null && seenGroups.has(groupId);
73
+ if (groupId != null) {
74
+ seenGroups.add(groupId);
75
+ }
76
+ if (!isDuplicateGroupMember) {
77
+ const base = 1;
78
+ const nestingPenalty = node.kind === "recursion_marker" ? 0 : state.depth;
79
+ contributions.push({
80
+ line: node.range.startLine,
81
+ kind: contributionKind(node),
82
+ base,
83
+ nestingPenalty,
84
+ total: base + nestingPenalty,
85
+ });
86
+ }
87
+ }
88
+
89
+ const childDepth = node.nestingBoundary === true ? state.depth + 1 : state.depth;
90
+ for (const child of node.children) {
91
+ walk(child, { depth: childDepth }, contributions, seenGroups);
92
+ }
93
+ }
94
+
95
+ /**
96
+ * @param symbolRoot The `ComplexityIRNode` (kind `"symbol"`) rooting the
97
+ * symbol's own subtree; traversal starts at depth 0.
98
+ * @param algorithm Caller-supplied algorithm id — pass domain's
99
+ * `COGNITIVE_ALGORITHM_ID` in production; kept as a plain parameter (not
100
+ * hardcoded) so this stays a pure function of its inputs.
101
+ */
102
+ export function computeCognitive(symbolRoot: ComplexityIRNode, algorithm: string): CognitiveResult {
103
+ const contributions: CognitiveContributionV1[] = [];
104
+ walk(symbolRoot, { depth: 0 }, contributions, new Set<string>());
105
+ const value = contributions.reduce((sum, c) => sum + c.total, 0);
106
+ assertCognitiveInvariant(contributions, value);
107
+ return { value, algorithm, contributions };
108
+ }
109
+
110
+ /**
111
+ * Throws when `sum(contributions[].total) !== total`. `computeCognitive`
112
+ * cannot actually violate this (value is defined as that very sum), but the
113
+ * work order asks for an explicit, independently-testable guard — this is
114
+ * also the function a caller re-assembling a snapshot from stored
115
+ * contributions should re-run to catch data corruption.
116
+ */
117
+ export function assertCognitiveInvariant(
118
+ contributions: readonly CognitiveContributionV1[],
119
+ total: number,
120
+ ): void {
121
+ const sum = contributions.reduce((s, c) => s + c.total, 0);
122
+ if (sum !== total) {
123
+ throw new Error(
124
+ `cognitive complexity invariant violated: sum(contributions.total)=${sum} does not equal total=${total}`,
125
+ );
126
+ }
127
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * CyclomaticCalculator (spec.md §16.2 "Classic", §16.3 "Extended").
3
+ *
4
+ * Classic: `M = 1 + decision_points`, one point per `DecisionFact` whose
5
+ * `countsForClassic` flag is set — the language adapter, not this
6
+ * calculator, decides which decision kinds are classic decision points
7
+ * (spec.md §16.2 lists if/else-if/loops/catch/ternary/case/guards).
8
+ *
9
+ * Extended: `M = 1 + decision_points`, one point per *independent* decision
10
+ * group whose `countsForExtended` flag is set. "Independent group" is where
11
+ * this calculator's own logic lives: `DecisionFact.logicalGroupId` marks
12
+ * facts that belong to the same short-circuit/pattern-alternative group
13
+ * (spec.md §16.3); this calculator counts each group once (first occurrence
14
+ * by source position wins), not once per operator/alternative.
15
+ */
16
+ import type { ComplexityFacts, DecisionFact } from "@untangle-code/domain";
17
+
18
+ import type { CyclomaticContribution, CyclomaticResult } from "./types.js";
19
+
20
+ function sortByPosition(decisions: readonly DecisionFact[]): DecisionFact[] {
21
+ return [...decisions].sort((a, b) => a.range.startByte - b.range.startByte);
22
+ }
23
+
24
+ /**
25
+ * @param facts Facts already narrowed to the target scope (see `selectSymbolFacts`
26
+ * for `scope: "symbol"`; pass a file's unfiltered facts for `scope: "file"`).
27
+ * @param baseLine Source line the synthetic "base" contribution (the `+1` in
28
+ * `M = 1 + decision_points`) is attributed to — typically the scope's own
29
+ * first line.
30
+ * @param algorithmVersion Caller-supplied algorithm/metrics version stamp
31
+ * (e.g. domain's `METRICS_VERSION`); this calculator is a pure function of
32
+ * its inputs and never invents its own version string.
33
+ */
34
+ export function computeCyclomatic(
35
+ facts: ComplexityFacts,
36
+ baseLine: number,
37
+ algorithmVersion: string,
38
+ ): CyclomaticResult {
39
+ const contributions: CyclomaticContribution[] = [
40
+ {
41
+ line: baseLine,
42
+ kind: "base",
43
+ value: 1,
44
+ countsForClassic: true,
45
+ countsForExtended: true,
46
+ note: "M = 1 + decision points (spec.md §16.2)",
47
+ },
48
+ ];
49
+
50
+ const seenGroups = new Set<string>();
51
+ for (const decision of sortByPosition(facts.decisions)) {
52
+ if (!decision.countsForClassic && !decision.countsForExtended) {
53
+ continue;
54
+ }
55
+ if (decision.logicalGroupId != null) {
56
+ if (seenGroups.has(decision.logicalGroupId)) {
57
+ continue;
58
+ }
59
+ seenGroups.add(decision.logicalGroupId);
60
+ }
61
+ contributions.push({
62
+ line: decision.range.startLine,
63
+ kind: decision.kind,
64
+ value: 1,
65
+ countsForClassic: decision.countsForClassic,
66
+ countsForExtended: decision.countsForExtended,
67
+ });
68
+ }
69
+
70
+ const classic = contributions
71
+ .filter((c) => c.countsForClassic)
72
+ .reduce((sum, c) => sum + c.value, 0);
73
+ const extended = contributions
74
+ .filter((c) => c.countsForExtended)
75
+ .reduce((sum, c) => sum + c.value, 0);
76
+
77
+ return { classic, extended, contributions, algorithmVersion };
78
+ }
package/src/entropy.ts ADDED
@@ -0,0 +1,30 @@
1
+ /** Shannon entropy in bits over a frequency distribution (spec.md §16.6, §16.8). */
2
+ export function shannonEntropy(frequencies: Iterable<number>): number {
3
+ let total = 0;
4
+ const values: number[] = [];
5
+ for (const f of frequencies) {
6
+ if (f <= 0) continue;
7
+ values.push(f);
8
+ total += f;
9
+ }
10
+ if (total === 0) return 0;
11
+ let entropy = 0;
12
+ for (const f of values) {
13
+ const p = f / total;
14
+ entropy -= p * Math.log2(p);
15
+ }
16
+ return entropy;
17
+ }
18
+
19
+ /** Builds a frequency map by counting occurrences of `key(item)` over `items`. */
20
+ export function frequenciesOf<T>(
21
+ items: readonly T[],
22
+ key: (item: T) => string,
23
+ ): Map<string, number> {
24
+ const counts = new Map<string, number>();
25
+ for (const item of items) {
26
+ const k = key(item);
27
+ counts.set(k, (counts.get(k) ?? 0) + 1);
28
+ }
29
+ return counts;
30
+ }
package/src/explain.ts ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Explain path (work order: "einen Explain-Pfad"). Produces a ranked,
3
+ * human-readable breakdown of a symbol's cyclomatic and cognitive
4
+ * contributions, largest first.
5
+ *
6
+ * Operates on the *stored* `ComplexitySnapshotV1` shape
7
+ * (`cyclomatic.contributors` is `MetricContribution[]`;
8
+ * `cognitive.contributors` is the full `CognitiveContributionV1[]` the
9
+ * snapshot persists per spec.md §16.4), so it works directly on whatever
10
+ * `assembleComplexitySnapshot` produced or on any snapshot loaded back from
11
+ * storage.
12
+ */
13
+ import type {
14
+ CognitiveContributionV1,
15
+ ComplexitySnapshotV1,
16
+ MetricContribution,
17
+ } from "@untangle-code/domain";
18
+
19
+ import type { ExplainEntry } from "./types.js";
20
+
21
+ function toCyclomaticEntries(
22
+ contributions: readonly MetricContribution[],
23
+ ): Array<Omit<ExplainEntry, "rank">> {
24
+ return contributions
25
+ .filter((c) => c.kind !== "base")
26
+ .map((c) => ({
27
+ metric: "cyclomatic" as const,
28
+ kind: c.kind,
29
+ line: c.line,
30
+ value: c.value,
31
+ text: `line ${c.line}: ${c.kind} contributes ${c.value} to cyclomatic complexity${
32
+ c.note ? ` (${c.note})` : ""
33
+ }`,
34
+ }));
35
+ }
36
+
37
+ function toCognitiveEntries(
38
+ contributions: readonly CognitiveContributionV1[],
39
+ ): Array<Omit<ExplainEntry, "rank">> {
40
+ return contributions.map((c) => ({
41
+ metric: "cognitive" as const,
42
+ kind: c.kind,
43
+ line: c.line,
44
+ value: c.total,
45
+ text: `line ${c.line}: ${c.kind} contributes ${c.total} to cognitive complexity (base=${c.base} nestingPenalty=${c.nestingPenalty})${
46
+ c.note ? ` (${c.note})` : ""
47
+ }`,
48
+ }));
49
+ }
50
+
51
+ /**
52
+ * @param snapshot The symbol's complexity snapshot.
53
+ * @param top Maximum number of entries to return (default 5), largest
54
+ * contribution first; ties keep cyclomatic before cognitive, then source
55
+ * order.
56
+ */
57
+ export function explainSymbol(snapshot: ComplexitySnapshotV1, top = 5): ExplainEntry[] {
58
+ const combined = [
59
+ ...toCyclomaticEntries(snapshot.cyclomatic.contributors),
60
+ ...toCognitiveEntries(snapshot.cognitive.contributors),
61
+ ];
62
+ combined.sort((a, b) => b.value - a.value);
63
+ return combined.slice(0, top).map((entry, index) => ({ ...entry, rank: index + 1 }));
64
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * HalsteadCalculator (spec.md §16.7). Formulas exactly as specified,
3
+ * including the `max(n, 1)` / `max(n2, 1)` guards against log(0)/div-by-0 on
4
+ * an empty scope.
5
+ *
6
+ * Accepts the domain `HalsteadTokens` shape ({operators, operands}); a full
7
+ * `ComplexityFacts` is also accepted as-is since it structurally satisfies
8
+ * that shape.
9
+ */
10
+ import type { HalsteadTokens } from "@untangle-code/domain";
11
+
12
+ import type { HalsteadResult } from "./types.js";
13
+
14
+ function log2(x: number): number {
15
+ return Math.log(x) / Math.LN2;
16
+ }
17
+
18
+ export interface HalsteadOptions {
19
+ /**
20
+ * Also compute Time (`effort / 18`, Halstead's Stroud-number heuristic) and
21
+ * Estimated Bugs (`volume / 3000`) — spec.md §16.7 requires both to be
22
+ * explicitly labeled as estimates when present, hence the separate
23
+ * `estimates` field on `HalsteadResult` rather than folding them into
24
+ * `metrics`.
25
+ */
26
+ includeEstimates?: boolean;
27
+ }
28
+
29
+ export function computeHalstead(
30
+ tokens: HalsteadTokens,
31
+ options: HalsteadOptions = {},
32
+ algorithmVersion = "untangle-halstead-v1",
33
+ ): HalsteadResult {
34
+ const N1 = tokens.operators.length;
35
+ const N2 = tokens.operands.length;
36
+ const n1 = new Set(tokens.operators.map((t) => t.text)).size;
37
+ const n2 = new Set(tokens.operands.map((t) => t.text)).size;
38
+ const n = n1 + n2;
39
+ const N = N1 + N2;
40
+ const volume = N * log2(Math.max(n, 1));
41
+ const difficulty = (n1 / 2) * (N2 / Math.max(n2, 1));
42
+ const effort = difficulty * volume;
43
+
44
+ const metrics: Record<string, number> = { n1, n2, N1, N2, n, N, volume, difficulty, effort };
45
+
46
+ const result: HalsteadResult = { metrics, algorithmVersion };
47
+ if (options.includeEstimates) {
48
+ result.estimates = {
49
+ timeSeconds: effort / 18,
50
+ estimatedBugs: volume / 3000,
51
+ };
52
+ }
53
+ return result;
54
+ }
package/src/index.ts ADDED
@@ -0,0 +1,85 @@
1
+ /**
2
+ * @untangle-code/complexity — the complexity engine (spec.md §16,
3
+ * architecture.md §6.7, §15). Pure, deterministic calculators over
4
+ * `ComplexityFacts`/`ComplexityIRNode` from `@untangle-code/domain`; no I/O,
5
+ * no parser-registry, no embeddings. This module is the ONLY public export
6
+ * of the package.
7
+ */
8
+
9
+ // Scoping/text utilities shared across calculators.
10
+ export {
11
+ containsRange,
12
+ rangeKey,
13
+ selectSymbolFacts,
14
+ sliceByRange,
15
+ splitLines,
16
+ strictlyContainsRange,
17
+ } from "./scope.js";
18
+ export { classifyLines, scopeLines } from "./text-lines.js";
19
+ export { shannonEntropy, frequenciesOf } from "./entropy.js";
20
+ export { classifyNodeKind, isAsyncNodeKind, isExceptionNodeKind } from "./node-kind-heuristics.js";
21
+ export type { NodeKindClass } from "./node-kind-heuristics.js";
22
+
23
+ // IR producer: ComplexityFacts -> ComplexityIRNode bridge (architecture.md §6.7).
24
+ export { buildSymbolIR } from "./ir/build-ir.js";
25
+ export type { BuildSymbolIROptions } from "./ir/build-ir.js";
26
+
27
+ // Calculators (architecture.md §15.2).
28
+ export { computeCyclomatic } from "./cyclomatic.js";
29
+ export { computeCognitive, assertCognitiveInvariant } from "./cognitive.js";
30
+ export { computeStructural } from "./structural.js";
31
+ export { computeTextual } from "./textual.js";
32
+ export { computeSyntactic } from "./syntactic.js";
33
+ export { computeHalstead } from "./halstead.js";
34
+ export type { HalsteadOptions } from "./halstead.js";
35
+ export { computeMaintainability, MAINTAINABILITY_FORMULA } from "./maintainability.js";
36
+ export type { MaintainabilityInput } from "./maintainability.js";
37
+ export { aggregate } from "./aggregator.js";
38
+ // Aggregation ueber den Scope-Baum, ohne Doppelzaehlung (WP-82).
39
+ export {
40
+ aggregateScopeTree,
41
+ assertScopeTreeInvariants,
42
+ checkAllScopeCoverage,
43
+ checkScopeCoverage,
44
+ checkScopeTreeInvariants,
45
+ checkSelectionCoverage,
46
+ selectionForReading,
47
+ selectScopeEntries,
48
+ SCOPE_INVARIANT_CODES,
49
+ SCOPE_SUM_READINGS,
50
+ } from "./scope-aggregate.js";
51
+ export type {
52
+ ScopeInvariantCode,
53
+ ScopeInvariantViolationV1,
54
+ ScopeMetricNodeV1,
55
+ ScopeSelectionKind,
56
+ ScopeSelectionV1,
57
+ ScopeSumReading,
58
+ ScopeTreeAggregateV1,
59
+ ScopeTreeOptions,
60
+ } from "./scope-aggregate.js";
61
+ // Eindeutige Scope-Ids als Voraussetzung der Dedupe ueber metricsScopeId (WP-82).
62
+ export { withUniqueScopeIds } from "./scope-identity.js";
63
+ export type { ScopeIdCollisionV1, UniqueScopeIdsResultV1 } from "./scope-identity.js";
64
+ // Prosa-Metriken als EIGENE Familie (vision.md §1c, WP-217). Ihre Werte sind
65
+ // `ProseNumberV1`-Objekte und koennen deshalb strukturell nie in eine
66
+ // Code-Metrik-Summe geraten.
67
+ export { computeProseMetrics, compareProseByStructureLoad } from "./prose/metrics.js";
68
+ export type { ProseLinkResolver, ProseMetricsOptions } from "./prose/metrics.js";
69
+ export { explainSymbol } from "./explain.js";
70
+ export { assembleComplexitySnapshot } from "./assemble.js";
71
+ export type { SnapshotContext, SnapshotParts } from "./assemble.js";
72
+
73
+ // Local result contracts (not domain types — see types.ts docs).
74
+ export type {
75
+ AggregationEntry,
76
+ AggregationResult,
77
+ CognitiveResult,
78
+ CyclomaticContribution,
79
+ CyclomaticResult,
80
+ ExplainEntry,
81
+ HalsteadResult,
82
+ MaintainabilityResult,
83
+ MetricsResult,
84
+ StructuralExternalInput,
85
+ } from "./types.js";