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.
- package/README.md +114 -8
- package/dist/analyzer.js +37 -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-scoring.d.ts +1 -0
- package/dist/error-handling-scoring.js +15 -0
- package/dist/error-handling.d.ts +33 -0
- package/dist/error-handling.js +147 -0
- package/dist/failure-contracts.d.ts +19 -0
- package/dist/failure-contracts.js +261 -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-disposition.d.ts +9 -0
- package/dist/handler-disposition.js +156 -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
|
@@ -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 {};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Iterative SCC traversal avoids stack overflow on long import chains and emits
|
|
2
|
+
// one cyclic component, not an exponential list of individual cycle paths.
|
|
3
|
+
export function cyclicComponents(graph) {
|
|
4
|
+
const visited = new Set(), order = [];
|
|
5
|
+
for (const root of graph.keys()) {
|
|
6
|
+
const stack = [[root, false]];
|
|
7
|
+
while (stack.length) {
|
|
8
|
+
const [node, finishing] = stack.pop();
|
|
9
|
+
if (finishing) {
|
|
10
|
+
order.push(node);
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
if (visited.has(node))
|
|
14
|
+
continue;
|
|
15
|
+
visited.add(node);
|
|
16
|
+
stack.push([node, true]);
|
|
17
|
+
for (const target of graph.get(node) ?? [])
|
|
18
|
+
if (!visited.has(target))
|
|
19
|
+
stack.push([target, false]);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const reverse = new Map([...graph.keys()].map(node => [node, new Set()]));
|
|
23
|
+
for (const [node, targets] of graph)
|
|
24
|
+
for (const target of targets)
|
|
25
|
+
reverse.get(target).add(node);
|
|
26
|
+
visited.clear();
|
|
27
|
+
const components = [];
|
|
28
|
+
for (const root of order.reverse()) {
|
|
29
|
+
if (visited.has(root))
|
|
30
|
+
continue;
|
|
31
|
+
const component = [], stack = [root];
|
|
32
|
+
visited.add(root);
|
|
33
|
+
while (stack.length) {
|
|
34
|
+
const node = stack.pop();
|
|
35
|
+
component.push(node);
|
|
36
|
+
for (const target of reverse.get(node) ?? [])
|
|
37
|
+
if (!visited.has(target)) {
|
|
38
|
+
visited.add(target);
|
|
39
|
+
stack.push(target);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (component.length > 1 || graph.get(root)?.has(root))
|
|
43
|
+
components.push(component.sort());
|
|
44
|
+
}
|
|
45
|
+
return components.sort((a, b) => a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0);
|
|
46
|
+
}
|
|
47
|
+
function referenceView(ids, edges) {
|
|
48
|
+
const graph = new Map(ids.map(id => [id, new Set()]));
|
|
49
|
+
const incoming = new Map(ids.map(id => [id, new Set()]));
|
|
50
|
+
for (const edge of edges)
|
|
51
|
+
if (edge.resolution === "internal" && edge.usage !== "typeOnly" && edge.target) {
|
|
52
|
+
graph.get(edge.from).add(edge.target);
|
|
53
|
+
incoming.get(edge.target).add(edge.from);
|
|
54
|
+
}
|
|
55
|
+
const modules = ids.map(id => ({ id, internalValueFanOut: graph.get(id).size, internalValueFanIn: incoming.get(id).size }));
|
|
56
|
+
const rank = (key) => modules.filter(item => item[key] > 0)
|
|
57
|
+
.sort((a, b) => b[key] - a[key] || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)).slice(0, 10);
|
|
58
|
+
return { graph, modules, internalValueEdgeCount: modules.reduce((sum, node) => sum + node.internalValueFanOut, 0),
|
|
59
|
+
highestFanOut: rank("internalValueFanOut"), highestFanIn: rank("internalValueFanIn") };
|
|
60
|
+
}
|
|
61
|
+
export function moduleTopology(ids, edges) {
|
|
62
|
+
const { graph, internalValueEdgeCount, ...overall } = referenceView(ids, edges);
|
|
63
|
+
const { graph: _imports, ...implementation } = referenceView(ids, edges.filter(edge => edge.roles.includes("implementation")));
|
|
64
|
+
const { graph: _exports, ...reExports } = referenceView(ids, edges.filter(edge => edge.roles.includes("reExport")));
|
|
65
|
+
return { ...overall, cycles: cyclicComponents(graph), dependencyViews: {
|
|
66
|
+
version: 2, classification: "explicit-forwarding-v2",
|
|
67
|
+
implementation: { basis: "Imports/loads with local uses or no recognized forwarding; unsupported alias flow remains here.", ...implementation },
|
|
68
|
+
reExports: { basis: "Direct re-exports and explicit forwarding through an unmodified local import/require binding or direct require call.", ...reExports },
|
|
69
|
+
totalInternalValueEdgeCount: internalValueEdgeCount,
|
|
70
|
+
sharedInternalValueEdgeCount: implementation.internalValueEdgeCount + reExports.internalValueEdgeCount - internalValueEdgeCount
|
|
71
|
+
} };
|
|
72
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import type { Finding } from "./evidence.js";
|
|
3
|
+
type ReactObservation = Pick<Finding, "category" | "message" | "observations" | "confidence">;
|
|
4
|
+
export declare function importedHookName(call: ts.CallExpression, checker: ts.TypeChecker): string | undefined;
|
|
5
|
+
export declare function inspectReactCall(call: ts.CallExpression, body: ts.ConciseBody, checker: ts.TypeChecker): ReactObservation[];
|
|
6
|
+
export {};
|