codemetrics-ai 0.2.0 → 0.4.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.
- package/README.md +99 -7
- package/dist/analyzer.js +35 -23
- package/dist/async-population.d.ts +12 -0
- package/dist/async-population.js +53 -0
- package/dist/async-probe.d.ts +8 -0
- package/dist/async-probe.js +52 -0
- package/dist/async-scoring.d.ts +3 -0
- package/dist/async-scoring.js +33 -0
- package/dist/complexity-breakdown.d.ts +21 -0
- package/dist/complexity-breakdown.js +30 -0
- package/dist/decomposition-evidence.d.ts +91 -0
- package/dist/decomposition-evidence.js +71 -0
- package/dist/error-handling.d.ts +30 -0
- package/dist/error-handling.js +114 -0
- package/dist/evidence-tools.js +20 -0
- package/dist/evidence.d.ts +3 -1
- package/dist/evidence.js +5 -2
- package/dist/evidence.schema.v3.json +171 -2
- package/dist/function-measurements.d.ts +17 -0
- package/dist/function-measurements.js +85 -0
- package/dist/function-nodes.d.ts +3 -0
- package/dist/function-nodes.js +5 -0
- package/dist/function-scoring.d.ts +5 -0
- package/dist/function-scoring.js +50 -0
- package/dist/handler-resolution.d.ts +26 -0
- package/dist/handler-resolution.js +99 -0
- package/dist/metrics.d.ts +3 -0
- package/dist/metrics.js +23 -45
- package/dist/module-forwarding.d.ts +4 -0
- package/dist/module-forwarding.js +153 -0
- package/dist/module-graph.d.ts +34 -0
- package/dist/module-graph.js +122 -0
- package/dist/module-references.d.ts +8 -0
- package/dist/module-references.js +51 -0
- package/dist/module-resolution.d.ts +10 -0
- package/dist/module-resolution.js +76 -0
- package/dist/module-topology.d.ts +72 -0
- package/dist/module-topology.js +72 -0
- package/dist/react-probe.d.ts +6 -0
- package/dist/react-probe.js +60 -0
- package/dist/score-arithmetic.d.ts +12 -0
- package/dist/score-arithmetic.js +40 -0
- package/dist/scoring-decision.d.ts +35 -0
- package/dist/scoring-decision.js +24 -0
- package/package.json +4 -3
|
@@ -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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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[]>;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
function unparenthesized(node) {
|
|
3
|
+
while (ts.isParenthesizedExpression(node))
|
|
4
|
+
node = node.expression;
|
|
5
|
+
return node;
|
|
6
|
+
}
|
|
7
|
+
function staticBase(node) {
|
|
8
|
+
node = unparenthesized(node);
|
|
9
|
+
while ((ts.isPropertyAccessExpression(node) && !node.questionDotToken) ||
|
|
10
|
+
(ts.isElementAccessExpression(node) && !node.questionDotToken && ts.isStringLiteralLike(node.argumentExpression)))
|
|
11
|
+
node = unparenthesized(node.expression);
|
|
12
|
+
return node;
|
|
13
|
+
}
|
|
14
|
+
function member(node) {
|
|
15
|
+
node = unparenthesized(node);
|
|
16
|
+
if (ts.isPropertyAccessExpression(node) && !node.questionDotToken)
|
|
17
|
+
return { owner: node.expression, name: node.name.text };
|
|
18
|
+
if (ts.isElementAccessExpression(node) && !node.questionDotToken && ts.isStringLiteralLike(node.argumentExpression))
|
|
19
|
+
return { owner: node.expression, name: node.argumentExpression.text };
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
function nodeName(node, name, checker) {
|
|
23
|
+
node = unparenthesized(node);
|
|
24
|
+
if (!ts.isIdentifier(node) || node.text !== name)
|
|
25
|
+
return false;
|
|
26
|
+
// In JS, getSymbolAtLocation can return the synthetic exports symbol even
|
|
27
|
+
// for a shadowed parameter. Ask for the lexical value/alias symbol instead.
|
|
28
|
+
const declarations = checker.getSymbolsInScope(node, ts.SymbolFlags.Value | ts.SymbolFlags.Alias)
|
|
29
|
+
.find(symbol => symbol.name === name)?.declarations;
|
|
30
|
+
return !declarations?.length || declarations.every(declaration => (ts.isIdentifier(declaration) && ts.isPropertyAccessExpression(declaration.parent) && declaration.parent.expression === declaration) ||
|
|
31
|
+
(declaration.getSourceFile().isDeclarationFile && declaration.getSourceFile().fileName.replaceAll("\\", "/").includes("/node_modules/@types/node/")));
|
|
32
|
+
}
|
|
33
|
+
function moduleExports(node, checker) {
|
|
34
|
+
const property = member(node);
|
|
35
|
+
return !!property && property.name === "exports" && nodeName(property.owner, "module", checker);
|
|
36
|
+
}
|
|
37
|
+
function exportTarget(node, checker) {
|
|
38
|
+
if (moduleExports(node, checker))
|
|
39
|
+
return true;
|
|
40
|
+
const property = member(node);
|
|
41
|
+
return !!property && (nodeName(property.owner, "exports", checker) || moduleExports(property.owner, checker));
|
|
42
|
+
}
|
|
43
|
+
function bindingNames(reference) {
|
|
44
|
+
const node = reference.node;
|
|
45
|
+
if (ts.isImportDeclaration(node)) {
|
|
46
|
+
const clause = node.importClause, bindings = clause?.namedBindings;
|
|
47
|
+
return [...(clause?.name ? [clause.name] : []), ...(bindings ? ts.isNamespaceImport(bindings) ? [bindings.name] :
|
|
48
|
+
bindings.elements.filter(element => !element.isTypeOnly).map(element => element.name) : [])];
|
|
49
|
+
}
|
|
50
|
+
if (ts.isImportEqualsDeclaration(node))
|
|
51
|
+
return [node.name];
|
|
52
|
+
if (reference.form === "require") {
|
|
53
|
+
let expression = node;
|
|
54
|
+
while (ts.isParenthesizedExpression(expression.parent))
|
|
55
|
+
expression = expression.parent;
|
|
56
|
+
const parent = expression.parent;
|
|
57
|
+
if (ts.isVariableDeclaration(parent) && parent.initializer === expression && ts.isIdentifier(parent.name))
|
|
58
|
+
return [parent.name];
|
|
59
|
+
}
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
function assignmentRoot(node) {
|
|
63
|
+
const parent = node.parent;
|
|
64
|
+
if (ts.isParenthesizedExpression(parent) || ts.isArrayLiteralExpression(parent) || ts.isObjectLiteralExpression(parent) ||
|
|
65
|
+
ts.isShorthandPropertyAssignment(parent) || ts.isSpreadElement(parent) || ts.isSpreadAssignment(parent) ||
|
|
66
|
+
(ts.isPropertyAssignment(parent) && parent.initializer === node))
|
|
67
|
+
return assignmentRoot(parent);
|
|
68
|
+
return node;
|
|
69
|
+
}
|
|
70
|
+
// One source walk tracks explicit forwarding and other uses of the same local
|
|
71
|
+
// binding. This is symbol-based syntax evidence, not alias/dataflow analysis.
|
|
72
|
+
export function dependencyRoles(source, checker, references) {
|
|
73
|
+
const bindings = new Map();
|
|
74
|
+
const declared = new Set(), forwardedNodes = new Set();
|
|
75
|
+
const perReference = new Map();
|
|
76
|
+
const directCalls = new Set(references.filter(reference => reference.form === "require").map(reference => reference.node));
|
|
77
|
+
for (const reference of references) {
|
|
78
|
+
const uses = [];
|
|
79
|
+
if (reference.usage !== "typeOnly")
|
|
80
|
+
for (const name of bindingNames(reference)) {
|
|
81
|
+
const symbol = checker.getSymbolAtLocation(name);
|
|
82
|
+
if (!symbol)
|
|
83
|
+
continue;
|
|
84
|
+
const use = { forwarded: false, local: false, mutated: false };
|
|
85
|
+
bindings.set(symbol, use);
|
|
86
|
+
uses.push(use);
|
|
87
|
+
declared.add(name);
|
|
88
|
+
}
|
|
89
|
+
perReference.set(reference, uses);
|
|
90
|
+
}
|
|
91
|
+
function forward(expression, symbol) {
|
|
92
|
+
const base = staticBase(expression);
|
|
93
|
+
const use = bindings.get(symbol ?? (ts.isIdentifier(base) ? checker.getSymbolAtLocation(base) : undefined));
|
|
94
|
+
if (use) {
|
|
95
|
+
use.forwarded = true;
|
|
96
|
+
forwardedNodes.add(base);
|
|
97
|
+
}
|
|
98
|
+
else if (directCalls.has(base))
|
|
99
|
+
forwardedNodes.add(base);
|
|
100
|
+
}
|
|
101
|
+
function local(node, symbol = checker.getSymbolAtLocation(node)) {
|
|
102
|
+
const use = symbol && bindings.get(symbol);
|
|
103
|
+
if (!use || declared.has(node) || forwardedNodes.has(node))
|
|
104
|
+
return;
|
|
105
|
+
use.local = true;
|
|
106
|
+
const expression = assignmentRoot(node);
|
|
107
|
+
const parent = expression.parent;
|
|
108
|
+
if ((ts.isBinaryExpression(parent) && parent.left === expression &&
|
|
109
|
+
parent.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && parent.operatorToken.kind <= ts.SyntaxKind.LastAssignment) ||
|
|
110
|
+
(ts.isVariableDeclaration(parent) && parent.name === expression && !!parent.initializer) ||
|
|
111
|
+
(ts.isBindingElement(parent) && parent.name === expression) ||
|
|
112
|
+
((ts.isForOfStatement(parent) || ts.isForInStatement(parent)) && parent.initializer === expression) ||
|
|
113
|
+
((ts.isPrefixUnaryExpression(parent) || ts.isPostfixUnaryExpression(parent)) &&
|
|
114
|
+
(parent.operator === ts.SyntaxKind.PlusPlusToken || parent.operator === ts.SyntaxKind.MinusMinusToken)))
|
|
115
|
+
use.mutated = true;
|
|
116
|
+
}
|
|
117
|
+
function visit(node) {
|
|
118
|
+
if (ts.isImportDeclaration(node) || ts.isImportEqualsDeclaration(node))
|
|
119
|
+
return;
|
|
120
|
+
if (ts.isExportDeclaration(node)) {
|
|
121
|
+
if (!node.moduleSpecifier && !node.isTypeOnly && node.exportClause && ts.isNamedExports(node.exportClause))
|
|
122
|
+
for (const element of node.exportClause.elements)
|
|
123
|
+
if (!element.isTypeOnly)
|
|
124
|
+
forward(element.propertyName ?? element.name, checker.getExportSpecifierLocalTargetSymbol(element));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (ts.isExportAssignment(node))
|
|
128
|
+
forward(node.expression);
|
|
129
|
+
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken && exportTarget(node.left, checker)) {
|
|
130
|
+
forward(node.right);
|
|
131
|
+
visit(node.right);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (ts.isShorthandPropertyAssignment(node))
|
|
135
|
+
local(node.name, checker.getShorthandAssignmentValueSymbol(node));
|
|
136
|
+
else if (ts.isIdentifier(node))
|
|
137
|
+
local(node);
|
|
138
|
+
ts.forEachChild(node, visit);
|
|
139
|
+
}
|
|
140
|
+
visit(source);
|
|
141
|
+
return new Map(references.map(reference => {
|
|
142
|
+
const uses = perReference.get(reference);
|
|
143
|
+
const roles = [];
|
|
144
|
+
if (reference.usage !== "typeOnly") {
|
|
145
|
+
const reExport = reference.form === "reExport" || forwardedNodes.has(reference.node) || uses.some(use => use.forwarded && !use.mutated);
|
|
146
|
+
if (!reExport || uses.some(use => use.local || !use.forwarded || use.mutated))
|
|
147
|
+
roles.push("implementation");
|
|
148
|
+
if (reExport)
|
|
149
|
+
roles.push("reExport");
|
|
150
|
+
}
|
|
151
|
+
return [reference.node, roles];
|
|
152
|
+
}));
|
|
153
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import type { PackageInput } from "./discovery.js";
|
|
3
|
+
import { type Dimension } from "./evidence.js";
|
|
4
|
+
import { type ModuleReference } from "./module-references.js";
|
|
5
|
+
import { type DependencyRole } from "./module-forwarding.js";
|
|
6
|
+
type Resolution = "internal" | "external" | "builtin" | "outOfScope" | "unavailableSource" | "unresolved" | "dynamic";
|
|
7
|
+
export interface DependencyObservation {
|
|
8
|
+
from: string;
|
|
9
|
+
project: string;
|
|
10
|
+
line: number;
|
|
11
|
+
sourceSpanStart: number;
|
|
12
|
+
form: ModuleReference["form"];
|
|
13
|
+
usage: ModuleReference["usage"];
|
|
14
|
+
specifier: string | null;
|
|
15
|
+
roles: DependencyRole[];
|
|
16
|
+
resolution: Resolution;
|
|
17
|
+
via: string;
|
|
18
|
+
target?: string;
|
|
19
|
+
targetFile?: string;
|
|
20
|
+
declarationTarget?: boolean;
|
|
21
|
+
}
|
|
22
|
+
export declare class ModuleGraphCollector {
|
|
23
|
+
private readonly root;
|
|
24
|
+
private readonly packages;
|
|
25
|
+
private readonly nodes;
|
|
26
|
+
private readonly dependencies;
|
|
27
|
+
private readonly scanned;
|
|
28
|
+
constructor(root: string, packages: PackageInput[]);
|
|
29
|
+
private relative;
|
|
30
|
+
forPackage(pkg: PackageInput, program: ts.Program): (source: ts.SourceFile) => void;
|
|
31
|
+
private resolveObservation;
|
|
32
|
+
finish(incompleteSource: boolean): Dimension;
|
|
33
|
+
}
|
|
34
|
+
export {};
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { isBuiltin } from "node:module";
|
|
3
|
+
import ts from "typescript";
|
|
4
|
+
import { hash } from "./evidence.js";
|
|
5
|
+
import { moduleReferences } from "./module-references.js";
|
|
6
|
+
import { canonicalFile, moduleResolver } from "./module-resolution.js";
|
|
7
|
+
import { moduleTopology } from "./module-topology.js";
|
|
8
|
+
import { dependencyRoles } from "./module-forwarding.js";
|
|
9
|
+
function reExportOnly(source) {
|
|
10
|
+
return source.statements.some(statement => ts.isExportDeclaration(statement) && !!statement.moduleSpecifier) &&
|
|
11
|
+
source.statements.every(statement => ts.isImportDeclaration(statement) || (ts.isExportDeclaration(statement) && !!statement.moduleSpecifier) ||
|
|
12
|
+
ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement) || ts.isEmptyStatement(statement) ||
|
|
13
|
+
(ts.isExpressionStatement(statement) && ts.isStringLiteral(statement.expression)));
|
|
14
|
+
}
|
|
15
|
+
export class ModuleGraphCollector {
|
|
16
|
+
root;
|
|
17
|
+
packages;
|
|
18
|
+
nodes = new Map();
|
|
19
|
+
dependencies = [];
|
|
20
|
+
scanned = new Set();
|
|
21
|
+
constructor(root, packages) {
|
|
22
|
+
this.root = root;
|
|
23
|
+
this.packages = packages;
|
|
24
|
+
for (const pkg of packages)
|
|
25
|
+
for (const file of pkg.files) {
|
|
26
|
+
const key = canonicalFile(file);
|
|
27
|
+
if (!this.nodes.has(key))
|
|
28
|
+
this.nodes.set(key, { id: this.relative(file), project: pkg.name, analyzed: false, reExportOnly: false });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
relative(file) { return path.relative(this.root, file).replaceAll("\\", "/"); }
|
|
32
|
+
forPackage(pkg, program) {
|
|
33
|
+
const resolve = moduleResolver(pkg, this.packages, program);
|
|
34
|
+
return (source) => {
|
|
35
|
+
const key = canonicalFile(source.fileName), node = this.nodes.get(key);
|
|
36
|
+
if (this.scanned.has(key))
|
|
37
|
+
return;
|
|
38
|
+
this.scanned.add(key);
|
|
39
|
+
node.analyzed = true;
|
|
40
|
+
node.reExportOnly = reExportOnly(source);
|
|
41
|
+
const references = moduleReferences(source, program.getTypeChecker());
|
|
42
|
+
const roles = dependencyRoles(source, program.getTypeChecker(), references);
|
|
43
|
+
for (const reference of references) {
|
|
44
|
+
const start = reference.node.getStart(source);
|
|
45
|
+
const observation = { from: node.id, project: node.project,
|
|
46
|
+
line: source.getLineAndCharacterOfPosition(start).line + 1, sourceSpanStart: start,
|
|
47
|
+
form: reference.form, usage: reference.usage, roles: roles.get(reference.node), specifier: reference.literal?.text ?? null,
|
|
48
|
+
resolution: "dynamic", via: "nonLiteralSpecifier" };
|
|
49
|
+
if (reference.literal)
|
|
50
|
+
this.resolveObservation(observation, source, reference.literal, resolve);
|
|
51
|
+
this.dependencies.push(observation);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
resolveObservation(observation, source, literal, resolve) {
|
|
56
|
+
if (isBuiltin(literal.text)) {
|
|
57
|
+
observation.resolution = "builtin";
|
|
58
|
+
observation.via = "nodeBuiltin";
|
|
59
|
+
observation.target = literal.text.startsWith("node:") ? literal.text : `node:${literal.text}`;
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const { resolved, via } = resolve(source, literal);
|
|
63
|
+
observation.via = via;
|
|
64
|
+
if (!resolved) {
|
|
65
|
+
observation.resolution = "unresolved";
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const file = canonicalFile(resolved.resolvedFileName);
|
|
69
|
+
const target = this.nodes.get(file);
|
|
70
|
+
observation.declarationTarget = /\.d\.[cm]?ts$/i.test(file);
|
|
71
|
+
observation.targetFile = target?.id ?? this.relative(ts.sys.realpath?.(resolved.resolvedFileName) ?? resolved.resolvedFileName);
|
|
72
|
+
if (target) {
|
|
73
|
+
observation.resolution = "internal";
|
|
74
|
+
observation.target = target.id;
|
|
75
|
+
}
|
|
76
|
+
else
|
|
77
|
+
observation.resolution = file.includes("/node_modules/") ? "external" : "outOfScope";
|
|
78
|
+
}
|
|
79
|
+
finish(incompleteSource) {
|
|
80
|
+
const nodes = [...this.nodes.values()].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
81
|
+
const byId = new Map(nodes.map(node => [node.id, node]));
|
|
82
|
+
const dependencies = this.dependencies.map(item => item.resolution === "internal" && !byId.get(item.target).analyzed ?
|
|
83
|
+
{ ...item, resolution: "unavailableSource" } : item)
|
|
84
|
+
.sort((a, b) => a.from < b.from ? -1 : a.from > b.from ? 1 : a.sourceSpanStart - b.sourceSpanStart);
|
|
85
|
+
const topology = moduleTopology(nodes.map(node => node.id), dependencies);
|
|
86
|
+
const degrees = new Map(topology.modules.map(node => [node.id, node]));
|
|
87
|
+
const counts = Object.fromEntries(['internal', 'external', 'builtin', 'outOfScope', 'unavailableSource', 'unresolved', 'dynamic']
|
|
88
|
+
.map(resolution => [resolution, dependencies.filter(item => item.resolution === resolution).length]));
|
|
89
|
+
const literalOccurrences = dependencies.length - counts.dynamic;
|
|
90
|
+
const gaps = incompleteSource || this.scanned.size < nodes.length || counts.unresolved + counts.dynamic + counts.outOfScope + counts.unavailableSource > 0;
|
|
91
|
+
const cycles = topology.cycles.map(modules => ({ id: hash(modules.join("\n")), modules }));
|
|
92
|
+
const findings = cycles.map(cycle => ({
|
|
93
|
+
ruleId: "javascript-typescript/architecture/moduleDependencyCycle", category: "moduleDependencyCycle",
|
|
94
|
+
fingerprint: hash(`javascript-typescript/architecture/moduleDependencyCycle|${cycle.modules.join("|")}`),
|
|
95
|
+
severity: "info", confidence: "high", file: cycle.modules[0], project: byId.get(cycle.modules[0]).project,
|
|
96
|
+
message: `${cycle.modules.length} module(s) form a cyclic value-dependency group; review loading behavior and intent.`,
|
|
97
|
+
observations: { modules: cycle.modules, classification: "reviewLead", scoreDisposition: "excludedUncalibrated",
|
|
98
|
+
countingUnit: "stronglyConnectedComponent", executionOrderEstablished: false }
|
|
99
|
+
}));
|
|
100
|
+
return { status: incompleteSource ? "failed" : "skipped", basis: incompleteSource ?
|
|
101
|
+
"Incomplete source analysis; module graph is diagnostic only." : "Module dependency evidence collected; architecture scoring is not calibrated.",
|
|
102
|
+
findings, scope: { id: "javascript-typescript/architecture/module-graph-v1", coverage: "partial",
|
|
103
|
+
includes: ["source-module-references", "explicit-type-only-dependencies", "internal-value-cycles", "internal-value-fan-in-out"],
|
|
104
|
+
excludes: ["architecture-score", "runtime-loading-order", "comprehensive-dynamic-resolution", "cohesion", "solid-compliance"] },
|
|
105
|
+
dependencyGraph: { version: 1, graphKind: "source-value-dependencies", coverage: {
|
|
106
|
+
status: gaps ? "gaps" : "observedDependenciesResolved", selectedModules: nodes.length, analyzedModules: this.scanned.size,
|
|
107
|
+
dependencyOccurrences: dependencies.length, literalOccurrences, resolvedLiteralOccurrences: literalOccurrences - counts.unresolved,
|
|
108
|
+
literalResolutionPercent: literalOccurrences ? 100 * (literalOccurrences - counts.unresolved) / literalOccurrences : null,
|
|
109
|
+
occurrencesByResolution: counts, explicitTypeOnlyOccurrences: dependencies.filter(item => item.usage === "typeOnly").length
|
|
110
|
+
},
|
|
111
|
+
resolutionEnvironment: { typescriptVersion: ts.version, nodeVersion: process.versions.node },
|
|
112
|
+
limitations: ["Value/mixed references are source syntax, not proof of emitted runtime imports.",
|
|
113
|
+
"Graph scope contains selected source modules only; external and excluded targets are not traversed.",
|
|
114
|
+
"Literal calls contribute dependency edges without establishing when or whether they execute.",
|
|
115
|
+
"Require aliases, custom loaders, evaluated strings and bundler-specific resolution are not inferred.",
|
|
116
|
+
"TypeScript resolution may select declarations; declarationTarget identifies these observations.",
|
|
117
|
+
"Ranked fan-in/out lists are observations, not calibrated defects or score deductions."],
|
|
118
|
+
nodes: nodes.map(node => ({ ...node, ...degrees.get(node.id) })), dependencies, cycles,
|
|
119
|
+
dependencyViews: topology.dependencyViews,
|
|
120
|
+
highestFanOut: topology.highestFanOut, highestFanIn: topology.highestFanIn } };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
export interface ModuleReference {
|
|
3
|
+
node: ts.Node;
|
|
4
|
+
literal?: ts.StringLiteralLike;
|
|
5
|
+
form: "import" | "reExport" | "importEquals" | "importType" | "require" | "dynamicImport";
|
|
6
|
+
usage: "value" | "typeOnly" | "mixed";
|
|
7
|
+
}
|
|
8
|
+
export declare function moduleReferences(source: ts.SourceFile, checker: ts.TypeChecker): ModuleReference[];
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
function bindingUsage(typeOnly, elements, hasDefault = false) {
|
|
3
|
+
if (typeOnly)
|
|
4
|
+
return "typeOnly";
|
|
5
|
+
const typed = elements?.filter(element => element.isTypeOnly).length ?? 0;
|
|
6
|
+
if (typed && typed === elements.length && !hasDefault)
|
|
7
|
+
return "typeOnly";
|
|
8
|
+
return typed ? "mixed" : "value";
|
|
9
|
+
}
|
|
10
|
+
function ambientNodeName(node, checker) {
|
|
11
|
+
const declarations = checker.getSymbolAtLocation(node)?.declarations;
|
|
12
|
+
return !declarations?.length || declarations.every(declaration => declaration.getSourceFile().isDeclarationFile &&
|
|
13
|
+
declaration.getSourceFile().fileName.replaceAll("\\", "/").includes("/node_modules/@types/node/"));
|
|
14
|
+
}
|
|
15
|
+
function isRequire(node, checker) {
|
|
16
|
+
if (ts.isIdentifier(node))
|
|
17
|
+
return node.text === "require" && ambientNodeName(node, checker);
|
|
18
|
+
return ts.isPropertyAccessExpression(node) && node.name.text === "require" && ts.isIdentifier(node.expression) &&
|
|
19
|
+
node.expression.text === "module" && ambientNodeName(node.expression, checker);
|
|
20
|
+
}
|
|
21
|
+
export function moduleReferences(source, checker) {
|
|
22
|
+
const references = [];
|
|
23
|
+
function add(node, expression, form, usage) {
|
|
24
|
+
references.push({ node, literal: expression && ts.isStringLiteralLike(expression) ? expression : undefined, form, usage });
|
|
25
|
+
}
|
|
26
|
+
function visit(node) {
|
|
27
|
+
if (ts.isImportDeclaration(node)) {
|
|
28
|
+
const clause = node.importClause;
|
|
29
|
+
const bindings = clause?.namedBindings;
|
|
30
|
+
add(node, node.moduleSpecifier, "import", bindingUsage(clause?.isTypeOnly ?? false, bindings && ts.isNamedImports(bindings) ? bindings.elements : undefined, !!clause?.name));
|
|
31
|
+
}
|
|
32
|
+
else if (ts.isExportDeclaration(node) && node.moduleSpecifier) {
|
|
33
|
+
add(node, node.moduleSpecifier, "reExport", bindingUsage(node.isTypeOnly, node.exportClause && ts.isNamedExports(node.exportClause) ? node.exportClause.elements : undefined));
|
|
34
|
+
}
|
|
35
|
+
else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference)) {
|
|
36
|
+
add(node, node.moduleReference.expression, "importEquals", node.isTypeOnly ? "typeOnly" : "value");
|
|
37
|
+
}
|
|
38
|
+
else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) {
|
|
39
|
+
add(node, node.argument.literal, "importType", "typeOnly");
|
|
40
|
+
}
|
|
41
|
+
else if (ts.isCallExpression(node)) {
|
|
42
|
+
if (node.expression.kind === ts.SyntaxKind.ImportKeyword)
|
|
43
|
+
add(node, node.arguments[0], "dynamicImport", "value");
|
|
44
|
+
else if (isRequire(node.expression, checker))
|
|
45
|
+
add(node, node.arguments[0], "require", "value");
|
|
46
|
+
}
|
|
47
|
+
ts.forEachChild(node, visit);
|
|
48
|
+
}
|
|
49
|
+
visit(source);
|
|
50
|
+
return references;
|
|
51
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import type { PackageInput } from "./discovery.js";
|
|
3
|
+
export declare const canonicalFile: (file: string) => string;
|
|
4
|
+
export declare function moduleResolver(pkg: PackageInput, packages: PackageInput[], program: ts.Program): (source: ts.SourceFile, literal: ts.StringLiteralLike) => {
|
|
5
|
+
resolved: ts.ResolvedModuleFull;
|
|
6
|
+
via: "typescript";
|
|
7
|
+
} | {
|
|
8
|
+
resolved: ts.ResolvedModuleFull | undefined;
|
|
9
|
+
via: "unresolved" | "workspaceManifest";
|
|
10
|
+
};
|