@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.
- package/LICENSE +661 -0
- package/NOTICE +90 -0
- package/README.md +235 -2
- package/THIRD_PARTY_LICENSES.md +24 -0
- package/dist/aggregator.d.ts +28 -0
- package/dist/aggregator.d.ts.map +1 -0
- package/dist/aggregator.js +77 -0
- package/dist/aggregator.js.map +1 -0
- package/dist/assemble.d.ts +50 -0
- package/dist/assemble.d.ts.map +1 -0
- package/dist/assemble.js +43 -0
- package/dist/assemble.js.map +1 -0
- package/dist/cognitive.d.ts +62 -0
- package/dist/cognitive.d.ts.map +1 -0
- package/dist/cognitive.js +62 -0
- package/dist/cognitive.js.map +1 -0
- package/dist/cyclomatic.d.ts +29 -0
- package/dist/cyclomatic.d.ts.map +1 -0
- package/dist/cyclomatic.js +52 -0
- package/dist/cyclomatic.js.map +1 -0
- package/dist/entropy.d.ts +5 -0
- package/dist/entropy.d.ts.map +1 -0
- package/dist/entropy.js +29 -0
- package/dist/entropy.js.map +1 -0
- package/dist/explain.d.ts +22 -0
- package/dist/explain.d.ts.map +1 -0
- package/dist/explain.js +35 -0
- package/dist/explain.js.map +1 -0
- package/dist/halstead.d.ts +23 -0
- package/dist/halstead.d.ts.map +1 -0
- package/dist/halstead.js +24 -0
- package/dist/halstead.js.map +1 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +34 -0
- package/dist/index.js.map +1 -0
- package/dist/ir/build-ir.d.ts +105 -0
- package/dist/ir/build-ir.d.ts.map +1 -0
- package/dist/ir/build-ir.js +267 -0
- package/dist/ir/build-ir.js.map +1 -0
- package/dist/maintainability.d.ts +15 -0
- package/dist/maintainability.d.ts.map +1 -0
- package/dist/maintainability.js +10 -0
- package/dist/maintainability.js.map +1 -0
- package/dist/node-kind-heuristics.d.ts +24 -0
- package/dist/node-kind-heuristics.d.ts.map +1 -0
- package/dist/node-kind-heuristics.js +37 -0
- package/dist/node-kind-heuristics.js.map +1 -0
- package/dist/prose/metrics.d.ts +81 -0
- package/dist/prose/metrics.d.ts.map +1 -0
- package/dist/prose/metrics.js +103 -0
- package/dist/prose/metrics.js.map +1 -0
- package/dist/scope-aggregate.d.ts +229 -0
- package/dist/scope-aggregate.d.ts.map +1 -0
- package/dist/scope-aggregate.js +588 -0
- package/dist/scope-aggregate.js.map +1 -0
- package/dist/scope-identity.d.ts +85 -0
- package/dist/scope-identity.d.ts.map +1 -0
- package/dist/scope-identity.js +144 -0
- package/dist/scope-identity.js.map +1 -0
- package/dist/scope.d.ts +36 -0
- package/dist/scope.d.ts.map +1 -0
- package/dist/scope.js +60 -0
- package/dist/scope.js.map +1 -0
- package/dist/structural.d.ts +30 -0
- package/dist/structural.d.ts.map +1 -0
- package/dist/structural.js +98 -0
- package/dist/structural.js.map +1 -0
- package/dist/syntactic.d.ts +35 -0
- package/dist/syntactic.d.ts.map +1 -0
- package/dist/syntactic.js +143 -0
- package/dist/syntactic.js.map +1 -0
- package/dist/text-lines.d.ts +34 -0
- package/dist/text-lines.d.ts.map +1 -0
- package/dist/text-lines.js +60 -0
- package/dist/text-lines.js.map +1 -0
- package/dist/textual.d.ts +28 -0
- package/dist/textual.d.ts.map +1 -0
- package/dist/textual.js +75 -0
- package/dist/textual.js.map +1 -0
- package/dist/types.d.ts +112 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +60 -3
- package/src/aggregator.ts +105 -0
- package/src/assemble.ts +109 -0
- package/src/cognitive.ts +127 -0
- package/src/cyclomatic.ts +78 -0
- package/src/entropy.ts +30 -0
- package/src/explain.ts +64 -0
- package/src/halstead.ts +54 -0
- package/src/index.ts +85 -0
- package/src/ir/build-ir.ts +411 -0
- package/src/maintainability.ts +29 -0
- package/src/node-kind-heuristics.ts +55 -0
- package/src/prose/metrics.ts +201 -0
- package/src/scope-aggregate.ts +775 -0
- package/src/scope-identity.ts +240 -0
- package/src/scope.ts +77 -0
- package/src/structural.ts +132 -0
- package/src/syntactic.ts +183 -0
- package/src/text-lines.ts +90 -0
- package/src/textual.ts +119 -0
- 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
|
+
}
|
package/src/assemble.ts
ADDED
|
@@ -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
|
+
}
|
package/src/cognitive.ts
ADDED
|
@@ -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
|
+
}
|
package/src/halstead.ts
ADDED
|
@@ -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";
|