fallow-type-aware 0.0.0-bootstrap.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/LICENSE +21 -0
- package/README.md +48 -0
- package/fallow-type-aware.mjs +11 -0
- package/package.json +37 -0
- package/src/cli.mjs +167 -0
- package/src/file-identity.mjs +11 -0
- package/src/generated-protocol.mjs +27 -0
- package/src/graph-algorithms.mjs +114 -0
- package/src/project-state.mjs +194 -0
- package/src/protocol.mjs +407 -0
- package/src/response-normalization.mjs +28 -0
- package/src/semantic-identity.mjs +315 -0
- package/src/semantic.mjs +2142 -0
- package/src/symbol-impact.mjs +220 -0
- package/src/type-coupling.mjs +166 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
isCallExpression,
|
|
5
|
+
isExportDeclaration,
|
|
6
|
+
isImportDeclaration,
|
|
7
|
+
isStringLiteralLikeNode,
|
|
8
|
+
} from "typescript/unstable/ast/is";
|
|
9
|
+
|
|
10
|
+
import { canonicalFileIdentity } from "./file-identity.mjs";
|
|
11
|
+
import {
|
|
12
|
+
projectSourceFiles,
|
|
13
|
+
relativePath,
|
|
14
|
+
semanticQueryIdentity,
|
|
15
|
+
sourceFileIdentity,
|
|
16
|
+
} from "./semantic-identity.mjs";
|
|
17
|
+
|
|
18
|
+
const TEST_FILE_PATTERN = /(?:^|[/_.-])(?:test|spec)\.[cm]?[jt]sx?$/u;
|
|
19
|
+
const compareText = (left, right) => Buffer.compare(Buffer.from(left), Buffer.from(right));
|
|
20
|
+
const slash = (value) => value.split(path.sep).join("/");
|
|
21
|
+
|
|
22
|
+
const visit = (node, callback) => {
|
|
23
|
+
callback(node);
|
|
24
|
+
node.forEachChild((child) => {
|
|
25
|
+
visit(child, callback);
|
|
26
|
+
return undefined;
|
|
27
|
+
});
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const isModuleEdgeDeclaration = (node) => isImportDeclaration(node) || isExportDeclaration(node);
|
|
31
|
+
|
|
32
|
+
const moduleSpecifier = (node) => {
|
|
33
|
+
if (!isModuleEdgeDeclaration(node)) return undefined;
|
|
34
|
+
if (!node.moduleSpecifier) return undefined;
|
|
35
|
+
return isStringLiteralLikeNode(node.moduleSpecifier) ? node.moduleSpecifier.text : null;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const resolveLocalModule = (sourceFile, specifier, files) => {
|
|
39
|
+
if (!specifier?.startsWith(".")) return undefined;
|
|
40
|
+
const base = path.resolve(path.dirname(sourceFile.fileName), specifier);
|
|
41
|
+
const candidates = [
|
|
42
|
+
base,
|
|
43
|
+
`${base}.ts`,
|
|
44
|
+
`${base}.tsx`,
|
|
45
|
+
`${base}.js`,
|
|
46
|
+
`${base}.jsx`,
|
|
47
|
+
path.join(base, "index.ts"),
|
|
48
|
+
path.join(base, "index.tsx"),
|
|
49
|
+
];
|
|
50
|
+
return candidates.map(canonicalFileIdentity).find((candidate) => files.has(candidate));
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const addReverseModuleEdge = (reverse, provider, consumer) => {
|
|
54
|
+
const consumers = reverse.get(provider) ?? new Set();
|
|
55
|
+
consumers.add(consumer);
|
|
56
|
+
reverse.set(provider, consumers);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const recordModuleEdge = (reverse, sourceFile, consumer, files, specifier) => {
|
|
60
|
+
if (specifier === undefined) return;
|
|
61
|
+
const provider = resolveLocalModule(sourceFile, specifier, files);
|
|
62
|
+
if (provider) addReverseModuleEdge(reverse, provider, consumer);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const isDynamicImport = (node, sourceFile) =>
|
|
66
|
+
isCallExpression(node) && node.expression?.getText(sourceFile) === "import";
|
|
67
|
+
|
|
68
|
+
const hasUnresolvedDynamicImport = (node, sourceFile) => {
|
|
69
|
+
if (!isDynamicImport(node, sourceFile)) return false;
|
|
70
|
+
const argument = node.arguments?.[0];
|
|
71
|
+
return !argument || !isStringLiteralLikeNode(argument);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const scanModuleSource = (sourceFile, files, reverse) => {
|
|
75
|
+
const consumer = sourceFileIdentity(sourceFile);
|
|
76
|
+
let hasDynamicBehavior = false;
|
|
77
|
+
visit(sourceFile, (node) => {
|
|
78
|
+
recordModuleEdge(reverse, sourceFile, consumer, files, moduleSpecifier(node));
|
|
79
|
+
hasDynamicBehavior ||= hasUnresolvedDynamicImport(node, sourceFile);
|
|
80
|
+
});
|
|
81
|
+
return hasDynamicBehavior;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const moduleGraph = (project) => {
|
|
85
|
+
const sources = projectSourceFiles(project);
|
|
86
|
+
const files = new Set(sources.map(sourceFileIdentity));
|
|
87
|
+
const reverse = new Map();
|
|
88
|
+
let hasDynamicBehavior = false;
|
|
89
|
+
for (const sourceFile of sources) {
|
|
90
|
+
const sourceHasDynamicBehavior = scanModuleSource(sourceFile, files, reverse);
|
|
91
|
+
hasDynamicBehavior ||= sourceHasDynamicBehavior;
|
|
92
|
+
}
|
|
93
|
+
return { reverse, hasDynamicBehavior };
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const mergeReverseGraph = (target, source) => {
|
|
97
|
+
for (const [provider, consumers] of source) {
|
|
98
|
+
const combinedConsumers = target.get(provider) ?? new Set();
|
|
99
|
+
consumers.forEach((consumer) => combinedConsumers.add(consumer));
|
|
100
|
+
target.set(provider, combinedConsumers);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const combinedModuleGraph = (projects) => {
|
|
105
|
+
const reverse = new Map();
|
|
106
|
+
let hasDynamicBehavior = false;
|
|
107
|
+
for (const project of projects) {
|
|
108
|
+
const graph = moduleGraph(project);
|
|
109
|
+
hasDynamicBehavior ||= graph.hasDynamicBehavior;
|
|
110
|
+
mergeReverseGraph(reverse, graph.reverse);
|
|
111
|
+
}
|
|
112
|
+
return { reverse, hasDynamicBehavior };
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const isTestFile = (fileName) =>
|
|
116
|
+
TEST_FILE_PATTERN.test(slash(fileName)) || slash(fileName).includes("/__tests__/");
|
|
117
|
+
|
|
118
|
+
const affectedConsumer = (root, consumer, current, evidenceLimit) => {
|
|
119
|
+
const provenance = [consumer, ...current.path];
|
|
120
|
+
const relativeProvenance = provenance.map((file) => relativePath(root, file));
|
|
121
|
+
return {
|
|
122
|
+
queueEntry: { file: consumer, path: provenance },
|
|
123
|
+
result: {
|
|
124
|
+
path: relativePath(root, consumer),
|
|
125
|
+
provenance: relativeProvenance.slice(0, evidenceLimit),
|
|
126
|
+
},
|
|
127
|
+
omitted: Math.max(0, relativeProvenance.length - evidenceLimit),
|
|
128
|
+
};
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const unvisitedConsumers = (graph, file, visited) =>
|
|
132
|
+
[...(graph.reverse.get(file) ?? [])].filter((consumer) => !visited.has(consumer));
|
|
133
|
+
|
|
134
|
+
const impactClosure = (root, graph, directFiles, evidenceLimit) => {
|
|
135
|
+
const queue = [...directFiles].map((file) => ({ file, path: [file] }));
|
|
136
|
+
const visited = new Set(directFiles);
|
|
137
|
+
const affected = [];
|
|
138
|
+
const tests = [];
|
|
139
|
+
let omittedProvenanceCount = 0;
|
|
140
|
+
while (queue.length > 0) {
|
|
141
|
+
const current = queue.shift();
|
|
142
|
+
for (const consumer of unvisitedConsumers(graph, current.file, visited)) {
|
|
143
|
+
visited.add(consumer);
|
|
144
|
+
const entry = affectedConsumer(root, consumer, current, evidenceLimit);
|
|
145
|
+
queue.push(entry.queueEntry);
|
|
146
|
+
omittedProvenanceCount += entry.omitted;
|
|
147
|
+
affected.push(entry.result);
|
|
148
|
+
if (isTestFile(consumer)) tests.push(entry.result);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
affected: affected.toSorted((left, right) => compareText(left.path, right.path)),
|
|
153
|
+
tests: tests.toSorted((left, right) => compareText(left.path, right.path)),
|
|
154
|
+
omittedProvenanceCount,
|
|
155
|
+
};
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
export const analyzeSymbolImpact = (
|
|
159
|
+
{ root, query, resolved, evidenceLimit, scanProjects, directReferenceFiles },
|
|
160
|
+
{ boundedResult },
|
|
161
|
+
) => {
|
|
162
|
+
const declarationFile = sourceFileIdentity(resolved.declaration.getSourceFile());
|
|
163
|
+
const directFiles = new Set([...directReferenceFiles].filter((file) => file !== declarationFile));
|
|
164
|
+
const graph = combinedModuleGraph(scanProjects);
|
|
165
|
+
const virtualDispatchCount = resolved.contractRelations.length;
|
|
166
|
+
const hasBoundedDispatch = virtualDispatchCount > 0;
|
|
167
|
+
const closure = impactClosure(root, graph, directFiles, evidenceLimit);
|
|
168
|
+
const directTests = [...directFiles]
|
|
169
|
+
.filter(isTestFile)
|
|
170
|
+
.map((file) => {
|
|
171
|
+
const relative = relativePath(root, file);
|
|
172
|
+
return { path: relative, provenance: [relative] };
|
|
173
|
+
})
|
|
174
|
+
.toSorted((left, right) => compareText(left.path, right.path));
|
|
175
|
+
const targetedTests = [...directTests, ...closure.tests].toSorted((left, right) =>
|
|
176
|
+
compareText(left.path, right.path),
|
|
177
|
+
);
|
|
178
|
+
const directConsumers = [...directFiles]
|
|
179
|
+
.map((file) => ({
|
|
180
|
+
path: relativePath(root, file),
|
|
181
|
+
namespace: query.symbol.namespace,
|
|
182
|
+
}))
|
|
183
|
+
.toSorted((left, right) => compareText(left.path, right.path));
|
|
184
|
+
const evidence = [
|
|
185
|
+
...directConsumers.map((consumer) => ({ ...consumer, role: "direct-consumer" })),
|
|
186
|
+
...closure.affected.map((consumer) => ({ ...consumer, role: "transitive-consumer" })),
|
|
187
|
+
...targetedTests.map((test) => ({ ...test, role: "targeted-test" })),
|
|
188
|
+
];
|
|
189
|
+
return boundedResult({
|
|
190
|
+
query,
|
|
191
|
+
assertion: directConsumers.length > 0 ? "consumers-found" : "no-consumers-found",
|
|
192
|
+
evidence,
|
|
193
|
+
evidenceLimit,
|
|
194
|
+
omissions: [
|
|
195
|
+
...(hasBoundedDispatch
|
|
196
|
+
? [{ reason_code: "virtual-dispatch", count: virtualDispatchCount }]
|
|
197
|
+
: []),
|
|
198
|
+
...(graph.hasDynamicBehavior ? [{ reason_code: "dynamic-behavior", count: 1 }] : []),
|
|
199
|
+
{
|
|
200
|
+
reason_code: "evidence-limit",
|
|
201
|
+
count:
|
|
202
|
+
Math.max(0, directConsumers.length - evidenceLimit) +
|
|
203
|
+
Math.max(0, closure.affected.length - evidenceLimit) +
|
|
204
|
+
Math.max(0, targetedTests.length - evidenceLimit) +
|
|
205
|
+
closure.omittedProvenanceCount,
|
|
206
|
+
},
|
|
207
|
+
],
|
|
208
|
+
data: {
|
|
209
|
+
symbol: semanticQueryIdentity(root, query, resolved),
|
|
210
|
+
selected_project: resolved.state.config,
|
|
211
|
+
direct_consumers: directConsumers.slice(0, evidenceLimit),
|
|
212
|
+
total_direct_consumer_count: directConsumers.length,
|
|
213
|
+
transitive_affected_files: closure.affected.slice(0, evidenceLimit),
|
|
214
|
+
total_transitive_affected_file_count: closure.affected.length,
|
|
215
|
+
targeted_tests: targetedTests.slice(0, evidenceLimit),
|
|
216
|
+
total_targeted_test_count: targetedTests.length,
|
|
217
|
+
confidence: graph.hasDynamicBehavior || hasBoundedDispatch ? "bounded" : "high",
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
};
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { findCycles } from "./graph-algorithms.mjs";
|
|
2
|
+
import { projectSourceFiles, sourceFileIdentity } from "./semantic-identity.mjs";
|
|
3
|
+
|
|
4
|
+
const compareText = (left, right) => Buffer.compare(Buffer.from(left), Buffer.from(right));
|
|
5
|
+
|
|
6
|
+
const percentile = (values, ratio) => {
|
|
7
|
+
if (values.length === 0) return 0;
|
|
8
|
+
const ordered = [...values].toSorted((left, right) => left - right);
|
|
9
|
+
return ordered[Math.min(ordered.length - 1, Math.floor((ordered.length - 1) * ratio))];
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const couplingCollection = () => ({
|
|
13
|
+
edges: [],
|
|
14
|
+
projectFiles: new Set(),
|
|
15
|
+
resolvedEntryPoints: new Set(),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const collectProjectCoupling = (root, query, collected, state, services) => {
|
|
19
|
+
projectSourceFiles(state.project).forEach((sourceFile) =>
|
|
20
|
+
collected.projectFiles.add(sourceFileIdentity(sourceFile)),
|
|
21
|
+
);
|
|
22
|
+
const entries = services.discoverEntryPoints(root, state.project, query.entryPoints);
|
|
23
|
+
entries.forEach((entryPoint) =>
|
|
24
|
+
collected.resolvedEntryPoints.add(sourceFileIdentity(entryPoint)),
|
|
25
|
+
);
|
|
26
|
+
collected.edges.push(
|
|
27
|
+
...services
|
|
28
|
+
.publicApiGraph(root, state.project, entries)
|
|
29
|
+
.edges.filter((edge) => edge.source.path !== edge.target.path),
|
|
30
|
+
);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const collectTypeCoupling = (root, query, ready, services) => {
|
|
34
|
+
const collected = couplingCollection();
|
|
35
|
+
ready.forEach((state) => collectProjectCoupling(root, query, collected, state, services));
|
|
36
|
+
collected.edges = services.uniqueSorted(collected.edges, (edge) => [edge.source, edge.target]);
|
|
37
|
+
return collected;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const connectionMaps = (edges) => {
|
|
41
|
+
const outgoing = new Map();
|
|
42
|
+
const incoming = new Map();
|
|
43
|
+
for (const edge of edges) {
|
|
44
|
+
const source = edge.source.path;
|
|
45
|
+
const target = edge.target.path;
|
|
46
|
+
outgoing.set(source, (outgoing.get(source) ?? new Set()).add(target));
|
|
47
|
+
incoming.set(target, (incoming.get(target) ?? new Set()).add(source));
|
|
48
|
+
}
|
|
49
|
+
return { outgoing, incoming };
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const couplingFiles = ({ outgoing, incoming }) => {
|
|
53
|
+
const files = new Set([...outgoing.keys(), ...incoming.keys()]);
|
|
54
|
+
const perFile = [...files]
|
|
55
|
+
.map((file) => ({
|
|
56
|
+
path: file,
|
|
57
|
+
outgoing_label: "public API depends on",
|
|
58
|
+
outgoing_files: [...(outgoing.get(file) ?? [])].toSorted(compareText),
|
|
59
|
+
incoming_label: "public types used by",
|
|
60
|
+
incoming_files: [...(incoming.get(file) ?? [])].toSorted(compareText),
|
|
61
|
+
}))
|
|
62
|
+
.toSorted((left, right) => compareText(left.path, right.path));
|
|
63
|
+
return { files, perFile };
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const connectionDegree = (entry) => entry.outgoing_files.length + entry.incoming_files.length;
|
|
67
|
+
|
|
68
|
+
const topCouplingContributors = (perFile) =>
|
|
69
|
+
[...perFile]
|
|
70
|
+
.toSorted(
|
|
71
|
+
(left, right) =>
|
|
72
|
+
connectionDegree(right) - connectionDegree(left) || compareText(left.path, right.path),
|
|
73
|
+
)
|
|
74
|
+
.slice(0, 10);
|
|
75
|
+
|
|
76
|
+
const boundCoupledFile = (entry, evidenceLimit) => ({
|
|
77
|
+
...entry,
|
|
78
|
+
outgoing_files: entry.outgoing_files.slice(0, evidenceLimit),
|
|
79
|
+
total_outgoing_file_count: entry.outgoing_files.length,
|
|
80
|
+
incoming_files: entry.incoming_files.slice(0, evidenceLimit),
|
|
81
|
+
total_incoming_file_count: entry.incoming_files.length,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const nestedCouplingOmissionCount = (perFile, evidenceLimit) =>
|
|
85
|
+
perFile.reduce(
|
|
86
|
+
(count, entry) =>
|
|
87
|
+
count +
|
|
88
|
+
Math.max(0, entry.outgoing_files.length - evidenceLimit) +
|
|
89
|
+
Math.max(0, entry.incoming_files.length - evidenceLimit),
|
|
90
|
+
0,
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const percentage = (numerator, denominator) =>
|
|
94
|
+
denominator === 0 ? null : (numerator / denominator) * 100;
|
|
95
|
+
|
|
96
|
+
const couplingConcentration = (edges, contributors) => {
|
|
97
|
+
if (edges.length === 0) return 0;
|
|
98
|
+
return contributors.reduce((sum, entry) => sum + connectionDegree(entry), 0) / (edges.length * 2);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const couplingEvidence = (edges) =>
|
|
102
|
+
edges.map((edge) => ({
|
|
103
|
+
source: edge.source.path,
|
|
104
|
+
target: edge.target.path,
|
|
105
|
+
relation: edge.relation,
|
|
106
|
+
evidence: edge.evidence,
|
|
107
|
+
}));
|
|
108
|
+
|
|
109
|
+
export const analyzeTypeCoupling = ({ root, query, states, evidenceLimit }, services) => {
|
|
110
|
+
const readiness = services.readyProjectStates(query, states);
|
|
111
|
+
if (readiness.error) return readiness.error;
|
|
112
|
+
const collected = collectTypeCoupling(root, query, readiness.ready, services);
|
|
113
|
+
const { edges, projectFiles, resolvedEntryPoints } = collected;
|
|
114
|
+
const { files, perFile } = couplingFiles(connectionMaps(edges));
|
|
115
|
+
const degrees = perFile.map(connectionDegree);
|
|
116
|
+
const outgoingDegrees = perFile.map((entry) => entry.outgoing_files.length);
|
|
117
|
+
const incomingDegrees = perFile.map((entry) => entry.incoming_files.length);
|
|
118
|
+
const highCouplingThreshold = percentile(degrees, 0.9);
|
|
119
|
+
const topContributors = topCouplingContributors(perFile);
|
|
120
|
+
const cycles = query.includeCycles ? findCycles(edges) : [];
|
|
121
|
+
const unavailableProjectCount = states.length - readiness.ready.length;
|
|
122
|
+
const missingEntryPointCount = services.missingEntryPoints(query, resolvedEntryPoints);
|
|
123
|
+
const nestedFileOmissionCount = nestedCouplingOmissionCount(perFile, evidenceLimit);
|
|
124
|
+
const boundFile = (entry) => boundCoupledFile(entry, evidenceLimit);
|
|
125
|
+
return services.boundedResult({
|
|
126
|
+
query,
|
|
127
|
+
assertion: edges.length > 0 ? "coupling-found" : "no-coupling-found",
|
|
128
|
+
evidence: couplingEvidence(edges),
|
|
129
|
+
evidenceLimit,
|
|
130
|
+
data: {
|
|
131
|
+
scope: "project-local-public-signatures",
|
|
132
|
+
direction: "directed",
|
|
133
|
+
project_size: projectFiles.size,
|
|
134
|
+
files_analyzed: projectFiles.size,
|
|
135
|
+
distinct_coupled_files: files.size,
|
|
136
|
+
edge_count: edges.length,
|
|
137
|
+
coupled_file_percentage: percentage(files.size, projectFiles.size),
|
|
138
|
+
p50_distinct_connections: percentile(degrees, 0.5),
|
|
139
|
+
p90_distinct_connections: percentile(degrees, 0.9),
|
|
140
|
+
p95_public_api_depends_on: percentile(outgoingDegrees, 0.95),
|
|
141
|
+
p95_public_types_used_by: percentile(incomingDegrees, 0.95),
|
|
142
|
+
high_coupling_percentage: percentage(
|
|
143
|
+
degrees.filter((degree) => degree > highCouplingThreshold).length,
|
|
144
|
+
projectFiles.size,
|
|
145
|
+
),
|
|
146
|
+
concentration: couplingConcentration(edges, topContributors),
|
|
147
|
+
files: perFile.slice(0, evidenceLimit).map(boundFile),
|
|
148
|
+
total_file_count: perFile.length,
|
|
149
|
+
top_contributors: topContributors.map(boundFile),
|
|
150
|
+
edges: edges.slice(0, evidenceLimit),
|
|
151
|
+
cycles: cycles.slice(0, evidenceLimit),
|
|
152
|
+
total_cycle_count: cycles.length,
|
|
153
|
+
},
|
|
154
|
+
omissions: [
|
|
155
|
+
{
|
|
156
|
+
reason_code: "evidence-limit",
|
|
157
|
+
count:
|
|
158
|
+
Math.max(0, perFile.length - evidenceLimit) +
|
|
159
|
+
Math.max(0, cycles.length - evidenceLimit) +
|
|
160
|
+
nestedFileOmissionCount,
|
|
161
|
+
},
|
|
162
|
+
{ reason_code: "blocking-diagnostics", count: unavailableProjectCount },
|
|
163
|
+
{ reason_code: "unknown-entry-point", count: missingEntryPointCount },
|
|
164
|
+
],
|
|
165
|
+
});
|
|
166
|
+
};
|