@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,90 @@
1
+ /**
2
+ * Shared, pure line/comment classification used by both StructuralCalculator
3
+ * and TextualCalculator (spec.md §16.5, §16.6). Not a calculator itself —
4
+ * just the byte/line bookkeeping both of them need, factored out so neither
5
+ * calculator duplicates it.
6
+ */
7
+ import type { ComplexityFacts, SourceRange } from "@untangle-code/domain";
8
+
9
+ import { sliceByRange, splitLines } from "./scope.js";
10
+
11
+ /** Leading markers recognized as "doc comment" (Untangle decision — see README "Entscheidungen" #3). */
12
+ const DOC_COMMENT_MARKERS = ["/**", "///", '"""', "'''", "##!", "//!", "/// "];
13
+
14
+ function isDocCommentText(commentText: string): boolean {
15
+ const trimmed = commentText.trimStart();
16
+ return DOC_COMMENT_MARKERS.some((marker) => trimmed.startsWith(marker));
17
+ }
18
+
19
+ export interface LineClassification {
20
+ /** Physical line count of the scope (`endLine - startLine + 1`, or 0 for an empty range). */
21
+ physicalLoc: number;
22
+ /** Set of absolute physical line numbers touched by any comment range. */
23
+ commentLines: Set<number>;
24
+ /** Subset of `commentLines` whose owning comment looks like a doc comment. */
25
+ docLines: Set<number>;
26
+ /** Absolute line numbers that are blank (whitespace-only) and not comment lines. */
27
+ blankLines: Set<number>;
28
+ }
29
+
30
+ /**
31
+ * Classifies every physical line of `scopeRange` within `sourceText` as
32
+ * blank, comment, and/or doc-comment. `comments` is expected already
33
+ * narrowed to the scope (e.g. via `selectSymbolFacts`).
34
+ *
35
+ * Documented simplification (README "Entscheidungen" #3): a line touched by
36
+ * *any part* of a comment range counts as a comment line in full, even for a
37
+ * trailing `code(); // comment` line — `ComplexityFacts` gives byte ranges,
38
+ * not a column split between code and comment, so a line can't be split
39
+ * further without raw-text heuristics beyond this package's scope.
40
+ */
41
+ export function classifyLines(
42
+ sourceText: string,
43
+ scopeRange: SourceRange,
44
+ comments: readonly SourceRange[],
45
+ ): LineClassification {
46
+ const scopeText = sliceByRange(sourceText, scopeRange);
47
+ const lines = splitLines(scopeText);
48
+ // A trailing newline at the end of the range produces one extra empty
49
+ // element from split("\n"); drop it so physicalLoc reflects real lines.
50
+ const physicalLines =
51
+ lines.length > 1 && lines[lines.length - 1] === "" && scopeText.endsWith("\n")
52
+ ? lines.slice(0, -1)
53
+ : lines;
54
+ const physicalLoc = physicalLines.length;
55
+
56
+ const commentLines = new Set<number>();
57
+ const docLines = new Set<number>();
58
+ for (const range of comments) {
59
+ const isDoc = isDocCommentText(sliceByRange(sourceText, range));
60
+ for (let line = range.startLine; line <= range.endLine; line++) {
61
+ commentLines.add(line);
62
+ if (isDoc) docLines.add(line);
63
+ }
64
+ }
65
+
66
+ const blankLines = new Set<number>();
67
+ for (let i = 0; i < physicalLines.length; i++) {
68
+ const lineNo = scopeRange.startLine + i;
69
+ const text = physicalLines[i] ?? "";
70
+ if (text.trim().length === 0 && !commentLines.has(lineNo)) {
71
+ blankLines.add(lineNo);
72
+ }
73
+ }
74
+
75
+ return { physicalLoc, commentLines, docLines, blankLines };
76
+ }
77
+
78
+ /** Re-exported for calculators that need to iterate raw scope lines directly. */
79
+ export function scopeLines(sourceText: string, scopeRange: SourceRange): string[] {
80
+ const scopeText = sliceByRange(sourceText, scopeRange);
81
+ const lines = splitLines(scopeText);
82
+ return lines.length > 1 && lines[lines.length - 1] === "" && scopeText.endsWith("\n")
83
+ ? lines.slice(0, -1)
84
+ : lines;
85
+ }
86
+
87
+ /** Convenience: comments already narrowed to one scope (see `selectSymbolFacts`). */
88
+ export function commentsOf(facts: ComplexityFacts): readonly SourceRange[] {
89
+ return facts.comments;
90
+ }
package/src/textual.ts ADDED
@@ -0,0 +1,119 @@
1
+ /**
2
+ * TextualCalculator (spec.md §16.6).
3
+ *
4
+ * `identifier_entropy`: Shannon entropy in bits computed over the multiset of
5
+ * *characters* across all identifier occurrences in scope (not per-token
6
+ * entropy over distinct identifier names) — Untangle decision, documented in
7
+ * README "Entscheidungen" #6: character-level entropy reacts to naming style
8
+ * (e.g. heavy abbreviation vs. verbose camelCase) rather than being dominated
9
+ * by how many times short common names like `i`/`x` repeat.
10
+ *
11
+ * `magic_literal_count`: counts `number`/`string` literals only (booleans,
12
+ * `null`, regexes, templates are never "magic" by definition here), excluding
13
+ * a small, documented allowlist (see `EXCLUDED_NUMERIC_LITERALS`/
14
+ * `EXCLUDED_STRING_LITERALS` below).
15
+ *
16
+ * `todo_count`/`fixme_count`/`hack_count`: case-insensitive whole-word
17
+ * occurrence counts of `TODO`/`FIXME`/`HACK` inside comment text, sliced from
18
+ * `sourceText` via each comment's byte range.
19
+ */
20
+ import type { ComplexityFacts, SourceRange } from "@untangle-code/domain";
21
+
22
+ import { frequenciesOf, shannonEntropy } from "./entropy.js";
23
+ import { sliceByRange } from "./scope.js";
24
+ import { classifyLines, scopeLines } from "./text-lines.js";
25
+ import type { MetricsResult } from "./types.js";
26
+
27
+ const EXCLUDED_NUMERIC_LITERALS = new Set(["0", "1", "-1"]);
28
+ const EXCLUDED_STRING_LITERALS = new Set(['""', "''", "``", ""]);
29
+
30
+ function isMagicLiteral(literalKind: string, raw: string): boolean {
31
+ const trimmed = raw.trim();
32
+ if (literalKind === "number") return !EXCLUDED_NUMERIC_LITERALS.has(trimmed);
33
+ if (literalKind === "string") return !EXCLUDED_STRING_LITERALS.has(trimmed);
34
+ return false;
35
+ }
36
+
37
+ function countWholeWordOccurrences(text: string, word: string): number {
38
+ const pattern = new RegExp(`\\b${word}\\b`, "gi");
39
+ return text.match(pattern)?.length ?? 0;
40
+ }
41
+
42
+ function codepointLength(text: string): number {
43
+ return Array.from(text).length;
44
+ }
45
+
46
+ /**
47
+ * @param facts Facts already narrowed to the target scope.
48
+ * @param sourceText Full original file content (UTF-8).
49
+ * @param scopeRange The scope's own range.
50
+ */
51
+ export function computeTextual(
52
+ facts: ComplexityFacts,
53
+ sourceText: string,
54
+ scopeRange: SourceRange,
55
+ algorithmVersion = "untangle-textual-v1",
56
+ ): MetricsResult {
57
+ const scopeText = sliceByRange(sourceText, scopeRange);
58
+ const { physicalLoc, commentLines, docLines } = classifyLines(
59
+ sourceText,
60
+ scopeRange,
61
+ facts.comments,
62
+ );
63
+ const lines = scopeLines(sourceText, scopeRange);
64
+
65
+ const lineLengths = lines.map(codepointLength);
66
+ const avgLineLength =
67
+ lineLengths.length > 0 ? lineLengths.reduce((s, v) => s + v, 0) / lineLengths.length : 0;
68
+ const maxLineLength = lineLengths.length > 0 ? Math.max(...lineLengths) : 0;
69
+
70
+ const identifierNames = facts.identifiers.map((id) => id.name);
71
+ const uniqueIdentifiers = new Set(identifierNames);
72
+ const identifierLengths = identifierNames.map(codepointLength);
73
+ const avgIdentifierLength =
74
+ identifierLengths.length > 0
75
+ ? identifierLengths.reduce((s, v) => s + v, 0) / identifierLengths.length
76
+ : 0;
77
+ const identifierCharFrequencies = frequenciesOf(identifierNames.join("").split(""), (ch) => ch);
78
+
79
+ const magicLiteralCount = facts.literals.filter((lit) =>
80
+ isMagicLiteral(lit.literalKind, lit.raw),
81
+ ).length;
82
+
83
+ let todoCount = 0;
84
+ let fixmeCount = 0;
85
+ let hackCount = 0;
86
+ for (const commentRange of facts.comments) {
87
+ const text = sliceByRange(sourceText, commentRange);
88
+ todoCount += countWholeWordOccurrences(text, "todo");
89
+ fixmeCount += countWholeWordOccurrences(text, "fixme");
90
+ hackCount += countWholeWordOccurrences(text, "hack");
91
+ }
92
+
93
+ const operatorTexts = facts.operators.map((t) => t.text);
94
+ const operandTexts = facts.operands.map((t) => t.text);
95
+
96
+ const metrics: Record<string, number> = {
97
+ character_count: codepointLength(scopeText),
98
+ byte_count: scopeRange.endByte - scopeRange.startByte,
99
+ token_count: operatorTexts.length + operandTexts.length,
100
+ avg_line_length: avgLineLength,
101
+ max_line_length: maxLineLength,
102
+ comment_ratio: physicalLoc > 0 ? commentLines.size / physicalLoc : 0,
103
+ doc_ratio: physicalLoc > 0 ? docLines.size / physicalLoc : 0,
104
+ identifier_count: identifierNames.length,
105
+ unique_identifier_count: uniqueIdentifiers.size,
106
+ avg_identifier_length: avgIdentifierLength,
107
+ identifier_entropy: shannonEntropy(identifierCharFrequencies.values()),
108
+ magic_literal_count: magicLiteralCount,
109
+ todo_count: todoCount,
110
+ fixme_count: fixmeCount,
111
+ hack_count: hackCount,
112
+ operator_count: operatorTexts.length,
113
+ operand_count: operandTexts.length,
114
+ unique_operator_count: new Set(operatorTexts).size,
115
+ unique_operand_count: new Set(operandTexts).size,
116
+ };
117
+
118
+ return { metrics, algorithmVersion };
119
+ }
package/src/types.ts ADDED
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Local result contracts for the complexity calculators (spec.md §16,
3
+ * architecture.md §15.2). These are NOT domain types: domain fixes the
4
+ * *stored* shapes (`ComplexitySnapshotV1`, `ComplexitySummaryV1`,
5
+ * `CognitiveContributionV1`, `MetricContribution`); this module fixes the
6
+ * richer *calculator output* shapes that `assemble.ts` folds into those
7
+ * stored shapes. See README "Entscheidungen" for the mapping rules.
8
+ */
9
+ import type { CognitiveContributionV1, MetricContribution } from "@untangle-code/domain";
10
+
11
+ /**
12
+ * One cyclomatic decision contribution. Structurally a `MetricContribution`
13
+ * (so it can be stored directly in `ComplexitySnapshotV1.cyclomatic.contributors`)
14
+ * plus the two spec.md §16.2/§16.3 membership flags that tell which of the two
15
+ * totals (classic/extended) this contribution feeds.
16
+ */
17
+ export interface CyclomaticContribution extends MetricContribution {
18
+ countsForClassic: boolean;
19
+ countsForExtended: boolean;
20
+ }
21
+
22
+ /** Output of `computeCyclomatic` (spec.md §16.2, §16.3). */
23
+ export interface CyclomaticResult {
24
+ classic: number;
25
+ extended: number;
26
+ /** Superset list (extended ⊇ classic); filter by the membership flags. */
27
+ contributions: CyclomaticContribution[];
28
+ algorithmVersion: string;
29
+ }
30
+
31
+ /** Output of `computeCognitive` (spec.md §16.4, algorithm `untangle-cognitive-v1`). */
32
+ export interface CognitiveResult {
33
+ value: number;
34
+ algorithm: string;
35
+ contributions: CognitiveContributionV1[];
36
+ }
37
+
38
+ /**
39
+ * Coupling and API-surface inputs that require whole-repository graph or
40
+ * export/visibility knowledge the facts-only calculators do not have. Every
41
+ * field is optional and is only ever included in the output when supplied
42
+ * here — never guessed or defaulted (per the work order).
43
+ */
44
+ export interface StructuralExternalInput {
45
+ fanIn?: number;
46
+ fanOut?: number;
47
+ afferentCoupling?: number;
48
+ efferentCoupling?: number;
49
+ /** Used as-is when given; otherwise derived from afferent/efferent when both are present. */
50
+ instability?: number;
51
+ importInDegree?: number;
52
+ importOutDegree?: number;
53
+ inheritanceDepth?: number;
54
+ publicApiCount?: number;
55
+ }
56
+
57
+ /** Envelope every non-cyclomatic/cognitive calculator returns (uniform API, spec.md §16.5–§16.8). */
58
+ export interface MetricsResult {
59
+ metrics: Record<string, number>;
60
+ algorithmVersion: string;
61
+ }
62
+
63
+ /** Output of `computeHalstead` (spec.md §16.7). */
64
+ export interface HalsteadResult {
65
+ metrics: Record<string, number>;
66
+ /** Present only when `options.includeEstimates` is set; explicitly labeled as estimates. */
67
+ estimates?: {
68
+ /** Estimated time to implement, in seconds (Halstead's Stroud-number-18 heuristic). */
69
+ timeSeconds: number;
70
+ /** Estimated defect count (`volume / 3000`, Halstead's heuristic). */
71
+ estimatedBugs: number;
72
+ };
73
+ algorithmVersion: string;
74
+ }
75
+
76
+ /** Output of `computeMaintainability` (spec.md §16.9). */
77
+ export interface MaintainabilityResult {
78
+ /** Clamped to [0, 100]. */
79
+ value: number;
80
+ /** Unclamped `MI_raw` before the `* 100 / 171` rescale, for debugging/explain. */
81
+ raw: number;
82
+ /** The exact formula used, as required by the work order. */
83
+ formula: string;
84
+ algorithmVersion: string;
85
+ }
86
+
87
+ /** One value to aggregate (spec.md §16.10). */
88
+ export interface AggregationEntry {
89
+ /** Dedupe key — duplicate ids are folded to a single sample (first occurrence wins). */
90
+ metricsScopeId: string;
91
+ value: number;
92
+ /** SLOC of the scope this value came from, for the SLOC-weighted mean. */
93
+ sloc?: number;
94
+ }
95
+
96
+ /** Result of `aggregate()` (spec.md §16.10). */
97
+ export interface AggregationResult {
98
+ count: number;
99
+ sum: number;
100
+ mean: number;
101
+ median: number;
102
+ max: number;
103
+ p90: number;
104
+ p95: number;
105
+ /** Undefined when no entry carried an `sloc`. */
106
+ slocWeightedMean?: number;
107
+ /** Number of (deduplicated) entries whose value is strictly greater than each configured threshold, keyed by the threshold's decimal string. */
108
+ thresholdCounts: Record<string, number>;
109
+ /** Number of raw entries dropped because their `metricsScopeId` repeated an earlier one. */
110
+ duplicatesDropped: number;
111
+ }
112
+
113
+ /** One line of `explainSymbol`'s ranked, human-readable breakdown. */
114
+ export interface ExplainEntry {
115
+ rank: number;
116
+ metric: "cyclomatic" | "cognitive";
117
+ kind: string;
118
+ line: number;
119
+ value: number;
120
+ text: string;
121
+ }