@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,757 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const ts = require('typescript');
|
|
6
|
+
|
|
7
|
+
const createEmptyStats = () => ({
|
|
8
|
+
changedPomEntriesByStatus: { A: 0, M: 0, D: 0, R: 0 },
|
|
9
|
+
semanticChangedMethodsCount: 0,
|
|
10
|
+
topLevelRuntimeChangedFiles: 0,
|
|
11
|
+
impactedMethodsTotal: 0,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const simpleHash = (text) => {
|
|
15
|
+
let hash = 2166136261;
|
|
16
|
+
const value = String(text || '');
|
|
17
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
18
|
+
hash ^= value.charCodeAt(index);
|
|
19
|
+
hash = (hash * 16777619) >>> 0;
|
|
20
|
+
}
|
|
21
|
+
return hash.toString(16);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const normalizeText = (text) => String(text || '').replace(/\s+/g, ' ').trim();
|
|
25
|
+
|
|
26
|
+
const createSemanticCache = () => ({
|
|
27
|
+
// AST and node-fingerprint caches are scoped to one script run.
|
|
28
|
+
// They avoid repeated parse/print work for large POM sets.
|
|
29
|
+
astByFileRef: new Map(),
|
|
30
|
+
fingerprintByNode: new Map(),
|
|
31
|
+
printer: ts.createPrinter({ removeComments: true }),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const getFingerprintForNode = ({ refKind, absPath, node, fingerprintKind, sourceFile, cache }) => {
|
|
35
|
+
// Fingerprints are normalized to ignore formatting-only changes.
|
|
36
|
+
const key = `${refKind}:${absPath}:${node.pos}:${node.end}:${fingerprintKind}`;
|
|
37
|
+
if (cache.fingerprintByNode.has(key)) return cache.fingerprintByNode.get(key);
|
|
38
|
+
|
|
39
|
+
const printed = cache.printer.printNode(ts.EmitHint.Unspecified, node, sourceFile);
|
|
40
|
+
const normalized = normalizeText(printed);
|
|
41
|
+
cache.fingerprintByNode.set(key, normalized);
|
|
42
|
+
return normalized;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const isCallableProperty = (member) => {
|
|
46
|
+
if (!ts.isPropertyDeclaration(member) || !member.initializer) return false;
|
|
47
|
+
return ts.isArrowFunction(member.initializer) || ts.isFunctionExpression(member.initializer);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const getConstructorIdentity = (member) => {
|
|
51
|
+
if (!ts.isConstructorDeclaration(member)) return null;
|
|
52
|
+
return { type: 'ctor', name: 'constructor' };
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const getMemberName = (member) => {
|
|
56
|
+
if (!member || !member.name) return null;
|
|
57
|
+
if (ts.isIdentifier(member.name)) return member.name.text;
|
|
58
|
+
if (ts.isStringLiteral(member.name) || ts.isNoSubstitutionTemplateLiteral(member.name)) return member.name.text;
|
|
59
|
+
return null;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const getTypeNameFromEntity = (entityName) => {
|
|
63
|
+
if (!entityName) return null;
|
|
64
|
+
if (ts.isIdentifier(entityName)) return entityName.text;
|
|
65
|
+
if (ts.isQualifiedName(entityName)) return entityName.right.text;
|
|
66
|
+
return null;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const getTypeReferenceName = (typeNode) => {
|
|
70
|
+
if (!typeNode || !ts.isTypeReferenceNode(typeNode)) return null;
|
|
71
|
+
return getTypeNameFromEntity(typeNode.typeName);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const getFieldNameFromThisPropertyAccess = (node) => {
|
|
75
|
+
if (!node) return null;
|
|
76
|
+
if (ts.isPropertyAccessExpression(node)) {
|
|
77
|
+
if (node.expression.kind !== ts.SyntaxKind.ThisKeyword) return null;
|
|
78
|
+
if (!ts.isIdentifier(node.name)) return null;
|
|
79
|
+
return node.name.text;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (ts.isElementAccessExpression(node)) {
|
|
83
|
+
if (node.expression.kind !== ts.SyntaxKind.ThisKeyword) return null;
|
|
84
|
+
if (!node.argumentExpression) return null;
|
|
85
|
+
if (ts.isStringLiteral(node.argumentExpression) || ts.isNoSubstitutionTemplateLiteral(node.argumentExpression)) {
|
|
86
|
+
return node.argumentExpression.text;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return null;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const getLiteralNameFromArgumentExpression = (node) => {
|
|
94
|
+
if (!node) return null;
|
|
95
|
+
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
|
|
96
|
+
return null;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const collectComposedFieldMappingsFromConstructor = (constructorNode) => {
|
|
100
|
+
const mappings = new Map();
|
|
101
|
+
if (!constructorNode || !constructorNode.body) return mappings;
|
|
102
|
+
|
|
103
|
+
for (const statement of constructorNode.body.statements) {
|
|
104
|
+
if (!ts.isExpressionStatement(statement)) continue;
|
|
105
|
+
const expression = statement.expression;
|
|
106
|
+
if (!ts.isBinaryExpression(expression)) continue;
|
|
107
|
+
if (expression.operatorToken.kind !== ts.SyntaxKind.EqualsToken) continue;
|
|
108
|
+
|
|
109
|
+
const fieldName = getFieldNameFromThisPropertyAccess(expression.left);
|
|
110
|
+
if (!fieldName) continue;
|
|
111
|
+
if (!ts.isNewExpression(expression.right)) continue;
|
|
112
|
+
|
|
113
|
+
const className = getTypeNameFromEntity(expression.right.expression);
|
|
114
|
+
if (!className) continue;
|
|
115
|
+
mappings.set(fieldName, className);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return mappings;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const getMemberIdentity = (member) => {
|
|
122
|
+
const ctor = getConstructorIdentity(member);
|
|
123
|
+
if (ctor) return ctor;
|
|
124
|
+
|
|
125
|
+
const name = getMemberName(member);
|
|
126
|
+
if (!name) return null;
|
|
127
|
+
if (ts.isGetAccessorDeclaration(member)) return { type: 'get', name };
|
|
128
|
+
if (ts.isSetAccessorDeclaration(member)) return { type: 'set', name };
|
|
129
|
+
if (ts.isPropertyDeclaration(member) && !isCallableProperty(member)) return { type: 'field', name };
|
|
130
|
+
return { type: 'call', name };
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const isRuntimeStatement = (statement) => {
|
|
134
|
+
// Class body changes are handled by semantic member diff below.
|
|
135
|
+
// Keeping class declarations here causes single-method edits to look like top-level runtime changes.
|
|
136
|
+
if (ts.isClassDeclaration(statement)) return false;
|
|
137
|
+
if (ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement)) return false;
|
|
138
|
+
if (ts.isImportDeclaration(statement)) {
|
|
139
|
+
return !statement.importClause || !statement.importClause.isTypeOnly;
|
|
140
|
+
}
|
|
141
|
+
if (ts.isExportDeclaration(statement)) return !statement.isTypeOnly;
|
|
142
|
+
return true;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const parseFileModel = ({ refKind, absPath, content, cache }) => {
|
|
146
|
+
// Parse once per unique file/ref/content and keep a compact model for semantic diff.
|
|
147
|
+
if (typeof content !== 'string') return null;
|
|
148
|
+
|
|
149
|
+
const key = `${refKind}:${absPath}:${content.length}:${simpleHash(content)}`;
|
|
150
|
+
if (cache.astByFileRef.has(key)) return cache.astByFileRef.get(key);
|
|
151
|
+
|
|
152
|
+
const scriptKind = path.extname(absPath).toLowerCase() === '.tsx' ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
|
|
153
|
+
const sourceFile = ts.createSourceFile(absPath, content, ts.ScriptTarget.Latest, true, scriptKind);
|
|
154
|
+
const runtimeStatements = sourceFile.statements.filter((statement) => isRuntimeStatement(statement));
|
|
155
|
+
const classModels = new Map();
|
|
156
|
+
|
|
157
|
+
const ensureClassModel = (className) => {
|
|
158
|
+
if (classModels.has(className)) return classModels.get(className);
|
|
159
|
+
const model = {
|
|
160
|
+
membersByIdentity: new Map(),
|
|
161
|
+
callableMembersByName: new Map(),
|
|
162
|
+
composedFieldClassByName: new Map(),
|
|
163
|
+
};
|
|
164
|
+
classModels.set(className, model);
|
|
165
|
+
return model;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const ensureMemberModel = ({ classModel, className, identity }) => {
|
|
169
|
+
const keyForClass = `${identity.type}:${identity.name}`;
|
|
170
|
+
if (classModel.membersByIdentity.has(keyForClass)) return classModel.membersByIdentity.get(keyForClass);
|
|
171
|
+
|
|
172
|
+
const memberModel = {
|
|
173
|
+
className,
|
|
174
|
+
memberName: identity.name,
|
|
175
|
+
identityType: identity.type,
|
|
176
|
+
overloadNodes: [],
|
|
177
|
+
implementationNode: null,
|
|
178
|
+
callable: identity.type === 'call' || identity.type === 'ctor' || identity.type === 'get' || identity.type === 'set',
|
|
179
|
+
};
|
|
180
|
+
classModel.membersByIdentity.set(keyForClass, memberModel);
|
|
181
|
+
if (memberModel.callable) {
|
|
182
|
+
classModel.callableMembersByName.set(identity.name, memberModel);
|
|
183
|
+
}
|
|
184
|
+
return memberModel;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
for (const statement of sourceFile.statements) {
|
|
188
|
+
if (!ts.isClassDeclaration(statement) || !statement.name?.text) continue;
|
|
189
|
+
const className = statement.name.text;
|
|
190
|
+
const classModel = ensureClassModel(className);
|
|
191
|
+
|
|
192
|
+
for (const member of statement.members) {
|
|
193
|
+
const identity = getMemberIdentity(member);
|
|
194
|
+
if (!identity) continue;
|
|
195
|
+
|
|
196
|
+
const memberModel = ensureMemberModel({ classModel, className, identity });
|
|
197
|
+
|
|
198
|
+
if (ts.isMethodDeclaration(member)) {
|
|
199
|
+
if (member.body) memberModel.implementationNode = member;
|
|
200
|
+
else memberModel.overloadNodes.push(member);
|
|
201
|
+
} else if (ts.isConstructorDeclaration(member)) {
|
|
202
|
+
memberModel.implementationNode = member;
|
|
203
|
+
const ctorMappings = collectComposedFieldMappingsFromConstructor(member);
|
|
204
|
+
for (const [fieldName, typeName] of ctorMappings.entries()) {
|
|
205
|
+
classModel.composedFieldClassByName.set(fieldName, typeName);
|
|
206
|
+
}
|
|
207
|
+
} else if (ts.isGetAccessorDeclaration(member) || ts.isSetAccessorDeclaration(member)) {
|
|
208
|
+
memberModel.implementationNode = member;
|
|
209
|
+
} else if (isCallableProperty(member)) {
|
|
210
|
+
memberModel.implementationNode = member;
|
|
211
|
+
} else if (ts.isPropertyDeclaration(member)) {
|
|
212
|
+
memberModel.implementationNode = member;
|
|
213
|
+
const fieldName = getMemberName(member);
|
|
214
|
+
const fieldTypeName = getTypeReferenceName(member.type);
|
|
215
|
+
if (fieldName && fieldTypeName) {
|
|
216
|
+
classModel.composedFieldClassByName.set(fieldName, fieldTypeName);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const result = {
|
|
223
|
+
sourceFile,
|
|
224
|
+
runtimeStatements,
|
|
225
|
+
classModels,
|
|
226
|
+
};
|
|
227
|
+
cache.astByFileRef.set(key, result);
|
|
228
|
+
return result;
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const getRuntimeFingerprint = ({ refKind, absPath, parsed, cache }) => {
|
|
232
|
+
if (!parsed) return '';
|
|
233
|
+
return parsed.runtimeStatements
|
|
234
|
+
.map((statement) => getFingerprintForNode({
|
|
235
|
+
refKind,
|
|
236
|
+
absPath,
|
|
237
|
+
node: statement,
|
|
238
|
+
fingerprintKind: 'topLevelStatement',
|
|
239
|
+
sourceFile: parsed.sourceFile,
|
|
240
|
+
cache,
|
|
241
|
+
}))
|
|
242
|
+
.join('\n');
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const getMemberFingerprint = ({ refKind, absPath, parsed, memberModel, cache }) => {
|
|
246
|
+
// Overload signatures and implementation are combined to catch API-shape changes.
|
|
247
|
+
if (!parsed || !memberModel) return '';
|
|
248
|
+
const overloadFingerprint = memberModel.overloadNodes
|
|
249
|
+
.map((node) => getFingerprintForNode({ refKind, absPath, node, fingerprintKind: 'memberSignature', sourceFile: parsed.sourceFile, cache }))
|
|
250
|
+
.join('\n');
|
|
251
|
+
|
|
252
|
+
const implementationFingerprint = memberModel.implementationNode
|
|
253
|
+
? getFingerprintForNode({
|
|
254
|
+
refKind,
|
|
255
|
+
absPath,
|
|
256
|
+
node: memberModel.implementationNode,
|
|
257
|
+
fingerprintKind: 'memberImplementation',
|
|
258
|
+
sourceFile: parsed.sourceFile,
|
|
259
|
+
cache,
|
|
260
|
+
})
|
|
261
|
+
: '';
|
|
262
|
+
|
|
263
|
+
return `overloads:${overloadFingerprint}\nimplementation:${implementationFingerprint}`;
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const addChangedMethod = (changedMethodsByClass, className, memberName) => {
|
|
267
|
+
if (!className || !memberName) return;
|
|
268
|
+
if (!changedMethodsByClass.has(className)) changedMethodsByClass.set(className, new Set());
|
|
269
|
+
changedMethodsByClass.get(className).add(memberName);
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const addAllCallableMembers = ({ parsed, changedMethodsByClass }) => {
|
|
273
|
+
if (!parsed) return;
|
|
274
|
+
for (const [className, classModel] of parsed.classModels.entries()) {
|
|
275
|
+
for (const memberName of classModel.callableMembersByName.keys()) {
|
|
276
|
+
addChangedMethod(changedMethodsByClass, className, memberName);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const addCallableMembersFromClassModel = ({ classModel, className, changedMethodsByClass }) => {
|
|
282
|
+
if (!classModel) return;
|
|
283
|
+
for (const memberName of classModel.callableMembersByName.keys()) {
|
|
284
|
+
addChangedMethod(changedMethodsByClass, className, memberName);
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Build semantic seed of changed callable members per class from changed source entries.
|
|
290
|
+
* The seed intentionally ignores formatting-only edits and expands field-level changes to callables.
|
|
291
|
+
*/
|
|
292
|
+
const collectChangedMethodsByClass = ({ changedPomEntries, baseRef, readChangeContents }) => {
|
|
293
|
+
// Semantic seed stage:
|
|
294
|
+
// 1) compare top-level runtime statements
|
|
295
|
+
// 2) compare class members (methods/accessors/ctor/callable properties/fields)
|
|
296
|
+
// 3) convert changed non-callable fields into callable-method impact for the class
|
|
297
|
+
const changedMethodsByClass = new Map();
|
|
298
|
+
const stats = createEmptyStats();
|
|
299
|
+
const cache = createSemanticCache();
|
|
300
|
+
|
|
301
|
+
for (const entry of changedPomEntries) {
|
|
302
|
+
if (stats.changedPomEntriesByStatus[entry.status] !== undefined) {
|
|
303
|
+
stats.changedPomEntriesByStatus[entry.status] += 1;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const { basePath, headPath, baseContent, headContent } = readChangeContents(entry, baseRef);
|
|
307
|
+
const baseAbsPath = basePath || entry.effectivePath;
|
|
308
|
+
const headAbsPath = headPath || entry.effectivePath;
|
|
309
|
+
|
|
310
|
+
if (baseContent !== null && headContent !== null && baseContent === headContent) {
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const parsedBase = parseFileModel({ refKind: 'base', absPath: baseAbsPath, content: baseContent, cache });
|
|
315
|
+
const parsedHead = parseFileModel({ refKind: 'head', absPath: headAbsPath, content: headContent, cache });
|
|
316
|
+
|
|
317
|
+
const baseRuntime = getRuntimeFingerprint({ refKind: 'base', absPath: baseAbsPath, parsed: parsedBase, cache });
|
|
318
|
+
const headRuntime = getRuntimeFingerprint({ refKind: 'head', absPath: headAbsPath, parsed: parsedHead, cache });
|
|
319
|
+
|
|
320
|
+
if (baseRuntime !== headRuntime) {
|
|
321
|
+
stats.topLevelRuntimeChangedFiles += 1;
|
|
322
|
+
addAllCallableMembers({ parsed: parsedBase, changedMethodsByClass });
|
|
323
|
+
addAllCallableMembers({ parsed: parsedHead, changedMethodsByClass });
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const classNames = new Set([
|
|
327
|
+
...Array.from(parsedBase?.classModels.keys() || []),
|
|
328
|
+
...Array.from(parsedHead?.classModels.keys() || []),
|
|
329
|
+
]);
|
|
330
|
+
|
|
331
|
+
for (const className of classNames) {
|
|
332
|
+
const baseClass = parsedBase?.classModels.get(className);
|
|
333
|
+
const headClass = parsedHead?.classModels.get(className);
|
|
334
|
+
const identities = new Set([
|
|
335
|
+
...Array.from(baseClass?.membersByIdentity.keys() || []),
|
|
336
|
+
...Array.from(headClass?.membersByIdentity.keys() || []),
|
|
337
|
+
]);
|
|
338
|
+
|
|
339
|
+
for (const identity of identities) {
|
|
340
|
+
const baseMember = baseClass?.membersByIdentity.get(identity) || null;
|
|
341
|
+
const headMember = headClass?.membersByIdentity.get(identity) || null;
|
|
342
|
+
|
|
343
|
+
const baseFingerprint = getMemberFingerprint({
|
|
344
|
+
refKind: 'base',
|
|
345
|
+
absPath: baseAbsPath,
|
|
346
|
+
parsed: parsedBase,
|
|
347
|
+
memberModel: baseMember,
|
|
348
|
+
cache,
|
|
349
|
+
});
|
|
350
|
+
const headFingerprint = getMemberFingerprint({
|
|
351
|
+
refKind: 'head',
|
|
352
|
+
absPath: headAbsPath,
|
|
353
|
+
parsed: parsedHead,
|
|
354
|
+
memberModel: headMember,
|
|
355
|
+
cache,
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
if (baseFingerprint === headFingerprint) continue;
|
|
359
|
+
|
|
360
|
+
const targetClass = headMember?.className || baseMember?.className;
|
|
361
|
+
const targetMember = headMember?.memberName || baseMember?.memberName;
|
|
362
|
+
const isCallable = headMember?.callable || baseMember?.callable;
|
|
363
|
+
const identityType = headMember?.identityType || baseMember?.identityType || '';
|
|
364
|
+
if (!isCallable) {
|
|
365
|
+
// Non-callable class fields (for example, locator properties) can affect all methods in this class.
|
|
366
|
+
if (identityType === 'field' && targetClass) {
|
|
367
|
+
addCallableMembersFromClassModel({ classModel: baseClass, className: targetClass, changedMethodsByClass });
|
|
368
|
+
addCallableMembersFromClassModel({ classModel: headClass, className: targetClass, changedMethodsByClass });
|
|
369
|
+
}
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
addChangedMethod(changedMethodsByClass, targetClass, targetMember);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
stats.semanticChangedMethodsCount = Array.from(changedMethodsByClass.values()).reduce((sum, methods) => sum + methods.size, 0);
|
|
378
|
+
|
|
379
|
+
return {
|
|
380
|
+
changedMethodsByClass,
|
|
381
|
+
stats,
|
|
382
|
+
};
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
const resolveCallableMemberKey = ({ className, memberName, callableMemberKeyByClassAndName, parentsByChild, mode }) => {
|
|
386
|
+
// Resolve member names through class lineage for both this.* and super.* calls.
|
|
387
|
+
if (!className || !memberName) return null;
|
|
388
|
+
|
|
389
|
+
let current = mode === 'super' ? parentsByChild.get(className) : className;
|
|
390
|
+
while (current) {
|
|
391
|
+
const classMap = callableMemberKeyByClassAndName.get(current);
|
|
392
|
+
if (classMap && classMap.has(memberName)) return classMap.get(memberName);
|
|
393
|
+
current = parentsByChild.get(current);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return null;
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
const getCallableFunctionBodyNode = (memberNode) => {
|
|
400
|
+
if (!memberNode) return null;
|
|
401
|
+
if (ts.isConstructorDeclaration(memberNode)) return memberNode.body || null;
|
|
402
|
+
if (ts.isMethodDeclaration(memberNode)) return memberNode.body || null;
|
|
403
|
+
if (ts.isGetAccessorDeclaration(memberNode) || ts.isSetAccessorDeclaration(memberNode)) return memberNode.body || null;
|
|
404
|
+
if (ts.isPropertyDeclaration(memberNode) && memberNode.initializer) {
|
|
405
|
+
if (ts.isArrowFunction(memberNode.initializer) || ts.isFunctionExpression(memberNode.initializer)) {
|
|
406
|
+
return memberNode.initializer.body;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return null;
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
const hasChangedMethodInLineage = ({ className, memberName, changedMethodsByClass, parentsByChild }) => {
|
|
413
|
+
let current = className;
|
|
414
|
+
while (current) {
|
|
415
|
+
const changedMethods = changedMethodsByClass.get(current);
|
|
416
|
+
if (changedMethods && changedMethods.has(memberName)) return true;
|
|
417
|
+
current = parentsByChild.get(current);
|
|
418
|
+
}
|
|
419
|
+
return false;
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
const resolveComposedFieldClassInLineage = ({
|
|
423
|
+
className,
|
|
424
|
+
fieldName,
|
|
425
|
+
composedFieldClassByNameByClass,
|
|
426
|
+
parentsByChild,
|
|
427
|
+
}) => {
|
|
428
|
+
let current = className;
|
|
429
|
+
while (current) {
|
|
430
|
+
const fieldMap = composedFieldClassByNameByClass.get(current);
|
|
431
|
+
const resolvedClass = fieldMap?.get(fieldName);
|
|
432
|
+
if (resolvedClass) return resolvedClass;
|
|
433
|
+
current = parentsByChild.get(current);
|
|
434
|
+
}
|
|
435
|
+
return null;
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
const getComposedClassesInLineage = ({
|
|
439
|
+
className,
|
|
440
|
+
composedFieldClassByNameByClass,
|
|
441
|
+
parentsByChild,
|
|
442
|
+
}) => {
|
|
443
|
+
const composedClasses = new Set();
|
|
444
|
+
let current = className;
|
|
445
|
+
while (current) {
|
|
446
|
+
const fieldMap = composedFieldClassByNameByClass.get(current);
|
|
447
|
+
if (fieldMap) {
|
|
448
|
+
for (const composedClass of fieldMap.values()) composedClasses.add(composedClass);
|
|
449
|
+
}
|
|
450
|
+
current = parentsByChild.get(current);
|
|
451
|
+
}
|
|
452
|
+
return composedClasses;
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Propagate semantic seed through class call graph and return final impacted methods by class.
|
|
457
|
+
* Includes inheritance and simple composition projection used by Stage B filtering.
|
|
458
|
+
*/
|
|
459
|
+
const buildImpactedMethodsByClass = ({ impactedClasses, changedMethodsByClass, parentsByChild, pageFiles }) => {
|
|
460
|
+
// Propagation stage:
|
|
461
|
+
// - build callable method graph from page files
|
|
462
|
+
// - reverse-traverse callers from semantic seed methods
|
|
463
|
+
// - project final method set back to impacted classes used by Stage B
|
|
464
|
+
const impactedMethodsByClass = new Map();
|
|
465
|
+
const stats = createEmptyStats();
|
|
466
|
+
const warnings = [];
|
|
467
|
+
const cache = createSemanticCache();
|
|
468
|
+
|
|
469
|
+
const callableMemberKeyByClassAndName = new Map();
|
|
470
|
+
const composedFieldClassByNameByClass = new Map();
|
|
471
|
+
const composedClassToOwnerClasses = new Map();
|
|
472
|
+
const callableNodeByMemberKey = new Map();
|
|
473
|
+
const memberKeyParts = new Map();
|
|
474
|
+
|
|
475
|
+
for (const filePath of pageFiles) {
|
|
476
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
477
|
+
const parsed = parseFileModel({ refKind: 'head', absPath: filePath, content, cache });
|
|
478
|
+
if (!parsed) continue;
|
|
479
|
+
|
|
480
|
+
for (const [className, classModel] of parsed.classModels.entries()) {
|
|
481
|
+
if (!callableMemberKeyByClassAndName.has(className)) callableMemberKeyByClassAndName.set(className, new Map());
|
|
482
|
+
const classMap = callableMemberKeyByClassAndName.get(className);
|
|
483
|
+
composedFieldClassByNameByClass.set(className, classModel.composedFieldClassByName);
|
|
484
|
+
for (const composedClass of classModel.composedFieldClassByName.values()) {
|
|
485
|
+
if (!composedClassToOwnerClasses.has(composedClass)) composedClassToOwnerClasses.set(composedClass, new Set());
|
|
486
|
+
composedClassToOwnerClasses.get(composedClass).add(className);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
for (const [memberName, memberModel] of classModel.callableMembersByName.entries()) {
|
|
490
|
+
const memberKey = `${className}#${memberName}`;
|
|
491
|
+
classMap.set(memberName, memberKey);
|
|
492
|
+
memberKeyParts.set(memberKey, { className, memberName });
|
|
493
|
+
if (memberModel.implementationNode) callableNodeByMemberKey.set(memberKey, memberModel.implementationNode);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const directEdges = new Map();
|
|
499
|
+
|
|
500
|
+
for (const [callerKey, callerNode] of callableNodeByMemberKey.entries()) {
|
|
501
|
+
const callerParts = memberKeyParts.get(callerKey);
|
|
502
|
+
if (!callerParts) continue;
|
|
503
|
+
|
|
504
|
+
const bodyNode = getCallableFunctionBodyNode(callerNode);
|
|
505
|
+
if (!bodyNode) continue;
|
|
506
|
+
|
|
507
|
+
const callees = new Set();
|
|
508
|
+
const addAllClassMembersAsCallees = (className, mode = 'this') => {
|
|
509
|
+
let current = mode === 'super' ? parentsByChild.get(className) : className;
|
|
510
|
+
while (current) {
|
|
511
|
+
const classMap = callableMemberKeyByClassAndName.get(current);
|
|
512
|
+
if (classMap) {
|
|
513
|
+
for (const memberKey of classMap.values()) callees.add(memberKey);
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
current = parentsByChild.get(current);
|
|
517
|
+
}
|
|
518
|
+
warnings.push(`Unresolvable ${mode} lineage for class ${className}`);
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
const getRootOfExpression = (expr) => {
|
|
522
|
+
let current = expr;
|
|
523
|
+
while (current && (ts.isPropertyAccessExpression(current) || ts.isElementAccessExpression(current))) {
|
|
524
|
+
current = current.expression;
|
|
525
|
+
}
|
|
526
|
+
return current;
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
const visit = (node) => {
|
|
530
|
+
const isCallLike = ts.isCallExpression(node) || (typeof ts.isCallChain === 'function' && ts.isCallChain(node));
|
|
531
|
+
if (isCallLike && (ts.isPropertyAccessExpression(node.expression) || ts.isElementAccessExpression(node.expression))) {
|
|
532
|
+
const calleeExpression = node.expression;
|
|
533
|
+
const objectExpr = calleeExpression.expression;
|
|
534
|
+
const methodName = ts.isPropertyAccessExpression(calleeExpression)
|
|
535
|
+
? (ts.isIdentifier(calleeExpression.name) ? calleeExpression.name.text : null)
|
|
536
|
+
: getLiteralNameFromArgumentExpression(calleeExpression.argumentExpression);
|
|
537
|
+
const isDynamicElementAccess = ts.isElementAccessExpression(calleeExpression) && !methodName;
|
|
538
|
+
|
|
539
|
+
if (objectExpr.kind === ts.SyntaxKind.ThisKeyword) {
|
|
540
|
+
if (methodName) {
|
|
541
|
+
const calleeKey = resolveCallableMemberKey({
|
|
542
|
+
className: callerParts.className,
|
|
543
|
+
memberName: methodName,
|
|
544
|
+
callableMemberKeyByClassAndName,
|
|
545
|
+
parentsByChild,
|
|
546
|
+
mode: 'this',
|
|
547
|
+
});
|
|
548
|
+
if (calleeKey) callees.add(calleeKey);
|
|
549
|
+
else warnings.push(`Unresolvable this.${methodName} in ${callerParts.className}`);
|
|
550
|
+
} else if (isDynamicElementAccess) {
|
|
551
|
+
addAllClassMembersAsCallees(callerParts.className, 'this');
|
|
552
|
+
warnings.push(`Dynamic this[...] call in ${callerParts.className} treated as uncertain`);
|
|
553
|
+
}
|
|
554
|
+
} else if (objectExpr.kind === ts.SyntaxKind.SuperKeyword) {
|
|
555
|
+
if (methodName) {
|
|
556
|
+
const calleeKey = resolveCallableMemberKey({
|
|
557
|
+
className: callerParts.className,
|
|
558
|
+
memberName: methodName,
|
|
559
|
+
callableMemberKeyByClassAndName,
|
|
560
|
+
parentsByChild,
|
|
561
|
+
mode: 'super',
|
|
562
|
+
});
|
|
563
|
+
if (calleeKey) callees.add(calleeKey);
|
|
564
|
+
else warnings.push(`Unresolvable super.${methodName} in ${callerParts.className}`);
|
|
565
|
+
} else if (isDynamicElementAccess) {
|
|
566
|
+
addAllClassMembersAsCallees(callerParts.className, 'super');
|
|
567
|
+
warnings.push(`Dynamic super[...] call in ${callerParts.className} treated as uncertain`);
|
|
568
|
+
}
|
|
569
|
+
} else if (ts.isPropertyAccessExpression(objectExpr) || ts.isElementAccessExpression(objectExpr)) {
|
|
570
|
+
if (!methodName) {
|
|
571
|
+
ts.forEachChild(node, visit);
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
const chainRoot = getRootOfExpression(objectExpr);
|
|
575
|
+
if (chainRoot && chainRoot.kind === ts.SyntaxKind.ThisKeyword && !getFieldNameFromThisPropertyAccess(objectExpr)) {
|
|
576
|
+
addAllClassMembersAsCallees(callerParts.className, 'this');
|
|
577
|
+
warnings.push(`Deep this.* chain in ${callerParts.className} treated as uncertain`);
|
|
578
|
+
ts.forEachChild(node, visit);
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
const fieldName = getFieldNameFromThisPropertyAccess(objectExpr);
|
|
582
|
+
if (!fieldName) {
|
|
583
|
+
ts.forEachChild(node, visit);
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
const composedClass = resolveComposedFieldClassInLineage({
|
|
587
|
+
className: callerParts.className,
|
|
588
|
+
fieldName,
|
|
589
|
+
composedFieldClassByNameByClass,
|
|
590
|
+
parentsByChild,
|
|
591
|
+
});
|
|
592
|
+
if (!composedClass) {
|
|
593
|
+
warnings.push(`Unknown composed field type for ${fieldName} in ${callerParts.className}`);
|
|
594
|
+
ts.forEachChild(node, visit);
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
const calleeKey = resolveCallableMemberKey({
|
|
598
|
+
className: composedClass,
|
|
599
|
+
memberName: methodName,
|
|
600
|
+
callableMemberKeyByClassAndName,
|
|
601
|
+
parentsByChild,
|
|
602
|
+
mode: 'this',
|
|
603
|
+
});
|
|
604
|
+
if (calleeKey) callees.add(calleeKey);
|
|
605
|
+
else warnings.push(`Unresolvable composed call ${fieldName}.${methodName} in ${callerParts.className}`);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
ts.forEachChild(node, visit);
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
visit(bodyNode);
|
|
612
|
+
directEdges.set(callerKey, callees);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const reverseEdges = new Map();
|
|
616
|
+
for (const [caller, callees] of directEdges.entries()) {
|
|
617
|
+
for (const callee of callees) {
|
|
618
|
+
if (!reverseEdges.has(callee)) reverseEdges.set(callee, new Set());
|
|
619
|
+
reverseEdges.get(callee).add(caller);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const queue = [];
|
|
624
|
+
const visited = new Set();
|
|
625
|
+
|
|
626
|
+
for (const [className, methodNames] of changedMethodsByClass.entries()) {
|
|
627
|
+
for (const methodName of methodNames) {
|
|
628
|
+
const memberKey = resolveCallableMemberKey({
|
|
629
|
+
className,
|
|
630
|
+
memberName: methodName,
|
|
631
|
+
callableMemberKeyByClassAndName,
|
|
632
|
+
parentsByChild,
|
|
633
|
+
mode: 'this',
|
|
634
|
+
});
|
|
635
|
+
if (!memberKey || visited.has(memberKey)) continue;
|
|
636
|
+
visited.add(memberKey);
|
|
637
|
+
queue.push(memberKey);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
while (queue.length > 0) {
|
|
642
|
+
// BFS with visited set prevents loops on recursive/cyclic call chains.
|
|
643
|
+
const current = queue.shift();
|
|
644
|
+
const callers = reverseEdges.get(current) || new Set();
|
|
645
|
+
for (const caller of callers) {
|
|
646
|
+
if (visited.has(caller)) continue;
|
|
647
|
+
visited.add(caller);
|
|
648
|
+
queue.push(caller);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const impactedMemberNames = new Set();
|
|
653
|
+
for (const memberKey of visited) {
|
|
654
|
+
const parts = memberKeyParts.get(memberKey);
|
|
655
|
+
if (!parts) continue;
|
|
656
|
+
impactedMemberNames.add(parts.memberName);
|
|
657
|
+
}
|
|
658
|
+
for (const methodNames of changedMethodsByClass.values()) {
|
|
659
|
+
for (const methodName of methodNames) impactedMemberNames.add(methodName);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
let classesForProjection = Array.from(callableMemberKeyByClassAndName.keys());
|
|
663
|
+
if (impactedClasses.size > 0) {
|
|
664
|
+
const childrenByParent = new Map();
|
|
665
|
+
for (const [childClass, parentClass] of parentsByChild.entries()) {
|
|
666
|
+
if (!childrenByParent.has(parentClass)) childrenByParent.set(parentClass, new Set());
|
|
667
|
+
childrenByParent.get(parentClass).add(childClass);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const projectionSet = new Set(impactedClasses);
|
|
671
|
+
const queueForComposition = Array.from(impactedClasses);
|
|
672
|
+
while (queueForComposition.length > 0) {
|
|
673
|
+
const currentClass = queueForComposition.shift();
|
|
674
|
+
const owners = composedClassToOwnerClasses.get(currentClass) || new Set();
|
|
675
|
+
for (const ownerClass of owners) {
|
|
676
|
+
if (projectionSet.has(ownerClass)) continue;
|
|
677
|
+
projectionSet.add(ownerClass);
|
|
678
|
+
queueForComposition.push(ownerClass);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const queueForDescendants = Array.from(projectionSet);
|
|
683
|
+
while (queueForDescendants.length > 0) {
|
|
684
|
+
const currentClass = queueForDescendants.shift();
|
|
685
|
+
const children = childrenByParent.get(currentClass) || new Set();
|
|
686
|
+
for (const childClass of children) {
|
|
687
|
+
if (projectionSet.has(childClass)) continue;
|
|
688
|
+
projectionSet.add(childClass);
|
|
689
|
+
queueForDescendants.push(childClass);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
classesForProjection = Array.from(projectionSet);
|
|
694
|
+
}
|
|
695
|
+
for (const className of classesForProjection) {
|
|
696
|
+
for (const memberName of impactedMemberNames) {
|
|
697
|
+
const resolvedMemberKey = resolveCallableMemberKey({
|
|
698
|
+
className,
|
|
699
|
+
memberName,
|
|
700
|
+
callableMemberKeyByClassAndName,
|
|
701
|
+
parentsByChild,
|
|
702
|
+
mode: 'this',
|
|
703
|
+
});
|
|
704
|
+
const isResolvedImpacted = Boolean(resolvedMemberKey && visited.has(resolvedMemberKey));
|
|
705
|
+
const isRemovedOrRenamedInLineage = !resolvedMemberKey &&
|
|
706
|
+
hasChangedMethodInLineage({ className, memberName, changedMethodsByClass, parentsByChild });
|
|
707
|
+
|
|
708
|
+
let isComposedImpacted = false;
|
|
709
|
+
if (!isResolvedImpacted && !isRemovedOrRenamedInLineage) {
|
|
710
|
+
const composedClasses = getComposedClassesInLineage({
|
|
711
|
+
className,
|
|
712
|
+
composedFieldClassByNameByClass,
|
|
713
|
+
parentsByChild,
|
|
714
|
+
});
|
|
715
|
+
for (const composedClass of composedClasses) {
|
|
716
|
+
const composedMemberKey = resolveCallableMemberKey({
|
|
717
|
+
className: composedClass,
|
|
718
|
+
memberName,
|
|
719
|
+
callableMemberKeyByClassAndName,
|
|
720
|
+
parentsByChild,
|
|
721
|
+
mode: 'this',
|
|
722
|
+
});
|
|
723
|
+
const isComposedResolvedImpacted = Boolean(composedMemberKey && visited.has(composedMemberKey));
|
|
724
|
+
const isComposedRemovedOrRenamed =
|
|
725
|
+
!composedMemberKey &&
|
|
726
|
+
hasChangedMethodInLineage({
|
|
727
|
+
className: composedClass,
|
|
728
|
+
memberName,
|
|
729
|
+
changedMethodsByClass,
|
|
730
|
+
parentsByChild,
|
|
731
|
+
});
|
|
732
|
+
if (isComposedResolvedImpacted || isComposedRemovedOrRenamed) {
|
|
733
|
+
isComposedImpacted = true;
|
|
734
|
+
break;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
if (!isResolvedImpacted && !isRemovedOrRenamedInLineage && !isComposedImpacted) continue;
|
|
740
|
+
if (!impactedMethodsByClass.has(className)) impactedMethodsByClass.set(className, new Set());
|
|
741
|
+
impactedMethodsByClass.get(className).add(memberName);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
stats.impactedMethodsTotal = Array.from(impactedMethodsByClass.values()).reduce((sum, methods) => sum + methods.size, 0);
|
|
746
|
+
|
|
747
|
+
return {
|
|
748
|
+
impactedMethodsByClass,
|
|
749
|
+
stats,
|
|
750
|
+
warnings,
|
|
751
|
+
};
|
|
752
|
+
};
|
|
753
|
+
|
|
754
|
+
module.exports = {
|
|
755
|
+
collectChangedMethodsByClass,
|
|
756
|
+
buildImpactedMethodsByClass,
|
|
757
|
+
};
|