codemetrics-ai 0.3.0 → 0.5.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 (45) hide show
  1. package/README.md +114 -8
  2. package/dist/analyzer.js +37 -27
  3. package/dist/async-population.d.ts +12 -0
  4. package/dist/async-population.js +53 -0
  5. package/dist/async-probe.d.ts +8 -0
  6. package/dist/async-probe.js +52 -0
  7. package/dist/async-scoring.d.ts +3 -0
  8. package/dist/async-scoring.js +33 -0
  9. package/dist/complexity-breakdown.d.ts +21 -0
  10. package/dist/complexity-breakdown.js +30 -0
  11. package/dist/decomposition-evidence.d.ts +91 -0
  12. package/dist/decomposition-evidence.js +71 -0
  13. package/dist/error-handling-scoring.d.ts +1 -0
  14. package/dist/error-handling-scoring.js +15 -0
  15. package/dist/error-handling.d.ts +33 -0
  16. package/dist/error-handling.js +147 -0
  17. package/dist/failure-contracts.d.ts +19 -0
  18. package/dist/failure-contracts.js +261 -0
  19. package/dist/function-measurements.d.ts +17 -0
  20. package/dist/function-measurements.js +85 -0
  21. package/dist/function-nodes.d.ts +3 -0
  22. package/dist/function-nodes.js +5 -0
  23. package/dist/function-scoring.d.ts +5 -0
  24. package/dist/function-scoring.js +50 -0
  25. package/dist/handler-disposition.d.ts +9 -0
  26. package/dist/handler-disposition.js +156 -0
  27. package/dist/handler-resolution.d.ts +26 -0
  28. package/dist/handler-resolution.js +99 -0
  29. package/dist/metrics.d.ts +3 -0
  30. package/dist/metrics.js +23 -45
  31. package/dist/module-forwarding.d.ts +4 -0
  32. package/dist/module-forwarding.js +153 -0
  33. package/dist/module-graph.d.ts +34 -0
  34. package/dist/module-graph.js +122 -0
  35. package/dist/module-references.d.ts +8 -0
  36. package/dist/module-references.js +51 -0
  37. package/dist/module-resolution.d.ts +10 -0
  38. package/dist/module-resolution.js +76 -0
  39. package/dist/module-topology.d.ts +72 -0
  40. package/dist/module-topology.js +72 -0
  41. package/dist/react-probe.d.ts +6 -0
  42. package/dist/react-probe.js +60 -0
  43. package/dist/score-arithmetic.d.ts +12 -0
  44. package/dist/score-arithmetic.js +40 -0
  45. package/package.json +4 -3
@@ -0,0 +1,85 @@
1
+ import ts from "typescript";
2
+ import { hash } from "./evidence.js";
3
+ import { isFunction } from "./function-nodes.js";
4
+ import { complexityBreakdown, decisionCategory, emptyDecisionCounts } from "./complexity-breakdown.js";
5
+ function excluded(node) {
6
+ return isFunction(node) || ts.isTypeNode(node) || ts.isInterfaceDeclaration(node) ||
7
+ ts.isTypeAliasDeclaration(node) || ts.isClassDeclaration(node) || ts.isClassExpression(node) || ts.isEnumDeclaration(node);
8
+ }
9
+ function assertedExpression(node) {
10
+ if (ts.isAsExpression(node) || ts.isTypeAssertionExpression(node) || ts.isSatisfiesExpression(node) ||
11
+ ts.isNonNullExpression(node) || ts.isExpressionWithTypeArguments(node))
12
+ return node.expression;
13
+ return undefined;
14
+ }
15
+ function runtimeChildren(node, source) {
16
+ const children = node.getChildren(source);
17
+ const typed = node;
18
+ const argumentsIndex = typed.typeArguments ? children.findIndex(child => child.kind === ts.SyntaxKind.SyntaxList &&
19
+ child.pos === typed.typeArguments.pos && child.end === typed.typeArguments.end) : -1;
20
+ return children.filter((child, index) => {
21
+ // Delimiters and commas around erased type arguments are not runtime tokens.
22
+ if (argumentsIndex >= 0 && index >= argumentsIndex - 1 && index <= argumentsIndex + 1)
23
+ return false;
24
+ if (typed.type && (child === typed.type || (child.kind === ts.SyntaxKind.ColonToken && children[index + 1] === typed.type)))
25
+ return false;
26
+ return child !== typed.exclamationToken;
27
+ });
28
+ }
29
+ // This scored measurement is deliberately separate from the historical CSV metric.
30
+ export function measureFunction(node, source, file, project, member) {
31
+ const body = node.body;
32
+ const decisionCounts = emptyDecisionCounts();
33
+ const operators = [], operands = [], lines = new Set();
34
+ function decisions(current) {
35
+ const expression = assertedExpression(current);
36
+ if (expression) {
37
+ decisions(expression);
38
+ return;
39
+ }
40
+ if (excluded(current))
41
+ return;
42
+ const category = decisionCategory(current);
43
+ if (category)
44
+ decisionCounts[category]++;
45
+ ts.forEachChild(current, decisions);
46
+ }
47
+ function tokens(current) {
48
+ const expression = assertedExpression(current);
49
+ if (expression) {
50
+ tokens(expression);
51
+ return;
52
+ }
53
+ if (excluded(current) || ts.isJsxText(current))
54
+ return;
55
+ const children = runtimeChildren(current, source);
56
+ if (children.length) {
57
+ children.forEach(tokens);
58
+ return;
59
+ }
60
+ if (current.kind === ts.SyntaxKind.EndOfFileToken || current.getWidth(source) === 0)
61
+ return;
62
+ const text = current.getText(source);
63
+ if (!["{", "}", ";"].includes(text)) {
64
+ const start = source.getLineAndCharacterOfPosition(current.getStart(source)).line;
65
+ const end = source.getLineAndCharacterOfPosition(current.end - 1).line;
66
+ for (let line = start; line <= end; line++)
67
+ lines.add(line);
68
+ }
69
+ if (ts.isIdentifier(current) || ts.isLiteralExpression(current))
70
+ operands.push(text);
71
+ else
72
+ operators.push(text);
73
+ }
74
+ decisions(body);
75
+ const breakdown = complexityBreakdown(decisionCounts);
76
+ const complexity = breakdown.total;
77
+ tokens(body);
78
+ const vocabulary = new Set(operators).size + new Set(operands).size;
79
+ const volume = vocabulary > 1 ? (operators.length + operands.length) * Math.log2(vocabulary) : 0;
80
+ const mi = lines.size === 0 ? 100 : Math.min(100, Math.max(0, (171 - 5.2 * Math.log(Math.max(volume, 1)) - 0.23 * complexity - 16.2 * Math.log(Math.max(lines.size, 1))) * 100 / 171));
81
+ const start = node.getStart(source);
82
+ return { id: hash(`${file}|${start}|${node.kind}`), file, project, member, sourceSpanStart: start,
83
+ line: source.getLineAndCharacterOfPosition(start).line + 1, ownComplexity: complexity,
84
+ ownSourceLines: lines.size, ownHalsteadVolume: volume, ownMaintainabilityIndex: mi, complexityBreakdown: breakdown };
85
+ }
@@ -0,0 +1,3 @@
1
+ import ts from "typescript";
2
+ export type FunctionNode = ts.FunctionDeclaration | ts.FunctionExpression | ts.ArrowFunction | ts.MethodDeclaration | ts.ConstructorDeclaration | ts.GetAccessorDeclaration | ts.SetAccessorDeclaration;
3
+ export declare function isFunction(node: ts.Node): node is FunctionNode;
@@ -0,0 +1,5 @@
1
+ import ts from "typescript";
2
+ export function isFunction(node) {
3
+ return ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node) ||
4
+ ts.isMethodDeclaration(node) || ts.isConstructorDeclaration(node) || ts.isGetAccessorDeclaration(node) || ts.isSetAccessorDeclaration(node);
5
+ }
@@ -0,0 +1,5 @@
1
+ import { type Dimension, type Finding } from "./evidence.js";
2
+ import type { FunctionMeasurement } from "./function-measurements.js";
3
+ export declare const complexityAnchors: readonly [readonly [3, 10], readonly [5, 8], readonly [10, 6], readonly [20, 4], readonly [40, 0]];
4
+ export declare const maintainabilityAnchors: readonly [readonly [40, 0], readonly [52, 2], readonly [58, 4], readonly [65, 6], readonly [70, 8], readonly [75, 10]];
5
+ export declare function scoreFunctions(functions: FunctionMeasurement[], dimension: "codeQuality" | "maintainability", findings?: Finding[]): Dimension;
@@ -0,0 +1,50 @@
1
+ import { scored } from "./evidence.js";
2
+ import { add, decimal, fraction, interpolate, multiply, numeric, roundScore, subtract } from "./score-arithmetic.js";
3
+ export const complexityAnchors = [[3, 10], [5, 8], [10, 6], [20, 4], [40, 0]];
4
+ export const maintainabilityAnchors = [[40, 0], [52, 2], [58, 4], [65, 6], [70, 8], [75, 10]];
5
+ export function scoreFunctions(functions, dimension, findings = []) {
6
+ const complexity = dimension === "codeQuality";
7
+ const anchors = complexity ? complexityAnchors : maintainabilityAnchors;
8
+ const measured = (item) => complexity ? item.ownComplexity : item.ownMaintainabilityIndex;
9
+ const scope = { id: `javascript-typescript/${dimension}/v2`, coverage: "partial",
10
+ includes: [complexity ? "owned-function-cyclomatic-complexity" : "owned-function-maintainability-index"],
11
+ excludes: ["module-and-field-initializers", "parameter-initializers", "runtime-behavior", complexity ? "decomposition" : "change-cost"] };
12
+ if (!functions.length)
13
+ return { status: "skipped", basis: "No implemented functions; function score is unmeasured.", findings, scope };
14
+ const ordered = functions.map(item => ({ item, score: interpolate(measured(item), anchors) }))
15
+ .sort((a, b) => (complexity ? measured(b.item) - measured(a.item) : measured(a.item) - measured(b.item)) ||
16
+ (a.item.id < b.item.id ? -1 : a.item.id > b.item.id ? 1 : 0));
17
+ const weakCount = complexity ? 1 : Math.ceil(ordered.length / 5);
18
+ const restCount = ordered.length - weakCount;
19
+ const weakWeight = restCount ? fraction(2n, 5n * BigInt(weakCount)) : fraction(1n, BigInt(weakCount));
20
+ const restWeight = restCount ? fraction(3n, 5n * BigInt(restCount)) : decimal(0);
21
+ const losses = { weakest: decimal(0), remainder: decimal(0) };
22
+ const contributions = ordered.map(({ item, score }, index) => {
23
+ const group = index < weakCount ? "weakest" : "remainder";
24
+ const weight = index < weakCount ? weakWeight : restWeight;
25
+ const weightedLoss = multiply(subtract(decimal(10), score), weight);
26
+ losses[group] = add(losses[group], weightedLoss);
27
+ return { ...item, group, individualScore: numeric(score), weightNumerator: Number(weight.numerator),
28
+ weightDenominator: Number(weight.denominator), weightedScoreLoss: numeric(weightedLoss) };
29
+ });
30
+ const total = subtract(decimal(10), add(losses.weakest, losses.remainder));
31
+ const decision = { version: 1, policy: `javascript-typescript/${dimension}/owned-functions-v2`, operation: "deductions",
32
+ finalScore: roundScore(total), inputs: { baseline: 10, eligibleFunctions: ordered.length, weakCount, restCount,
33
+ anchors, aggregation: complexity ? "worst-40-remainder-60" : "weakest-quintile-40-remainder-60",
34
+ rounding: "decimal-rational-half-up-one-decimal", unroundedScore: numeric(total) },
35
+ steps: (restCount ? ["weakest", "remainder"] : ["weakest"]).map(group => ({ id: group, kind: "component", score: numeric(losses[group]),
36
+ disposition: "applied", inputs: { weightedScoreLoss: numeric(losses[group]), functionCount: group === "weakest" ? weakCount : restCount },
37
+ findingCategories: complexity ? ["highFunctionComplexity"] : [] })), findingEffects: [] };
38
+ const result = scored(decision, `${complexity ? "Method complexity: one worst function" : "Maintainability: weakest ceil(N/5) functions"} receives 40%; remaining functions receive 60%. Single-function scopes use their own score. JS/TS remains uncalibrated across ecosystems.`, findings, { ...decision.inputs, measurement: "owned-function-body-v2-type-erasure", functionContributions: contributions, topOffenders: contributions.slice(0, 5) });
39
+ // Findings identify population inputs, never additional independent penalties.
40
+ const byId = new Map(contributions.map(item => [item.id, item.group]));
41
+ decision.findingEffects = result.findings.map(finding => {
42
+ const group = byId.get(String(finding.observations.functionId));
43
+ return { fingerprint: finding.fingerprint, category: finding.category,
44
+ effect: group ? "policyInput" : "noAdditionalReduction", stepIds: group ? [group] : [] };
45
+ });
46
+ return { ...result, scope, ...(complexity ? { displayName: "Method Complexity", componentDetails: {
47
+ methodComplexity: { score: result.score, eligibleFunctions: ordered.length, topOffenders: contributions.slice(0, 5) },
48
+ decomposition: { score: null, status: "unsupported", description: "JS/TS decomposition is not measured by this policy." }
49
+ } } : {}) };
50
+ }
@@ -0,0 +1,9 @@
1
+ import ts from "typescript";
2
+ import type { FailureContracts } from "./failure-contracts.js";
3
+ export type DispositionKind = "propagated" | "failureResult" | "reported" | "fallback" | "documented" | "unexplainedSwallowing" | "unknown";
4
+ export interface HandlerDisposition {
5
+ classification: DispositionKind;
6
+ reason: string;
7
+ comments?: string[];
8
+ }
9
+ export declare function classifyDisposition(body: ts.ConciseBody, checker: ts.TypeChecker, empty: boolean, comments: string[], use: ts.Node, contracts: FailureContracts): HandlerDisposition;
@@ -0,0 +1,156 @@
1
+ import ts from "typescript";
2
+ import { unwrapHandler } from "./handler-resolution.js";
3
+ const result = (classification, reason) => ({ classification, reason });
4
+ // These are declarations of intent, not a judgment about whether the rationale is good.
5
+ const intentional = /\b(?:ignor(?:e|ed|ing)|swallow(?:ed|ing)?|discard(?:ed|ing)?|optional|best[- ]effort|does nothing|no[- ]op|intention(?:al|ally))\b/i;
6
+ function leadingComments(node) {
7
+ const source = node.getSourceFile();
8
+ return (ts.getLeadingCommentRanges(source.text, node.getFullStart()) ?? [])
9
+ .map(range => source.text.slice(range.pos, range.end)).filter(text => intentional.test(text));
10
+ }
11
+ function declaredIntent(body, checker) {
12
+ const owner = body.parent;
13
+ if (ts.isCatchClause(owner))
14
+ return [...leadingComments(owner), ...leadingComments(owner.parent)];
15
+ if (ts.isFunctionDeclaration(owner)) {
16
+ const declarations = owner.name ? checker.getSymbolAtLocation(owner.name)?.declarations ?? [owner] : [owner];
17
+ return declarations.flatMap(leadingComments);
18
+ }
19
+ if ((ts.isArrowFunction(owner) || ts.isFunctionExpression(owner)) && ts.isVariableDeclaration(owner.parent)) {
20
+ const list = owner.parent.parent;
21
+ // A comment on a multi-binding declaration cannot identify one handler's intent.
22
+ if (ts.isVariableDeclarationList(list) && list.declarations.length === 1)
23
+ return leadingComments(list.parent);
24
+ }
25
+ return leadingComments(owner);
26
+ }
27
+ function explicitValue(expression) {
28
+ if (!expression)
29
+ return false;
30
+ const node = unwrapHandler(expression);
31
+ // No names, calls, spreads or property reads: their value/side effects need more flow analysis.
32
+ if (ts.isLiteralExpression(node) || [ts.SyntaxKind.NullKeyword, ts.SyntaxKind.TrueKeyword, ts.SyntaxKind.FalseKeyword].includes(node.kind))
33
+ return true;
34
+ if (ts.isPrefixUnaryExpression(node))
35
+ return ts.isNumericLiteral(node.operand);
36
+ if (ts.isArrayLiteralExpression(node))
37
+ return node.elements.every(element => explicitValue(element));
38
+ return ts.isObjectLiteralExpression(node) && node.properties.every(property => ts.isPropertyAssignment(property) &&
39
+ !ts.isComputedPropertyName(property.name) && explicitValue(property.initializer));
40
+ }
41
+ function statementOutcome(statement, checker, contracts) {
42
+ if (ts.isThrowStatement(statement))
43
+ return result("propagated", "Unconditional throw on the failure path.");
44
+ if (ts.isReturnStatement(statement))
45
+ return explicitValue(statement.expression) || contracts.explicitResult(statement.expression)
46
+ ? result("failureResult", "Returns an explicit value/container on the failure path; suitability and caller correctness are not judged.")
47
+ : result("unknown", "Return value or caller contract requires additional analysis.");
48
+ if (ts.isBlock(statement))
49
+ return blockOutcome(statement.statements, checker, contracts);
50
+ if (ts.isIfStatement(statement)) {
51
+ const yes = statementOutcome(statement.thenStatement, checker, contracts);
52
+ const no = statement.elseStatement && statementOutcome(statement.elseStatement, checker, contracts);
53
+ if (yes && no && ![yes.classification, no.classification].includes("unknown"))
54
+ return result(yes.classification === no.classification ? yes.classification : "failureResult", "Both branches have an explicit failure disposition.");
55
+ return result("unknown", "Conditional failure path is not fully resolved.");
56
+ }
57
+ if (ts.isExpressionStatement(statement) && ts.isCallExpression(statement.expression)) {
58
+ if (contracts.forwardsCaughtError(statement.expression))
59
+ return result("propagated", "Passes the caught error to a caller-supplied callback; consumer correctness is outside scope.");
60
+ if (contracts.reports(statement.expression))
61
+ return result("reported", "Standard console output or a resolved source-backed logging wrapper; runtime filtering is outside scope.");
62
+ }
63
+ if (ts.isExpressionStatement(statement) || ts.isVariableStatement(statement) || ts.isEmptyStatement(statement) ||
64
+ ts.isFunctionDeclaration(statement) || ts.isTypeAliasDeclaration(statement) || ts.isInterfaceDeclaration(statement))
65
+ return;
66
+ return result("unknown", "Control flow is outside the bounded handler classifier.");
67
+ }
68
+ function blockOutcome(statements, checker, contracts) {
69
+ for (const statement of statements) {
70
+ const outcome = statementOutcome(statement, checker, contracts);
71
+ if (outcome)
72
+ return outcome;
73
+ }
74
+ }
75
+ function containsReturn(node) {
76
+ if (ts.isReturnStatement(node))
77
+ return true;
78
+ if (ts.isFunctionLike(node))
79
+ return false;
80
+ return ts.forEachChild(node, containsReturn) ?? false;
81
+ }
82
+ function guardedFallback(statement, next, checker, contracts) {
83
+ if (!next || !ts.isIfStatement(next) || statement.tryBlock.statements.length !== 1)
84
+ return false;
85
+ const assignment = statement.tryBlock.statements[0];
86
+ if (!ts.isExpressionStatement(assignment) || !ts.isBinaryExpression(assignment.expression) ||
87
+ assignment.expression.operatorToken.kind !== ts.SyntaxKind.EqualsToken || !ts.isIdentifier(assignment.expression.left))
88
+ return false;
89
+ const symbol = checker.getSymbolAtLocation(assignment.expression.left);
90
+ const declarations = symbol?.declarations ?? [];
91
+ if (declarations.length !== 1 || !ts.isVariableDeclaration(declarations[0]) || declarations[0].initializer)
92
+ return false;
93
+ const refersToBinding = (node) => (ts.isIdentifier(node) && checker.getSymbolAtLocation(node) === symbol) ||
94
+ (ts.forEachChild(node, refersToBinding) ?? false);
95
+ if (refersToBinding(assignment.expression.right))
96
+ return false;
97
+ // Require the uninitialized binding immediately before the try: no earlier assigned state.
98
+ const parent = statement.parent;
99
+ if (!ts.isBlock(parent) && !ts.isSourceFile(parent))
100
+ return false;
101
+ const previous = parent.statements[parent.statements.indexOf(statement) - 1];
102
+ if (!previous || !ts.isVariableStatement(previous) || previous.declarationList.declarations.length !== 1 ||
103
+ !(previous.declarationList.flags & ts.NodeFlags.Let) || previous.declarationList.declarations[0] !== declarations[0])
104
+ return false;
105
+ const condition = unwrapHandler(next.expression);
106
+ if (!ts.isBinaryExpression(condition) || condition.operatorToken.kind !== ts.SyntaxKind.EqualsEqualsEqualsToken)
107
+ return false;
108
+ const left = unwrapHandler(condition.left), right = unwrapHandler(condition.right);
109
+ if (!ts.isIdentifier(left) || checker.getSymbolAtLocation(left) !== symbol || !ts.isIdentifier(right) || right.text !== "undefined")
110
+ return false;
111
+ const undefinedDeclarations = checker.getSymbolAtLocation(right)?.declarations ?? [];
112
+ if (undefinedDeclarations.some(declaration => !declaration.getSourceFile().hasNoDefaultLib))
113
+ return false;
114
+ const outcome = statementOutcome(next.thenStatement, checker, contracts);
115
+ return !!outcome && ["failureResult", "propagated", "reported"].includes(outcome.classification);
116
+ }
117
+ export function classifyDisposition(body, checker, empty, comments, use, contracts) {
118
+ const owner = body.parent;
119
+ // Finally may replace a throw/return, even when the catch itself is explicit.
120
+ if (ts.isCatchClause(owner)) {
121
+ for (let ancestor = owner.parent; ancestor && !ts.isFunctionLike(ancestor); ancestor = ancestor.parent)
122
+ if (ts.isTryStatement(ancestor) && ancestor.finallyBlock)
123
+ return result("unknown", "Finally may change the failure disposition.");
124
+ }
125
+ const intent = [...(empty ? comments : comments.filter(comment => intentional.test(comment))), ...declaredIntent(body, checker)];
126
+ if (intent.length)
127
+ return { ...result("documented", "Attached developer intent is honored without judging the business decision."), comments: intent };
128
+ if (!empty) {
129
+ const outcome = ts.isBlock(body) ? blockOutcome(body.statements, checker, contracts) : explicitValue(body) || contracts.explicitResult(body) ? result("failureResult", "Explicit value/container from rejection handler.") : undefined;
130
+ return outcome ?? result("unknown", "Code presence alone does not establish a failure disposition.");
131
+ }
132
+ if (!ts.isCatchClause(owner)) {
133
+ if (!ts.isExpressionStatement(use.parent))
134
+ return result("unknown", "The rejection handler's result is used by an enclosing expression or caller; its failure contract needs context.");
135
+ return result("unexplainedSwallowing", "Empty rejection handler resolves without a value or declared intent; its result is discarded.");
136
+ }
137
+ const statement = owner.parent, parent = statement.parent;
138
+ if (!ts.isBlock(parent) && !ts.isSourceFile(parent))
139
+ return result("unknown", "Enclosing control flow requires context.");
140
+ const following = parent.statements.slice(parent.statements.indexOf(statement) + 1);
141
+ if (guardedFallback(statement, following[0], checker, contracts))
142
+ return result("fallback", "Failed assignment is followed by an explicit undefined guard and failure outcome.");
143
+ if (following.length) {
144
+ const outcome = blockOutcome(following, checker, contracts);
145
+ if (outcome && outcome.classification !== "unknown")
146
+ return result("fallback", outcome.reason);
147
+ return result("unknown", "Continuation after the empty catch needs a failure-contract review.");
148
+ }
149
+ if (containsReturn(statement.tryBlock) && contracts.guardedUndefinedResult(body))
150
+ return result("fallback", "Every selected-source call tests the optional result before using it; no escaping function reference was found.");
151
+ if (containsReturn(statement.tryBlock))
152
+ return result("unknown", "Success returns a value; the implicit/undefined failure result may be a caller contract.");
153
+ if (!ts.isSourceFile(parent) && !ts.isFunctionLike(parent.parent))
154
+ return result("unknown", "Outer continuation or control flow requires context.");
155
+ return result("unexplainedSwallowing", "Terminal empty catch discards failure without an explicit result or attached intent.");
156
+ }
@@ -0,0 +1,26 @@
1
+ import ts from "typescript";
2
+ export declare function unwrapHandler(node: ts.Expression): ts.Expression;
3
+ type Resolution = {
4
+ body: ts.ConciseBody;
5
+ resolution: "inline" | "reference";
6
+ } | {
7
+ reason: string;
8
+ };
9
+ /** Bounded declaration lookup, not a runtime value-flow or mutation proof. */
10
+ export declare class HandlerResolver {
11
+ private readonly checker;
12
+ private readonly selected;
13
+ private readonly written;
14
+ private readonly identities;
15
+ constructor(checker: ts.TypeChecker, sources: ts.SourceFile[], root: string, project: string);
16
+ private recordWrite;
17
+ identity(body: ts.ConciseBody): {
18
+ id: string;
19
+ file: string;
20
+ project: string;
21
+ line: number;
22
+ };
23
+ resolve(argument: ts.Expression | undefined): Resolution;
24
+ private resolveReference;
25
+ }
26
+ export {};
@@ -0,0 +1,99 @@
1
+ import ts from "typescript";
2
+ import path from "node:path";
3
+ import { hash } from "./evidence.js";
4
+ import { isFunction } from "./function-nodes.js";
5
+ export function unwrapHandler(node) {
6
+ while (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isTypeAssertionExpression(node) ||
7
+ ts.isNonNullExpression(node) || ts.isSatisfiesExpression(node))
8
+ node = node.expression;
9
+ return node;
10
+ }
11
+ /** Bounded declaration lookup, not a runtime value-flow or mutation proof. */
12
+ export class HandlerResolver {
13
+ checker;
14
+ selected = new Set();
15
+ written = new Set();
16
+ identities = new Map();
17
+ constructor(checker, sources, root, project) {
18
+ this.checker = checker;
19
+ for (const source of sources) {
20
+ this.selected.add(source);
21
+ const file = path.relative(root, source.fileName).replaceAll("\\", "/");
22
+ let functions = 0, catches = 0;
23
+ const visit = (node) => {
24
+ if (ts.isTypeNode(node) && !ts.isExpressionWithTypeArguments(node))
25
+ return;
26
+ const body = isFunction(node) ? node.body : ts.isCatchClause(node) ? node.block : undefined;
27
+ if (body) {
28
+ const key = ts.isCatchClause(node) ? `catch:${catches++}` : `function:${functions++}`;
29
+ this.identities.set(body, { id: hash(`${project}|${file}|${key}`), file, project,
30
+ line: source.getLineAndCharacterOfPosition(body.getStart(source)).line + 1 });
31
+ }
32
+ if (ts.isBinaryExpression(node) && node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
33
+ node.operatorToken.kind <= ts.SyntaxKind.LastAssignment)
34
+ this.recordWrite(node.left);
35
+ if ((ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) &&
36
+ [ts.SyntaxKind.PlusPlusToken, ts.SyntaxKind.MinusMinusToken].includes(node.operator))
37
+ this.recordWrite(node.operand);
38
+ if ((ts.isForInStatement(node) || ts.isForOfStatement(node)) && !ts.isVariableDeclarationList(node.initializer))
39
+ this.recordWrite(node.initializer);
40
+ ts.forEachChild(node, visit);
41
+ };
42
+ visit(source);
43
+ }
44
+ }
45
+ recordWrite(node) {
46
+ if (ts.isIdentifier(node)) {
47
+ const symbol = this.checker.getSymbolAtLocation(node);
48
+ if (symbol)
49
+ this.written.add(symbol);
50
+ }
51
+ else if (!ts.isPropertyAccessExpression(node) && !ts.isElementAccessExpression(node)) {
52
+ // Destructuring and parenthesized assignments are conservatively included.
53
+ ts.forEachChild(node, child => this.recordWrite(child));
54
+ }
55
+ }
56
+ identity(body) { return this.identities.get(body); }
57
+ resolve(argument) {
58
+ if (!argument)
59
+ return { reason: "missingArgument" };
60
+ const direct = unwrapHandler(argument);
61
+ if (isFunction(direct) && direct.body)
62
+ return { body: direct.body, resolution: "inline" };
63
+ return this.resolveReference(direct, new Set());
64
+ }
65
+ resolveReference(node, seen) {
66
+ node = unwrapHandler(node);
67
+ if (!ts.isIdentifier(node))
68
+ return { reason: "unsupportedExpression" };
69
+ let symbol = this.checker.getSymbolAtLocation(node);
70
+ if (!symbol)
71
+ return { reason: "unresolvedSymbol" };
72
+ if (this.written.has(symbol))
73
+ return { reason: "reassignedBinding" };
74
+ if (symbol.flags & ts.SymbolFlags.Alias)
75
+ symbol = this.checker.getAliasedSymbol(symbol);
76
+ if (this.written.has(symbol))
77
+ return { reason: "reassignedBinding" };
78
+ if (seen.has(symbol) || seen.size >= 32)
79
+ return { reason: "aliasCycleOrDepth" };
80
+ seen.add(symbol);
81
+ const declarations = symbol.declarations ?? [];
82
+ if (!declarations.length || declarations.some(declaration => !this.selected.has(declaration.getSourceFile())))
83
+ return { reason: "outsideSelectedSource" };
84
+ const implementations = declarations.filter(declaration => ts.isFunctionDeclaration(declaration) && declaration.body);
85
+ if (implementations.length === 1)
86
+ return { body: implementations[0].body, resolution: "reference" };
87
+ if (declarations.length !== 1 || !ts.isVariableDeclaration(declarations[0]))
88
+ return { reason: "unsupportedDeclaration" };
89
+ const declaration = declarations[0];
90
+ if (!ts.isVariableDeclarationList(declaration.parent) || !(declaration.parent.flags & ts.NodeFlags.Const))
91
+ return { reason: "mutableBinding" };
92
+ if (!declaration.initializer)
93
+ return { reason: "missingInitializer" };
94
+ const value = unwrapHandler(declaration.initializer);
95
+ if (isFunction(value) && value.body)
96
+ return { body: value.body, resolution: "reference" };
97
+ return this.resolveReference(value, seen);
98
+ }
99
+ }
package/dist/metrics.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import ts from "typescript";
2
2
  import { type Finding } from "./evidence.js";
3
+ import { type FunctionMeasurement } from "./function-measurements.js";
3
4
  export interface Metric {
4
5
  project: string;
5
6
  file: string;
@@ -18,9 +19,11 @@ export interface Metric {
18
19
  export declare function maintainability(volume: number, complexity: number, lines: number): number;
19
20
  export declare function analyzeFile(source: ts.SourceFile, checker: ts.TypeChecker, project: string, root: string): {
20
21
  metrics: Metric[];
22
+ functions: FunctionMeasurement[];
21
23
  findings: {
22
24
  dimension: "codeQuality" | "performanceAsync";
23
25
  finding: Finding;
24
26
  }[];
25
27
  reactSupported: boolean;
28
+ asyncSupported: boolean;
26
29
  };
package/dist/metrics.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import path from "node:path";
2
2
  import ts from "typescript";
3
3
  import { hash } from "./evidence.js";
4
- function isFunction(node) {
5
- return ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node) ||
6
- ts.isMethodDeclaration(node) || ts.isConstructorDeclaration(node) || ts.isGetAccessorDeclaration(node) || ts.isSetAccessorDeclaration(node);
7
- }
4
+ import { isFunction } from "./function-nodes.js";
5
+ import { inspectReactCall } from "./react-probe.js";
6
+ import { inspectAsyncNode, isAsyncFunction, isPromiseConstruction } from "./async-probe.js";
7
+ import { measureFunction } from "./function-measurements.js";
8
8
  function nameOf(node, source) {
9
9
  if (node.name)
10
10
  return node.name.getText(source);
@@ -28,19 +28,24 @@ export function maintainability(volume, complexity, lines) {
28
28
  export function analyzeFile(source, checker, project, root) {
29
29
  const file = path.relative(root, source.fileName).replaceAll("\\", "/");
30
30
  const metrics = [];
31
+ const functions = [];
31
32
  const findings = [];
32
33
  const importCount = new Set(source.statements.filter(ts.isImportDeclaration).map(node => node.moduleSpecifier.getText(source))).size;
33
34
  const identities = new Map();
34
- function emit(category, dimension, node, member, message, observations, confidence = "high") {
35
+ let asyncSupported = false;
36
+ function emit(category, dimension, node, member, message, observations, confidence = "high", severity = "warning") {
35
37
  const ruleId = `javascript-typescript/${dimension}/${category}`;
36
38
  const identity = `${ruleId}|${file}|${project}|${member}`;
37
39
  const occurrence = identities.get(identity) ?? 0;
38
40
  identities.set(identity, occurrence + 1);
39
41
  findings.push({ dimension, finding: { category, ruleId, fingerprint: hash(`${identity}|${occurrence}`),
40
- severity: "warning", confidence, file, line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1,
42
+ severity, confidence, file, line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1,
41
43
  project, member, message, observations } });
42
44
  }
43
45
  function visit(node, parentName = "") {
46
+ asyncSupported ||= isAsyncFunction(node) || ts.isAwaitExpression(node) || isPromiseConstruction(node, checker);
47
+ for (const finding of inspectAsyncNode(node, checker))
48
+ emit(finding.category, "performanceAsync", node, parentName || "<module>", finding.message, { ...finding.observations, sourceSpanStart: node.getStart(source), sourceSpanLength: node.getWidth(source) }, finding.confidence, finding.severity);
44
49
  if (isFunction(node) && node.body) {
45
50
  const name = nameOf(node, source);
46
51
  const member = parentName ? `${parentName}/${name}` : name;
@@ -57,41 +62,9 @@ export function analyzeFile(source, checker, project, root) {
57
62
  if (ts.isJsxElement(child) || ts.isJsxSelfClosingElement(child) || ts.isJsxFragment(child))
58
63
  hasJsx = true;
59
64
  if (ts.isCallExpression(child)) {
60
- const symbol = checker.getSymbolAtLocation(ts.isPropertyAccessExpression(child.expression) ? child.expression.name : child.expression);
61
- const declarations = symbol?.declarations ?? [];
62
- const reactHook = declarations.some(declaration => {
63
- let cursor = declaration;
64
- while (cursor && !ts.isImportDeclaration(cursor))
65
- cursor = cursor.parent;
66
- return cursor && ts.isStringLiteral(cursor.moduleSpecifier) && cursor.moduleSpecifier.text === "react";
67
- });
68
- const hookName = declarations.find(ts.isImportSpecifier);
69
- const calledName = hookName ? (hookName.propertyName ?? hookName.name).text : ts.isPropertyAccessExpression(child.expression) ? child.expression.name.text : "";
70
- const namespaceSymbol = ts.isPropertyAccessExpression(child.expression) ? checker.getSymbolAtLocation(child.expression.expression) : undefined;
71
- const reactNamespace = namespaceSymbol?.declarations?.some(declaration => {
72
- let cursor = declaration;
73
- while (cursor && !ts.isImportDeclaration(cursor))
74
- cursor = cursor.parent;
75
- return cursor && ts.isStringLiteral(cursor.moduleSpecifier) && cursor.moduleSpecifier.text === "react";
76
- });
77
- if ((reactHook || reactNamespace) && /^use[A-Z]/.test(calledName)) {
78
- let ancestor = child.parent;
79
- while (ancestor && ancestor !== node.body) {
80
- if (ts.isIfStatement(ancestor) || ts.isConditionalExpression(ancestor) || ts.isForStatement(ancestor) || ts.isForOfStatement(ancestor) || ts.isForInStatement(ancestor) || ts.isWhileStatement(ancestor) || ts.isDoStatement(ancestor) ||
81
- ts.isBinaryExpression(ancestor) && [ts.SyntaxKind.AmpersandAmpersandToken, ts.SyntaxKind.BarBarToken, ts.SyntaxKind.QuestionQuestionToken].includes(ancestor.operatorToken.kind)) {
82
- emit("conditionalHook", "performanceAsync", child, member, `${calledName} is called within a conditional or loop.`, { hook: calledName });
83
- break;
84
- }
85
- ancestor = ancestor.parent;
86
- }
87
- if (["useEffect", "useLayoutEffect"].includes(calledName)) {
88
- const callback = child.arguments[0];
89
- if (callback && isFunction(callback) && callback.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword))
90
- emit("asyncEffectCallback", "performanceAsync", child, member, "An effect callback is async and returns a Promise instead of cleanup or undefined.", { hook: calledName });
91
- if (child.arguments.length < 2)
92
- emit("effectWithoutDependencies", "performanceAsync", child, member, "Effect runs after every render; verify that this is intentional.", { hook: calledName }, "low");
93
- }
94
- }
65
+ for (const finding of inspectReactCall(child, node.body, checker))
66
+ emit(finding.category, "performanceAsync", child, member, finding.message, { ...finding.observations, sourceSpanStart: child.getStart(source), classification: finding.confidence === "low" ? "reviewLead" : "actionableSignal",
67
+ scoreDisposition: finding.confidence === "low" ? "excludedReviewLead" : "scored" }, finding.confidence, finding.confidence === "low" ? "info" : "warning");
95
68
  }
96
69
  });
97
70
  if (!ts.isBlock(node.body))
@@ -138,16 +111,21 @@ export function analyzeFile(source, checker, project, root) {
138
111
  complexity, sourceLines: Math.max(1, sourceLineSet.size), executableLines, halsteadVolume: volume,
139
112
  maintainabilityIndex: maintainability(volume, complexity, Math.max(1, sourceLineSet.size)), coupling: importCount, inheritance };
140
113
  metrics.push(metric);
141
- if (complexity > 10)
142
- emit("highFunctionComplexity", "codeQuality", node, member, `${member} has cyclomatic complexity ${complexity} (threshold: 10).`, { measured: complexity, threshold: 10, metric: "cyclomaticComplexity", kind });
114
+ const measurement = measureFunction(node, source, file, project, member);
115
+ functions.push(measurement);
116
+ if (measurement.ownComplexity > 10)
117
+ emit("highFunctionComplexity", "codeQuality", node, member, `${member} has cyclomatic complexity ${measurement.ownComplexity} (threshold: 10).`, { measured: measurement.ownComplexity, threshold: 10, metric: "ownedCyclomaticComplexity", kind, functionId: measurement.id,
118
+ complexityBreakdown: measurement.complexityBreakdown });
143
119
  ts.forEachChild(node, child => visit(child, member));
144
120
  return;
145
121
  }
146
- if (!ts.isTypeNode(node))
122
+ // An instantiation expression (factory<T>) is classified as a TypeNode by
123
+ // TypeScript, but its expression can contain real functions and operations.
124
+ if (!ts.isTypeNode(node) || ts.isExpressionWithTypeArguments(node))
147
125
  ts.forEachChild(node, child => visit(child, parentName));
148
126
  }
149
127
  visit(source);
150
128
  const reactSupported = source.statements.some(statement => ts.isImportDeclaration(statement) && !statement.importClause?.isTypeOnly &&
151
129
  ts.isStringLiteral(statement.moduleSpecifier) && statement.moduleSpecifier.text === "react");
152
- return { metrics, findings, reactSupported };
130
+ return { metrics, functions, findings, reactSupported, asyncSupported };
153
131
  }
@@ -0,0 +1,4 @@
1
+ import ts from "typescript";
2
+ import type { ModuleReference } from "./module-references.js";
3
+ export type DependencyRole = "implementation" | "reExport";
4
+ export declare function dependencyRoles(source: ts.SourceFile, checker: ts.TypeChecker, references: ModuleReference[]): Map<ts.Node, DependencyRole[]>;