codemetrics-ai 0.3.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 +98 -8
- package/dist/analyzer.js +35 -27
- 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/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/package.json +4 -3
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
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
export const canonicalFile = (file) => {
|
|
4
|
+
const resolved = path.resolve(ts.sys.realpath?.(file) ?? file).replaceAll("\\", "/");
|
|
5
|
+
return ts.sys.useCaseSensitiveFileNames ? resolved : resolved.toLowerCase();
|
|
6
|
+
};
|
|
7
|
+
// A failed installed-package lookup can mean an intentionally private subpath
|
|
8
|
+
// or missing build output. A same-named workspace is not a substitute version.
|
|
9
|
+
function hasInstalledPackage(specifier, importer) {
|
|
10
|
+
if (specifier.startsWith(".") || path.isAbsolute(specifier))
|
|
11
|
+
return false;
|
|
12
|
+
const parts = specifier.split("/");
|
|
13
|
+
const name = parts.slice(0, specifier.startsWith("@") ? 2 : 1).join("/");
|
|
14
|
+
for (let directory = path.dirname(importer);; directory = path.dirname(directory)) {
|
|
15
|
+
if (ts.sys.directoryExists(path.join(directory, "node_modules", name)))
|
|
16
|
+
return true;
|
|
17
|
+
if (path.dirname(directory) === directory)
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
// A read-only virtual node_modules view lets TypeScript interpret workspace
|
|
22
|
+
// manifests/exports without installing packages or guessing source entry points.
|
|
23
|
+
function workspaceHost(packages) {
|
|
24
|
+
const names = new Map();
|
|
25
|
+
const ambiguous = new Set();
|
|
26
|
+
for (const pkg of packages) {
|
|
27
|
+
if (names.has(pkg.name))
|
|
28
|
+
ambiguous.add(pkg.name);
|
|
29
|
+
else
|
|
30
|
+
names.set(pkg.name, pkg.root);
|
|
31
|
+
}
|
|
32
|
+
for (const name of ambiguous)
|
|
33
|
+
names.delete(name);
|
|
34
|
+
function mapped(file) {
|
|
35
|
+
const normalized = file.replaceAll("\\", "/");
|
|
36
|
+
const index = normalized.lastIndexOf("/node_modules/");
|
|
37
|
+
if (index < 0)
|
|
38
|
+
return undefined;
|
|
39
|
+
const tail = normalized.slice(index + 14).split("/");
|
|
40
|
+
const count = tail[0].startsWith("@") ? 2 : 1;
|
|
41
|
+
const root = names.get(tail.slice(0, count).join("/"));
|
|
42
|
+
return root ? path.join(root, ...tail.slice(count)) : undefined;
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
fileExists: file => ts.sys.fileExists(mapped(file) ?? file),
|
|
46
|
+
readFile: file => ts.sys.readFile(mapped(file) ?? file),
|
|
47
|
+
directoryExists: directory => {
|
|
48
|
+
const normalized = directory.replaceAll("\\", "/").replace(/\/$/, "");
|
|
49
|
+
if (normalized.endsWith("/node_modules"))
|
|
50
|
+
return names.size > 0;
|
|
51
|
+
const scope = normalized.match(/\/node_modules\/(@[^/]+)$/)?.[1];
|
|
52
|
+
if (scope && [...names.keys()].some(name => name.startsWith(scope + "/")))
|
|
53
|
+
return true;
|
|
54
|
+
return ts.sys.directoryExists(mapped(directory) ?? directory);
|
|
55
|
+
},
|
|
56
|
+
realpath: file => ts.sys.realpath?.(mapped(file) ?? file) ?? mapped(file) ?? file,
|
|
57
|
+
getCurrentDirectory: ts.sys.getCurrentDirectory,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
export function moduleResolver(pkg, packages, program) {
|
|
61
|
+
const options = pkg.selection === "default-source-scan" ? { ...pkg.options, moduleResolution: ts.ModuleResolutionKind.Node10 } : pkg.options;
|
|
62
|
+
const canonical = (file) => ts.sys.useCaseSensitiveFileNames ? file : file.toLowerCase();
|
|
63
|
+
const cache = ts.createModuleResolutionCache(pkg.root, canonical, options);
|
|
64
|
+
const workspaceCache = ts.createModuleResolutionCache(pkg.root, canonical, options);
|
|
65
|
+
const host = workspaceHost(packages);
|
|
66
|
+
return (source, literal) => {
|
|
67
|
+
const mode = program.getModeForUsageLocation(source, literal);
|
|
68
|
+
const ordinary = ts.resolveModuleName(literal.text, source.fileName, options, ts.sys, cache, undefined, mode).resolvedModule;
|
|
69
|
+
if (ordinary)
|
|
70
|
+
return { resolved: ordinary, via: "typescript" };
|
|
71
|
+
if (hasInstalledPackage(literal.text, source.fileName))
|
|
72
|
+
return { resolved: undefined, via: "unresolved" };
|
|
73
|
+
const workspace = ts.resolveModuleName(literal.text, source.fileName, options, host, workspaceCache, undefined, mode).resolvedModule;
|
|
74
|
+
return { resolved: workspace, via: workspace ? "workspaceManifest" : "unresolved" };
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
export declare function cyclicComponents(graph: Map<string, Set<string>>): string[][];
|
|
2
|
+
interface DependencyEdge {
|
|
3
|
+
from: string;
|
|
4
|
+
target?: string;
|
|
5
|
+
usage: string;
|
|
6
|
+
resolution: string;
|
|
7
|
+
form: string;
|
|
8
|
+
roles: string[];
|
|
9
|
+
}
|
|
10
|
+
export declare function moduleTopology(ids: string[], edges: DependencyEdge[]): {
|
|
11
|
+
cycles: string[][];
|
|
12
|
+
dependencyViews: {
|
|
13
|
+
version: number;
|
|
14
|
+
classification: string;
|
|
15
|
+
implementation: {
|
|
16
|
+
modules: {
|
|
17
|
+
id: string;
|
|
18
|
+
internalValueFanOut: number;
|
|
19
|
+
internalValueFanIn: number;
|
|
20
|
+
}[];
|
|
21
|
+
internalValueEdgeCount: number;
|
|
22
|
+
highestFanOut: {
|
|
23
|
+
id: string;
|
|
24
|
+
internalValueFanOut: number;
|
|
25
|
+
internalValueFanIn: number;
|
|
26
|
+
}[];
|
|
27
|
+
highestFanIn: {
|
|
28
|
+
id: string;
|
|
29
|
+
internalValueFanOut: number;
|
|
30
|
+
internalValueFanIn: number;
|
|
31
|
+
}[];
|
|
32
|
+
basis: string;
|
|
33
|
+
};
|
|
34
|
+
reExports: {
|
|
35
|
+
modules: {
|
|
36
|
+
id: string;
|
|
37
|
+
internalValueFanOut: number;
|
|
38
|
+
internalValueFanIn: number;
|
|
39
|
+
}[];
|
|
40
|
+
internalValueEdgeCount: number;
|
|
41
|
+
highestFanOut: {
|
|
42
|
+
id: string;
|
|
43
|
+
internalValueFanOut: number;
|
|
44
|
+
internalValueFanIn: number;
|
|
45
|
+
}[];
|
|
46
|
+
highestFanIn: {
|
|
47
|
+
id: string;
|
|
48
|
+
internalValueFanOut: number;
|
|
49
|
+
internalValueFanIn: number;
|
|
50
|
+
}[];
|
|
51
|
+
basis: string;
|
|
52
|
+
};
|
|
53
|
+
totalInternalValueEdgeCount: number;
|
|
54
|
+
sharedInternalValueEdgeCount: number;
|
|
55
|
+
};
|
|
56
|
+
modules: {
|
|
57
|
+
id: string;
|
|
58
|
+
internalValueFanOut: number;
|
|
59
|
+
internalValueFanIn: number;
|
|
60
|
+
}[];
|
|
61
|
+
highestFanOut: {
|
|
62
|
+
id: string;
|
|
63
|
+
internalValueFanOut: number;
|
|
64
|
+
internalValueFanIn: number;
|
|
65
|
+
}[];
|
|
66
|
+
highestFanIn: {
|
|
67
|
+
id: string;
|
|
68
|
+
internalValueFanOut: number;
|
|
69
|
+
internalValueFanIn: number;
|
|
70
|
+
}[];
|
|
71
|
+
};
|
|
72
|
+
export {};
|