@autotests/playwright-impact 0.1.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 +117 -0
- package/package.json +33 -0
- package/src/analyze-impacted-specs.js +294 -0
- package/src/format-analyze-result.js +29 -0
- package/src/index.d.ts +74 -0
- package/src/index.js +10 -0
- package/src/modules/class-impact-helpers.js +86 -0
- package/src/modules/file-and-git-helpers.js +234 -0
- package/src/modules/fixture-map-helpers.js +167 -0
- package/src/modules/global-watch-helpers.js +278 -0
- package/src/modules/import-impact-helpers.js +236 -0
- package/src/modules/method-filter-helpers.js +338 -0
- package/src/modules/method-impact-helpers.js +757 -0
- package/src/modules/shell.js +24 -0
- package/src/modules/spec-selection-helpers.js +73 -0
- package/tests/_test-helpers.js +45 -0
- package/tests/analyze-impacted-specs.integration.test.js +477 -0
- package/tests/analyze-impacted-specs.test.js +36 -0
- package/tests/class-impact-helpers.test.js +101 -0
- package/tests/file-and-git-helpers.test.js +140 -0
- package/tests/file-status-compat.test.js +55 -0
- package/tests/fixture-map-helpers.test.js +118 -0
- package/tests/format-analyze-result.test.js +26 -0
- package/tests/global-watch-helpers.test.js +92 -0
- package/tests/method-filter-helpers.test.js +316 -0
- package/tests/method-impact-helpers.test.js +195 -0
- package/tests/semantic-coverage-matrix.test.js +381 -0
- package/tests/spec-selection-helpers.test.js +115 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const ts = require('typescript');
|
|
6
|
+
|
|
7
|
+
const DEFAULT_EXTENSIONS = ['.ts', '.tsx'];
|
|
8
|
+
|
|
9
|
+
const normalizePath = (filePath) => filePath.split(path.sep).join('/');
|
|
10
|
+
|
|
11
|
+
const toAbsolute = (repoRoot, relativePath) => path.resolve(repoRoot, normalizePath(relativePath));
|
|
12
|
+
|
|
13
|
+
const readTsConfigPathAliases = (repoRoot) => {
|
|
14
|
+
const tsConfigPath = path.join(repoRoot, 'tsconfig.json');
|
|
15
|
+
if (!fs.existsSync(tsConfigPath)) return [];
|
|
16
|
+
|
|
17
|
+
const rawText = fs.readFileSync(tsConfigPath, 'utf8');
|
|
18
|
+
const parsedResult = ts.parseConfigFileTextToJson(tsConfigPath, rawText);
|
|
19
|
+
const parsed = parsedResult?.config;
|
|
20
|
+
if (!parsed || typeof parsed !== 'object') return [];
|
|
21
|
+
|
|
22
|
+
const baseUrl = parsed?.compilerOptions?.baseUrl || '.';
|
|
23
|
+
const paths = parsed?.compilerOptions?.paths || {};
|
|
24
|
+
const entries = [];
|
|
25
|
+
|
|
26
|
+
for (const [aliasPattern, targets] of Object.entries(paths)) {
|
|
27
|
+
if (!Array.isArray(targets) || targets.length === 0) continue;
|
|
28
|
+
const hasWildcard = aliasPattern.includes('*');
|
|
29
|
+
const [aliasPrefix, aliasSuffix] = hasWildcard ? aliasPattern.split('*') : [aliasPattern, ''];
|
|
30
|
+
|
|
31
|
+
entries.push({
|
|
32
|
+
aliasPattern,
|
|
33
|
+
aliasPrefix,
|
|
34
|
+
aliasSuffix,
|
|
35
|
+
hasWildcard,
|
|
36
|
+
targets: targets.map((targetPattern) => {
|
|
37
|
+
const [targetPrefix, targetSuffix] = targetPattern.includes('*')
|
|
38
|
+
? targetPattern.split('*')
|
|
39
|
+
: [targetPattern, ''];
|
|
40
|
+
return { targetPrefix, targetSuffix };
|
|
41
|
+
}),
|
|
42
|
+
baseUrlAbs: path.resolve(repoRoot, baseUrl),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return entries;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const getCandidateFilePaths = (basePath, fileExtensions) => {
|
|
50
|
+
const ext = path.extname(basePath).toLowerCase();
|
|
51
|
+
if (fileExtensions.includes(ext)) return [basePath];
|
|
52
|
+
|
|
53
|
+
const candidates = [];
|
|
54
|
+
candidates.push(...fileExtensions.map((fileExt) => `${basePath}${fileExt}`));
|
|
55
|
+
candidates.push(...fileExtensions.map((fileExt) => path.join(basePath, `index${fileExt}`)));
|
|
56
|
+
return candidates;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const resolveModuleSpecifier = ({ importerAbsPath, moduleSpecifier, repoRoot, fileExtensions, aliasEntries }) => {
|
|
60
|
+
const resolved = [];
|
|
61
|
+
|
|
62
|
+
const addResolvedFromBase = (basePathAbs) => {
|
|
63
|
+
for (const candidate of getCandidateFilePaths(basePathAbs, fileExtensions)) {
|
|
64
|
+
if (!fs.existsSync(candidate)) continue;
|
|
65
|
+
resolved.push(path.resolve(candidate));
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
if (moduleSpecifier.startsWith('.')) {
|
|
70
|
+
const importerDir = path.dirname(importerAbsPath);
|
|
71
|
+
addResolvedFromBase(path.resolve(importerDir, moduleSpecifier));
|
|
72
|
+
return Array.from(new Set(resolved));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Support asset-like references passed as plain strings inside helpers,
|
|
76
|
+
// for example: "getAcademicPeriods.completed.json".
|
|
77
|
+
const looksLikeFileName = /\.[A-Za-z0-9]+$/.test(moduleSpecifier);
|
|
78
|
+
if (looksLikeFileName) {
|
|
79
|
+
// Resolve "file.ext" literals from current and parent directories.
|
|
80
|
+
// This supports helpers that reference shared mock assets from parent folders.
|
|
81
|
+
let currentDir = path.dirname(importerAbsPath);
|
|
82
|
+
const repoRootAbs = path.resolve(repoRoot);
|
|
83
|
+
while (currentDir.startsWith(repoRootAbs)) {
|
|
84
|
+
const candidate = path.resolve(currentDir, moduleSpecifier);
|
|
85
|
+
if (fs.existsSync(candidate)) resolved.push(candidate);
|
|
86
|
+
if (currentDir === repoRootAbs) break;
|
|
87
|
+
currentDir = path.dirname(currentDir);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
for (const alias of aliasEntries) {
|
|
92
|
+
if (!alias.hasWildcard) {
|
|
93
|
+
if (moduleSpecifier !== alias.aliasPattern) continue;
|
|
94
|
+
for (const target of alias.targets) {
|
|
95
|
+
addResolvedFromBase(path.resolve(alias.baseUrlAbs, target.targetPrefix));
|
|
96
|
+
}
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const startsOk = moduleSpecifier.startsWith(alias.aliasPrefix);
|
|
101
|
+
const endsOk = moduleSpecifier.endsWith(alias.aliasSuffix);
|
|
102
|
+
if (!startsOk || !endsOk) continue;
|
|
103
|
+
|
|
104
|
+
const wildcardValue = moduleSpecifier.slice(alias.aliasPrefix.length, moduleSpecifier.length - alias.aliasSuffix.length);
|
|
105
|
+
for (const target of alias.targets) {
|
|
106
|
+
const joined = `${target.targetPrefix}${wildcardValue}${target.targetSuffix}`;
|
|
107
|
+
addResolvedFromBase(path.resolve(alias.baseUrlAbs, joined));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return Array.from(new Set(resolved));
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const extractImportSpecifiers = (sourceFile) => {
|
|
115
|
+
const specifiers = [];
|
|
116
|
+
|
|
117
|
+
const addText = (node) => {
|
|
118
|
+
if (!node || !ts.isStringLiteral(node)) return;
|
|
119
|
+
specifiers.push(node.text);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const visit = (node) => {
|
|
123
|
+
if (ts.isImportDeclaration(node)) addText(node.moduleSpecifier);
|
|
124
|
+
if (ts.isExportDeclaration(node) && node.moduleSpecifier) addText(node.moduleSpecifier);
|
|
125
|
+
|
|
126
|
+
if (ts.isCallExpression(node)) {
|
|
127
|
+
if (ts.isIdentifier(node.expression) && node.expression.text === 'require') {
|
|
128
|
+
addText(node.arguments?.[0]);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (node.expression.kind === ts.SyntaxKind.ImportKeyword) {
|
|
132
|
+
addText(node.arguments?.[0]);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
for (const argument of node.arguments || []) {
|
|
136
|
+
if (!ts.isStringLiteral(argument)) continue;
|
|
137
|
+
if (!/\.[A-Za-z0-9]+$/.test(argument.text)) continue;
|
|
138
|
+
specifiers.push(argument.text);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
ts.forEachChild(node, visit);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
visit(sourceFile);
|
|
146
|
+
return specifiers;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const isSpecPath = (filePath, fileExtensions) => fileExtensions.some((ext) => filePath.endsWith(`.spec${ext}`));
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Select specs impacted by changed source imports/re-exports.
|
|
153
|
+
* This stage covers helper/function modules that are not represented as fixture classes.
|
|
154
|
+
*/
|
|
155
|
+
const selectSpecsByChangedImports = ({
|
|
156
|
+
repoRoot,
|
|
157
|
+
testsRootAbs,
|
|
158
|
+
changedPomEntries,
|
|
159
|
+
listFilesRecursive,
|
|
160
|
+
fileExtensions = DEFAULT_EXTENSIONS,
|
|
161
|
+
}) => {
|
|
162
|
+
const aliasEntries = readTsConfigPathAliases(repoRoot);
|
|
163
|
+
const specFiles = listFilesRecursive(testsRootAbs).filter((filePath) => isSpecPath(filePath, fileExtensions));
|
|
164
|
+
|
|
165
|
+
const reverseDeps = new Map();
|
|
166
|
+
const visited = new Set();
|
|
167
|
+
const queue = [...specFiles];
|
|
168
|
+
|
|
169
|
+
const addReverseEdge = (dependencyAbs, importerAbs) => {
|
|
170
|
+
if (!reverseDeps.has(dependencyAbs)) reverseDeps.set(dependencyAbs, new Set());
|
|
171
|
+
reverseDeps.get(dependencyAbs).add(importerAbs);
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
while (queue.length > 0) {
|
|
175
|
+
const currentAbs = path.resolve(queue.shift());
|
|
176
|
+
if (visited.has(currentAbs)) continue;
|
|
177
|
+
visited.add(currentAbs);
|
|
178
|
+
if (!fs.existsSync(currentAbs)) continue;
|
|
179
|
+
|
|
180
|
+
let content;
|
|
181
|
+
try {
|
|
182
|
+
content = fs.readFileSync(currentAbs, 'utf8');
|
|
183
|
+
} catch (_error) {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const scriptKind = currentAbs.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
|
|
188
|
+
const sourceFile = ts.createSourceFile(currentAbs, content, ts.ScriptTarget.Latest, true, scriptKind);
|
|
189
|
+
const importSpecifiers = extractImportSpecifiers(sourceFile);
|
|
190
|
+
for (const moduleSpecifier of importSpecifiers) {
|
|
191
|
+
const dependencies = resolveModuleSpecifier({
|
|
192
|
+
importerAbsPath: currentAbs,
|
|
193
|
+
moduleSpecifier,
|
|
194
|
+
repoRoot,
|
|
195
|
+
fileExtensions,
|
|
196
|
+
aliasEntries,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
for (const dependencyAbs of dependencies) {
|
|
200
|
+
if (!dependencyAbs.startsWith(path.resolve(repoRoot))) continue;
|
|
201
|
+
addReverseEdge(dependencyAbs, currentAbs);
|
|
202
|
+
if (isSpecPath(dependencyAbs, fileExtensions)) continue;
|
|
203
|
+
queue.push(dependencyAbs);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const changedSeeds = new Set();
|
|
209
|
+
for (const entry of changedPomEntries) {
|
|
210
|
+
for (const candidate of [entry.effectivePath, entry.oldPath, entry.newPath]) {
|
|
211
|
+
if (!candidate) continue;
|
|
212
|
+
changedSeeds.add(toAbsolute(repoRoot, candidate));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const impactedSpecs = new Set();
|
|
217
|
+
const traverseQueue = Array.from(changedSeeds);
|
|
218
|
+
const traversed = new Set();
|
|
219
|
+
while (traverseQueue.length > 0) {
|
|
220
|
+
const current = traverseQueue.shift();
|
|
221
|
+
if (traversed.has(current)) continue;
|
|
222
|
+
traversed.add(current);
|
|
223
|
+
|
|
224
|
+
const importers = reverseDeps.get(current) || new Set();
|
|
225
|
+
for (const importerAbs of importers) {
|
|
226
|
+
if (isSpecPath(importerAbs, fileExtensions)) impactedSpecs.add(importerAbs);
|
|
227
|
+
if (!traversed.has(importerAbs)) traverseQueue.push(importerAbs);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return Array.from(impactedSpecs).sort((a, b) => a.localeCompare(b));
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
module.exports = {
|
|
235
|
+
selectSpecsByChangedImports,
|
|
236
|
+
};
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const ts = require('typescript');
|
|
5
|
+
|
|
6
|
+
const DEFAULT_SELECTION_BIAS = 'fail-open';
|
|
7
|
+
const MAX_PRECISE_CHAIN_DEPTH = 2;
|
|
8
|
+
|
|
9
|
+
const getFixtureKeyFromBindingElement = (element) => {
|
|
10
|
+
if (!ts.isBindingElement(element)) return null;
|
|
11
|
+
|
|
12
|
+
if (element.propertyName) {
|
|
13
|
+
if (ts.isIdentifier(element.propertyName)) return element.propertyName.text;
|
|
14
|
+
if (ts.isStringLiteral(element.propertyName) || ts.isNoSubstitutionTemplateLiteral(element.propertyName)) {
|
|
15
|
+
return element.propertyName.text;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (ts.isIdentifier(element.name)) return element.name.text;
|
|
20
|
+
return null;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const extractFixtureVariablesFromSpecAst = ({ sourceFile, fixtureKeyToClass, fixtureKeys }) => {
|
|
24
|
+
// Map fixture variable names used in test callbacks to their underlying POM class.
|
|
25
|
+
const fixtureVarToClass = new Map();
|
|
26
|
+
|
|
27
|
+
const parseBindingPattern = (bindingPattern) => {
|
|
28
|
+
for (const element of bindingPattern.elements) {
|
|
29
|
+
if (!ts.isBindingElement(element)) continue;
|
|
30
|
+
const fixtureKey = getFixtureKeyFromBindingElement(element);
|
|
31
|
+
if (!fixtureKey) continue;
|
|
32
|
+
if (!fixtureKeys.has(fixtureKey)) continue;
|
|
33
|
+
|
|
34
|
+
const className = fixtureKeyToClass.get(fixtureKey);
|
|
35
|
+
if (!className) continue;
|
|
36
|
+
|
|
37
|
+
if (ts.isIdentifier(element.name)) {
|
|
38
|
+
fixtureVarToClass.set(element.name.text, className);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (ts.isObjectBindingPattern(element.name) || ts.isArrayBindingPattern(element.name)) {
|
|
43
|
+
fixtureVarToClass.set(fixtureKey, className);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const visit = (node) => {
|
|
49
|
+
if ((ts.isArrowFunction(node) || ts.isFunctionExpression(node) || ts.isFunctionDeclaration(node)) && node.parameters.length > 0) {
|
|
50
|
+
for (const parameter of node.parameters) {
|
|
51
|
+
if (ts.isObjectBindingPattern(parameter.name)) parseBindingPattern(parameter.name);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
ts.forEachChild(node, visit);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
visit(sourceFile);
|
|
58
|
+
return fixtureVarToClass;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const getLiteralNameFromArgumentExpression = (node) => {
|
|
62
|
+
if (!node) return null;
|
|
63
|
+
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
|
|
64
|
+
return null;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const getRootIdentifierName = (node) => {
|
|
68
|
+
let current = node;
|
|
69
|
+
while (current) {
|
|
70
|
+
if (ts.isIdentifier(current)) return current.text;
|
|
71
|
+
if (ts.isPropertyAccessExpression(current) || ts.isElementAccessExpression(current)) {
|
|
72
|
+
current = current.expression;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (ts.isParenthesizedExpression(current)) {
|
|
76
|
+
current = current.expression;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const getAccessChainDepth = (node) => {
|
|
85
|
+
let depth = 0;
|
|
86
|
+
let current = node;
|
|
87
|
+
while (current && (ts.isPropertyAccessExpression(current) || ts.isElementAccessExpression(current))) {
|
|
88
|
+
depth += 1;
|
|
89
|
+
current = current.expression;
|
|
90
|
+
}
|
|
91
|
+
return depth;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Stage B matcher for a single spec AST.
|
|
96
|
+
* Produces:
|
|
97
|
+
* - precise matches for known fixtureVar.method() patterns
|
|
98
|
+
* - uncertain call-site count for dynamic/alias/deep patterns
|
|
99
|
+
*/
|
|
100
|
+
const collectImpactedMethodMatchesInSpec = ({ sourceFile, fixtureVarToClass, impactedMethodsByClass, selectionBias }) => {
|
|
101
|
+
// Stage B checks fixtureVar.method(), fixtureVar[method](), and optional call-chain forms.
|
|
102
|
+
const preciseMatches = new Set();
|
|
103
|
+
let uncertainCallSites = 0;
|
|
104
|
+
const aliasCalls = new Map();
|
|
105
|
+
|
|
106
|
+
const includeUncertain = selectionBias === 'fail-open';
|
|
107
|
+
|
|
108
|
+
const tryRegisterAlias = (aliasName, className, methodName, isUncertain) => {
|
|
109
|
+
if (!aliasName || !className) return;
|
|
110
|
+
aliasCalls.set(aliasName, {
|
|
111
|
+
className,
|
|
112
|
+
methodName: methodName || null,
|
|
113
|
+
isUncertain: Boolean(isUncertain || !methodName),
|
|
114
|
+
});
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const visit = (node) => {
|
|
118
|
+
if (ts.isVariableDeclaration(node)) {
|
|
119
|
+
if (ts.isIdentifier(node.name) && node.initializer) {
|
|
120
|
+
if (ts.isPropertyAccessExpression(node.initializer) || ts.isElementAccessExpression(node.initializer)) {
|
|
121
|
+
const rootIdentifier = getRootIdentifierName(node.initializer.expression);
|
|
122
|
+
const className = rootIdentifier ? fixtureVarToClass.get(rootIdentifier) : null;
|
|
123
|
+
if (className) {
|
|
124
|
+
const methodName = ts.isPropertyAccessExpression(node.initializer)
|
|
125
|
+
? (ts.isIdentifier(node.initializer.name) ? node.initializer.name.text : null)
|
|
126
|
+
: getLiteralNameFromArgumentExpression(node.initializer.argumentExpression);
|
|
127
|
+
const uncertain = true;
|
|
128
|
+
tryRegisterAlias(node.name.text, className, methodName, uncertain);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (ts.isObjectBindingPattern(node.name) && node.initializer && ts.isIdentifier(node.initializer)) {
|
|
134
|
+
const className = fixtureVarToClass.get(node.initializer.text);
|
|
135
|
+
if (className) {
|
|
136
|
+
for (const element of node.name.elements) {
|
|
137
|
+
if (!ts.isBindingElement(element)) continue;
|
|
138
|
+
const methodName = element.propertyName && ts.isIdentifier(element.propertyName)
|
|
139
|
+
? element.propertyName.text
|
|
140
|
+
: (ts.isIdentifier(element.name) ? element.name.text : null);
|
|
141
|
+
const aliasName = ts.isIdentifier(element.name) ? element.name.text : null;
|
|
142
|
+
tryRegisterAlias(aliasName, className, methodName, true);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const isCallLike = ts.isCallExpression(node) || (typeof ts.isCallChain === 'function' && ts.isCallChain(node));
|
|
149
|
+
if (isCallLike && ts.isIdentifier(node.expression)) {
|
|
150
|
+
const alias = aliasCalls.get(node.expression.text);
|
|
151
|
+
if (alias) {
|
|
152
|
+
const impactedMethods = impactedMethodsByClass.get(alias.className) || new Set();
|
|
153
|
+
if (alias.methodName && impactedMethods.has(alias.methodName) && !alias.isUncertain) {
|
|
154
|
+
preciseMatches.add(`${alias.className}.${alias.methodName}`);
|
|
155
|
+
} else {
|
|
156
|
+
uncertainCallSites += 1;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (isCallLike && (ts.isPropertyAccessExpression(node.expression) || ts.isElementAccessExpression(node.expression))) {
|
|
162
|
+
const calleeExpression = node.expression;
|
|
163
|
+
const objectExpr = calleeExpression.expression;
|
|
164
|
+
const rootIdentifier = getRootIdentifierName(objectExpr);
|
|
165
|
+
const className = rootIdentifier ? fixtureVarToClass.get(rootIdentifier) : null;
|
|
166
|
+
|
|
167
|
+
if (className) {
|
|
168
|
+
const impactedMethods = impactedMethodsByClass.get(className) || new Set();
|
|
169
|
+
const chainDepth = getAccessChainDepth(objectExpr);
|
|
170
|
+
const tooDeepForPrecise = chainDepth > MAX_PRECISE_CHAIN_DEPTH;
|
|
171
|
+
|
|
172
|
+
if (ts.isPropertyAccessExpression(calleeExpression) && ts.isIdentifier(calleeExpression.name)) {
|
|
173
|
+
const methodName = calleeExpression.name.text;
|
|
174
|
+
if (tooDeepForPrecise) {
|
|
175
|
+
// Deep chains are intentionally uncertain to avoid false-precise matches.
|
|
176
|
+
uncertainCallSites += 1;
|
|
177
|
+
} else if (impactedMethods.has(methodName)) {
|
|
178
|
+
preciseMatches.add(`${className}.${methodName}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (ts.isElementAccessExpression(calleeExpression)) {
|
|
183
|
+
const methodName = getLiteralNameFromArgumentExpression(calleeExpression.argumentExpression);
|
|
184
|
+
if (methodName) {
|
|
185
|
+
if (tooDeepForPrecise) {
|
|
186
|
+
uncertainCallSites += 1;
|
|
187
|
+
} else if (impactedMethods.has(methodName)) {
|
|
188
|
+
preciseMatches.add(`${className}.${methodName}`);
|
|
189
|
+
}
|
|
190
|
+
} else {
|
|
191
|
+
uncertainCallSites += 1;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
ts.forEachChild(node, visit);
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
visit(sourceFile);
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
preciseMatches: Array.from(preciseMatches).sort((a, b) => a.localeCompare(b)),
|
|
204
|
+
uncertainCallSites,
|
|
205
|
+
shouldIncludeByUncertain: includeUncertain && uncertainCallSites > 0,
|
|
206
|
+
};
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Final Stage B spec filter.
|
|
211
|
+
* Policy:
|
|
212
|
+
* - direct changed specs are always retained
|
|
213
|
+
* - precise matches are retained
|
|
214
|
+
* - uncertain-only specs are retained only in fail-open mode
|
|
215
|
+
*/
|
|
216
|
+
const filterSpecsByImpactedMethods = ({
|
|
217
|
+
selectedSpecs,
|
|
218
|
+
directChangedSpecsAbs,
|
|
219
|
+
alwaysIncludeSpecsAbs = [],
|
|
220
|
+
fixtureKeyToClass,
|
|
221
|
+
fixtureKeys,
|
|
222
|
+
impactedMethodsByClass,
|
|
223
|
+
selectionBias = DEFAULT_SELECTION_BIAS,
|
|
224
|
+
}) => {
|
|
225
|
+
// Stage B keeps direct changed specs unconditionally and filters the rest by impacted calls.
|
|
226
|
+
if (selectedSpecs.length === 0) {
|
|
227
|
+
return {
|
|
228
|
+
filteredSpecs: [],
|
|
229
|
+
droppedByMethodFilter: 0,
|
|
230
|
+
retainedWithoutMethodFilter: 0,
|
|
231
|
+
selectionReasons: new Map(),
|
|
232
|
+
uncertainCallSites: 0,
|
|
233
|
+
warnings: [],
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const directChangedSet = new Set(directChangedSpecsAbs);
|
|
238
|
+
const alwaysIncludeSet = new Set(alwaysIncludeSpecsAbs);
|
|
239
|
+
const selectionReasons = new Map();
|
|
240
|
+
const warnings = [];
|
|
241
|
+
let uncertainCallSitesTotal = 0;
|
|
242
|
+
|
|
243
|
+
if (impactedMethodsByClass.size === 0) {
|
|
244
|
+
let retainedWithoutMethodFilter = 0;
|
|
245
|
+
for (const specPath of selectedSpecs) {
|
|
246
|
+
if (directChangedSet.has(specPath)) {
|
|
247
|
+
selectionReasons.set(specPath, 'direct-changed-spec');
|
|
248
|
+
} else if (alwaysIncludeSet.has(specPath)) {
|
|
249
|
+
selectionReasons.set(specPath, 'matched-import-graph');
|
|
250
|
+
} else {
|
|
251
|
+
retainedWithoutMethodFilter += 1;
|
|
252
|
+
selectionReasons.set(specPath, 'retained-no-impacted-methods');
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return {
|
|
256
|
+
filteredSpecs: selectedSpecs,
|
|
257
|
+
droppedByMethodFilter: 0,
|
|
258
|
+
retainedWithoutMethodFilter,
|
|
259
|
+
selectionReasons,
|
|
260
|
+
uncertainCallSites: 0,
|
|
261
|
+
warnings,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const filteredSpecs = [];
|
|
266
|
+
let droppedByMethodFilter = 0;
|
|
267
|
+
let retainedWithoutMethodFilter = 0;
|
|
268
|
+
|
|
269
|
+
for (const specPath of selectedSpecs) {
|
|
270
|
+
if (directChangedSet.has(specPath)) {
|
|
271
|
+
filteredSpecs.push(specPath);
|
|
272
|
+
selectionReasons.set(specPath, 'direct-changed-spec');
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (alwaysIncludeSet.has(specPath)) {
|
|
276
|
+
filteredSpecs.push(specPath);
|
|
277
|
+
selectionReasons.set(specPath, 'matched-import-graph');
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
let content;
|
|
282
|
+
try {
|
|
283
|
+
content = fs.readFileSync(specPath, 'utf8');
|
|
284
|
+
} catch (_error) {
|
|
285
|
+
retainedWithoutMethodFilter += 1;
|
|
286
|
+
filteredSpecs.push(specPath);
|
|
287
|
+
selectionReasons.set(specPath, 'retained-read-error');
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const scriptKind = specPath.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
|
|
292
|
+
const sourceFile = ts.createSourceFile(specPath, content, ts.ScriptTarget.Latest, true, scriptKind);
|
|
293
|
+
const fixtureVarToClass = extractFixtureVariablesFromSpecAst({ sourceFile, fixtureKeyToClass, fixtureKeys });
|
|
294
|
+
|
|
295
|
+
if (fixtureVarToClass.size === 0) {
|
|
296
|
+
retainedWithoutMethodFilter += 1;
|
|
297
|
+
filteredSpecs.push(specPath);
|
|
298
|
+
selectionReasons.set(specPath, 'retained-no-bindings');
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const matchResult = collectImpactedMethodMatchesInSpec({
|
|
303
|
+
sourceFile,
|
|
304
|
+
fixtureVarToClass,
|
|
305
|
+
impactedMethodsByClass,
|
|
306
|
+
selectionBias,
|
|
307
|
+
});
|
|
308
|
+
uncertainCallSitesTotal += matchResult.uncertainCallSites;
|
|
309
|
+
|
|
310
|
+
if (matchResult.preciseMatches.length > 0) {
|
|
311
|
+
filteredSpecs.push(specPath);
|
|
312
|
+
selectionReasons.set(specPath, 'matched-precise');
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (matchResult.shouldIncludeByUncertain) {
|
|
317
|
+
filteredSpecs.push(specPath);
|
|
318
|
+
selectionReasons.set(specPath, 'matched-uncertain-fail-open');
|
|
319
|
+
warnings.push(`Uncertain callsite retained spec: ${specPath}`);
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
droppedByMethodFilter += 1;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return {
|
|
327
|
+
filteredSpecs: filteredSpecs.sort((a, b) => a.localeCompare(b)),
|
|
328
|
+
droppedByMethodFilter,
|
|
329
|
+
retainedWithoutMethodFilter,
|
|
330
|
+
selectionReasons,
|
|
331
|
+
uncertainCallSites: uncertainCallSitesTotal,
|
|
332
|
+
warnings,
|
|
333
|
+
};
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
module.exports = {
|
|
337
|
+
filterSpecsByImpactedMethods,
|
|
338
|
+
};
|