@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 ADDED
@@ -0,0 +1,117 @@
1
+ # test-impact-core
2
+
3
+ Select only impacted Playwright specs from changed POM and utility methods.
4
+
5
+ `test-impact-core` is built for Playwright teams using Page Object Model who want faster feedback and shorter CI cycles without running the full test suite on every commit.
6
+
7
+ It solves a common problem: broad reruns after small UI/POM changes. Instead of running everything, it computes the smallest reliable spec set to execute.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ // npm
13
+ npm i @autotests/test-impact-core
14
+
15
+ // pnpm
16
+ pnpm add @autotests/test-impact-core
17
+ ```
18
+
19
+ ## Why
20
+
21
+ - Large Playwright projects grow quickly, and full-suite runs become expensive.
22
+ - POM-heavy repositories often change in narrow areas but still trigger wide test execution.
23
+ - Running all specs for each PR slows CI, delays triage, and increases infrastructure cost.
24
+ - `test-impact-core` changes this by selecting only specs impacted by changed files and changed method behavior.
25
+
26
+ ## 30-Second Setup (Minimum Required Configuration)
27
+
28
+ Only these fields are required. Everything else has defaults.
29
+
30
+ - `repoRoot`: absolute path to repository root.
31
+ - `profile.testsRootRelative`: where Playwright specs live.
32
+ - `profile.changedSpecPrefix`: prefix used to detect directly changed spec files.
33
+ - `profile.isRelevantPomPath(filePath)`: rule that marks which source files are considered POM/utility inputs for impact analysis.
34
+
35
+ Public API entry point: `analyzeImpactedSpecs`.
36
+
37
+ ## Typical CI Usage
38
+
39
+ Typical workflow in CI:
40
+
41
+ 1. Compare feature branch changes against `main` (via `baseRef`).
42
+ 2. Compute impacted specs.
43
+ 3. Exit early when `hasAnythingToRun` is false.
44
+ 4. Run only `selectedSpecsRelative` in Playwright.
45
+
46
+ This pattern reduces pipeline time while keeping selection practical and explainable.
47
+
48
+ ## How It Works (High Level)
49
+
50
+ 1. Read changed files from Git.
51
+ 2. Mark directly changed spec files.
52
+ 3. Detect semantic impact in changed POM/utility methods.
53
+ 4. Map impacted methods to fixture usage in specs.
54
+ 5. Apply selection policy and return final spec set.
55
+
56
+ ## Before/After Example (Text)
57
+
58
+ Before:
59
+ - A change in one shared POM/utility file often leads teams to run a large suite or the full project.
60
+
61
+ After with `test-impact-core`:
62
+ - The same change results in a narrowed list of impacted specs only.
63
+ - Directly changed specs are still always included.
64
+ - CI runs faster, and failures are easier to link to the actual code change.
65
+
66
+ ## Configuration
67
+
68
+ ### Required
69
+
70
+ - `repoRoot`: absolute path to repository root.
71
+ - `profile`: project configuration object.
72
+ - `profile.testsRootRelative`: tests root relative path.
73
+ - `profile.changedSpecPrefix`: prefix for direct changed spec detection.
74
+ - `profile.isRelevantPomPath(filePath)`: function that returns true for relevant POM/utility files.
75
+
76
+ ### Optional (Advanced)
77
+
78
+ Most teams can skip these initially.
79
+
80
+ - `analysisRootsRelative`: roots for class/method graph scan. Default: `['src/pages', 'src/utils']`.
81
+ - `fixturesTypesRelative`: fixture map file path. Default: `src/fixtures/types.ts`.
82
+ - `baseRef`: git ref for comparison (example: `origin/main`).
83
+ - `includeUntrackedSpecs` (default `true`): include untracked `*.spec.ts`/`*.spec.tsx` as direct changed specs.
84
+ - `includeWorkingTreeWithBase` (default `true`): when `baseRef` is set, union committed diff (`base...HEAD`) with current working tree diff (`HEAD`).
85
+ - `fileExtensions` (default `['.ts', '.tsx']`): file extensions to analyze.
86
+ - `selectionBias` (default `'fail-open'`): uncertain call-site behavior.
87
+
88
+ ## Result Fields (Most Used)
89
+
90
+ - `selectedSpecsRelative`: relative paths for your test runner CLI.
91
+ - `hasAnythingToRun`: quick boolean for early exit.
92
+ - `selectionReasons`: reason code per selected spec.
93
+
94
+ ## Advanced Diagnostics
95
+
96
+ - `warnings`: compatibility or uncertainty warnings.
97
+ - `coverageStats.uncertainCallSites`: count of uncertain call sites encountered.
98
+ - `coverageStats.statusFallbackHits`: count of git status fallbacks (`C/T/U/unknown`).
99
+ - `changedEntriesBySource`: diagnostics for diff sources (`base...HEAD`, working tree, untracked).
100
+
101
+ ### Reason Codes
102
+
103
+ - `direct-changed-spec`: spec was directly changed in git/untracked set.
104
+ - `matched-precise`: spec has precise impacted fixture method usage.
105
+ - `matched-uncertain-fail-open`: uncertain match retained by fail-open policy.
106
+ - `retained-no-bindings`: spec kept because no fixture bindings were found in callback params.
107
+
108
+ ### Selection Policy (`selectionBias`)
109
+
110
+ - Default: `fail-open` for pragmatic safety.
111
+ - Alternative: `fail-closed` to drop uncertain matches.
112
+
113
+ ## Notes
114
+
115
+ - This library is intentionally pragmatic and defaults to `selectionBias: 'fail-open'`.
116
+ - For maximum safety, use `fail-open` in CI and monitor `warnings`/`uncertainCallSites`.
117
+ - Use deterministic input (`baseRef`, clean profile predicates) for deterministic output.
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@autotests/playwright-impact",
3
+ "version": "0.1.0",
4
+ "description": "Core impacted-test selection library for changed POM/method analysis",
5
+ "license": "ISC",
6
+ "main": "src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js"
9
+ },
10
+ "types": "src/index.d.ts",
11
+ "files": [
12
+ "src",
13
+ "tests",
14
+ "README.md"
15
+ ],
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "type": "commonjs",
20
+ "engines": {
21
+ "node": ">=18"
22
+ },
23
+ "scripts": {
24
+ "test": "node --test tests/*.test.js"
25
+ },
26
+ "peerDependencies": {
27
+ "typescript": ">=5 <6"
28
+ },
29
+ "devDependencies": {
30
+ "@types/node": "^24.10.9",
31
+ "typescript": "^5.9.2"
32
+ }
33
+ }
@@ -0,0 +1,294 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const {
6
+ SUPPORTED_FILE_EXTENSIONS,
7
+ listFilesRecursive,
8
+ getChangedEntries,
9
+ readChangeContents,
10
+ getUntrackedSpecPaths,
11
+ } = require('./modules/file-and-git-helpers');
12
+ const { buildInheritanceGraph, collectImpactedClasses, getFixtureKeysForClasses } = require('./modules/class-impact-helpers');
13
+ const { parseFixtureMappings } = require('./modules/fixture-map-helpers');
14
+ const { collectChangedMethodsByClass, buildImpactedMethodsByClass } = require('./modules/method-impact-helpers');
15
+ const { selectSpecFiles } = require('./modules/spec-selection-helpers');
16
+ const { filterSpecsByImpactedMethods } = require('./modules/method-filter-helpers');
17
+ const { selectSpecsByChangedImports } = require('./modules/import-impact-helpers');
18
+ const { evaluateGlobalWatch, getDefaultGlobalWatchPatterns } = require('./modules/global-watch-helpers');
19
+
20
+ const DIRECT_CHANGED_SPEC_STATUSES = new Set(['A', 'M', 'R']);
21
+ const DEFAULT_SELECTION_BIAS = 'fail-open';
22
+ const DEFAULT_GLOBAL_WATCH_MODE = 'force-all-in-project';
23
+
24
+ const getStatusSummary = (entries) => {
25
+ const summary = { A: 0, M: 0, D: 0, R: 0 };
26
+ for (const entry of entries) {
27
+ if (summary[entry.status] !== undefined) summary[entry.status] += 1;
28
+ }
29
+ return summary;
30
+ };
31
+
32
+ const validateProfile = (profile) => {
33
+ if (!profile || typeof profile !== 'object') throw new Error('Missing required profile configuration');
34
+ if (!profile.testsRootRelative) throw new Error('Missing profile.testsRootRelative');
35
+ if (!profile.changedSpecPrefix) throw new Error('Missing profile.changedSpecPrefix');
36
+ if (typeof profile.isRelevantPomPath !== 'function') throw new Error('Missing profile.isRelevantPomPath(filePath) function');
37
+ };
38
+
39
+ const normalizeFileExtensions = (fileExtensions) => {
40
+ const source = Array.isArray(fileExtensions) && fileExtensions.length > 0 ? fileExtensions : Array.from(SUPPORTED_FILE_EXTENSIONS);
41
+ const normalized = source
42
+ .map((ext) => String(ext || '').trim().toLowerCase())
43
+ .filter((ext) => ext.startsWith('.'));
44
+ return normalized.length > 0 ? Array.from(new Set(normalized)) : Array.from(SUPPORTED_FILE_EXTENSIONS);
45
+ };
46
+
47
+ /**
48
+ * Analyze changed sources and return the deterministic list of impacted spec files.
49
+ * Pipeline:
50
+ * 1) collect and normalize changed entries from git/untracked sources
51
+ * 2) seed semantic impact from changed POM classes/methods
52
+ * 3) Stage A preselect specs by impacted fixture keys
53
+ * 4) Stage B precise/uncertain method matching with selection bias policy
54
+ */
55
+ const analyzeImpactedSpecs = ({
56
+ repoRoot,
57
+ baseRef = null,
58
+ profile,
59
+ includeUntrackedSpecs = true,
60
+ includeWorkingTreeWithBase = true,
61
+ fileExtensions = ['.ts', '.tsx'],
62
+ selectionBias = DEFAULT_SELECTION_BIAS,
63
+ }) => {
64
+ validateProfile(profile);
65
+ if (!repoRoot) throw new Error('Missing required repoRoot');
66
+
67
+ const effectiveExtensions = normalizeFileExtensions(fileExtensions);
68
+ const testsRoot = path.join(repoRoot, profile.testsRootRelative);
69
+ const analysisRootsRelative = profile.analysisRootsRelative || ['src/pages', 'src/utils'];
70
+ const fixturesTypesRelative = profile.fixturesTypesRelative || 'src/fixtures/types.ts';
71
+ const globalWatchMode = profile.globalWatchMode || DEFAULT_GLOBAL_WATCH_MODE;
72
+ const globalWatchPatterns = Array.isArray(profile.globalWatchPatterns) && profile.globalWatchPatterns.length > 0
73
+ ? profile.globalWatchPatterns
74
+ : getDefaultGlobalWatchPatterns();
75
+
76
+ // Stage 0: gather changed files and keep only profile-relevant subsets.
77
+ const changedEntriesResult = getChangedEntries({
78
+ repoRoot,
79
+ baseRef,
80
+ includeWorkingTreeWithBase,
81
+ profile,
82
+ fileExtensions: effectiveExtensions,
83
+ });
84
+ const changedEntries = changedEntriesResult.entries;
85
+ const changedPomEntries = changedEntries.filter((entry) => {
86
+ const candidates = [entry.effectivePath, entry.oldPath, entry.newPath].filter(Boolean);
87
+ return candidates.some((filePath) => profile.isRelevantPomPath(filePath));
88
+ });
89
+ const changedSpecEntries = changedEntries.filter((entry) => {
90
+ const targetPath = String(entry.effectivePath || '').trim();
91
+ return targetPath.startsWith(profile.changedSpecPrefix) && effectiveExtensions.some((ext) => targetPath.endsWith(`.spec${ext}`));
92
+ });
93
+ const globalWatch = globalWatchMode === 'disabled'
94
+ ? { matchedPaths: [], resolvedFiles: [] }
95
+ : evaluateGlobalWatch({
96
+ repoRoot,
97
+ changedEntries,
98
+ patterns: globalWatchPatterns,
99
+ listFilesRecursive,
100
+ });
101
+
102
+ const changedSpecFiles = changedSpecEntries
103
+ .filter((entry) => DIRECT_CHANGED_SPEC_STATUSES.has(entry.status))
104
+ .map((entry) => entry.effectivePath)
105
+ .filter(Boolean);
106
+ const untrackedSpecFiles = includeUntrackedSpecs
107
+ ? getUntrackedSpecPaths({ repoRoot, changedSpecPrefix: profile.changedSpecPrefix, fileExtensions: effectiveExtensions })
108
+ : [];
109
+ const directChangedSpecFiles = Array.from(new Set([...changedSpecFiles, ...untrackedSpecFiles])).sort((a, b) => a.localeCompare(b));
110
+
111
+ if (globalWatchMode !== 'disabled' && globalWatch.matchedPaths.length > 0) {
112
+ const selectedSpecs = listFilesRecursive(testsRoot)
113
+ .filter((filePath) => effectiveExtensions.some((ext) => filePath.endsWith(`.spec${ext}`)))
114
+ .sort((a, b) => a.localeCompare(b));
115
+ const selectionReasons = new Map(selectedSpecs.map((specPath) => [specPath, 'global-watch-force-all']));
116
+
117
+ return {
118
+ selectedSpecs,
119
+ selectedSpecsRelative: selectedSpecs.map((specPath) => path.relative(repoRoot, specPath)),
120
+ changedPomEntries,
121
+ directChangedSpecFiles,
122
+ statusSummary: getStatusSummary(changedPomEntries),
123
+ impactedClasses: new Set(),
124
+ impactedMethodsByClass: new Map(),
125
+ fixtureKeys: new Set(),
126
+ stageASelectedCount: selectedSpecs.length,
127
+ semanticStats: { changedPomEntriesByStatus: { A: 0, M: 0, D: 0, R: 0 }, semanticChangedMethodsCount: 0, topLevelRuntimeChangedFiles: 0 },
128
+ propagationStats: { impactedMethodsTotal: 0 },
129
+ droppedByMethodFilter: 0,
130
+ retainedWithoutMethodFilter: 0,
131
+ selectionReasons,
132
+ hasAnythingToRun: selectedSpecs.length > 0,
133
+ warnings: changedEntriesResult.warnings,
134
+ coverageStats: { uncertainCallSites: 0, statusFallbackHits: changedEntriesResult.statusFallbackHits },
135
+ changedEntriesBySource: changedEntriesResult.changedEntriesBySource,
136
+ forcedAllSpecs: true,
137
+ forcedAllSpecsReason: 'global-watch-force-all',
138
+ globalWatchMatches: globalWatch.matchedPaths,
139
+ globalWatchResolvedFiles: globalWatch.resolvedFiles,
140
+ };
141
+ }
142
+
143
+ // Fast exit when neither changed POM files nor direct changed specs are present.
144
+ if (changedPomEntries.length === 0 && directChangedSpecFiles.length === 0) {
145
+ return {
146
+ selectedSpecs: [],
147
+ selectedSpecsRelative: [],
148
+ changedPomEntries,
149
+ directChangedSpecFiles,
150
+ statusSummary: getStatusSummary(changedPomEntries),
151
+ impactedClasses: new Set(),
152
+ impactedMethodsByClass: new Map(),
153
+ fixtureKeys: new Set(),
154
+ stageASelectedCount: 0,
155
+ semanticStats: { changedPomEntriesByStatus: { A: 0, M: 0, D: 0, R: 0 }, semanticChangedMethodsCount: 0, topLevelRuntimeChangedFiles: 0 },
156
+ propagationStats: { impactedMethodsTotal: 0 },
157
+ droppedByMethodFilter: 0,
158
+ retainedWithoutMethodFilter: 0,
159
+ selectionReasons: new Map(),
160
+ hasAnythingToRun: false,
161
+ warnings: changedEntriesResult.warnings,
162
+ coverageStats: { uncertainCallSites: 0, statusFallbackHits: changedEntriesResult.statusFallbackHits },
163
+ changedEntriesBySource: changedEntriesResult.changedEntriesBySource,
164
+ forcedAllSpecs: false,
165
+ forcedAllSpecsReason: null,
166
+ globalWatchMatches: globalWatch.matchedPaths,
167
+ globalWatchResolvedFiles: globalWatch.resolvedFiles,
168
+ };
169
+ }
170
+
171
+ let impactedClasses = new Set();
172
+ let impactedMethodsByClass = new Map();
173
+ let fixtureKeys = new Set();
174
+ let fixtureKeyToClass = new Map();
175
+ let selectedSpecs = [];
176
+ let stageASelectedCount = 0;
177
+ let semanticStats = {
178
+ changedPomEntriesByStatus: { A: 0, M: 0, D: 0, R: 0 },
179
+ semanticChangedMethodsCount: 0,
180
+ topLevelRuntimeChangedFiles: 0,
181
+ };
182
+ let propagationStats = { impactedMethodsTotal: 0 };
183
+ let propagationWarnings = [];
184
+ let importMatchedSpecs = [];
185
+
186
+ const pageFiles = analysisRootsRelative
187
+ .flatMap((relativePath) => listFilesRecursive(path.join(repoRoot, relativePath)))
188
+ .filter((filePath) => effectiveExtensions.includes(path.extname(filePath).toLowerCase()));
189
+ const { parentsByChild, childrenByParent } = buildInheritanceGraph(pageFiles, fs.readFileSync);
190
+
191
+ if (changedPomEntries.length > 0) {
192
+ importMatchedSpecs = selectSpecsByChangedImports({
193
+ repoRoot,
194
+ testsRootAbs: testsRoot,
195
+ changedPomEntries,
196
+ listFilesRecursive,
197
+ fileExtensions: effectiveExtensions,
198
+ });
199
+ }
200
+
201
+ // Stage 1: semantic seed and callgraph propagation from changed POM entries.
202
+ if (changedPomEntries.length > 0) {
203
+ const changedMethodsResult = collectChangedMethodsByClass({
204
+ changedPomEntries,
205
+ baseRef,
206
+ readChangeContents: (entry, entryBaseRef) => readChangeContents({ repoRoot, entry, baseRef: entryBaseRef }),
207
+ });
208
+
209
+ semanticStats = changedMethodsResult.stats;
210
+ const hasSemanticPomImpact = semanticStats.semanticChangedMethodsCount > 0 || semanticStats.topLevelRuntimeChangedFiles > 0;
211
+
212
+ if (hasSemanticPomImpact) {
213
+ impactedClasses = collectImpactedClasses({
214
+ changedPomEntries,
215
+ childrenByParent,
216
+ baseRef,
217
+ readChangeContents: (entry, entryBaseRef) => readChangeContents({ repoRoot, entry, baseRef: entryBaseRef }),
218
+ });
219
+
220
+ const impactedMethodsResult = buildImpactedMethodsByClass({
221
+ impactedClasses,
222
+ changedMethodsByClass: changedMethodsResult.changedMethodsByClass,
223
+ parentsByChild,
224
+ pageFiles,
225
+ });
226
+
227
+ impactedMethodsByClass = impactedMethodsResult.impactedMethodsByClass;
228
+ propagationStats = impactedMethodsResult.stats;
229
+ propagationWarnings = impactedMethodsResult.warnings || [];
230
+
231
+ const fixtureMappings = parseFixtureMappings({ typesPath: path.join(repoRoot, fixturesTypesRelative) });
232
+ fixtureKeyToClass = fixtureMappings.fixtureKeyToClass;
233
+ const classesForFixtureSelection = impactedMethodsByClass.size > 0 ? new Set(impactedMethodsByClass.keys()) : impactedClasses;
234
+ fixtureKeys = getFixtureKeysForClasses(classesForFixtureSelection, fixtureMappings.classToFixtureKeys);
235
+
236
+ // Stage A: fixture-key prefilter to avoid scanning unrelated specs in Stage B.
237
+ if (fixtureKeys.size > 0) {
238
+ selectedSpecs = selectSpecFiles({ testsRootAbs: testsRoot, fixtureKeys, listFilesRecursive, fileExtensions: effectiveExtensions });
239
+ stageASelectedCount = selectedSpecs.length;
240
+ }
241
+ }
242
+ }
243
+
244
+ // Directly changed specs are always added after Stage A prefilter.
245
+ const directChangedSpecsAbs = directChangedSpecFiles.map((filePath) => path.join(repoRoot, filePath));
246
+ const selectedSet = new Set([...selectedSpecs, ...directChangedSpecsAbs, ...importMatchedSpecs]);
247
+ selectedSpecs = Array.from(selectedSet).sort((a, b) => a.localeCompare(b));
248
+ stageASelectedCount = selectedSpecs.length;
249
+
250
+ // Stage B: method-level filtering with fail-open/fail-closed handling for uncertain call sites.
251
+ const methodFilterResult = filterSpecsByImpactedMethods({
252
+ selectedSpecs,
253
+ directChangedSpecsAbs,
254
+ alwaysIncludeSpecsAbs: importMatchedSpecs,
255
+ fixtureKeyToClass,
256
+ fixtureKeys,
257
+ impactedMethodsByClass,
258
+ selectionBias,
259
+ });
260
+
261
+ selectedSpecs = methodFilterResult.filteredSpecs;
262
+
263
+ return {
264
+ selectedSpecs,
265
+ selectedSpecsRelative: selectedSpecs.map((specPath) => path.relative(repoRoot, specPath)),
266
+ changedPomEntries,
267
+ directChangedSpecFiles,
268
+ statusSummary: getStatusSummary(changedPomEntries),
269
+ impactedClasses,
270
+ impactedMethodsByClass,
271
+ fixtureKeys,
272
+ stageASelectedCount,
273
+ semanticStats,
274
+ propagationStats,
275
+ droppedByMethodFilter: methodFilterResult.droppedByMethodFilter,
276
+ retainedWithoutMethodFilter: methodFilterResult.retainedWithoutMethodFilter,
277
+ selectionReasons: methodFilterResult.selectionReasons,
278
+ hasAnythingToRun: selectedSpecs.length > 0,
279
+ warnings: [...changedEntriesResult.warnings, ...propagationWarnings, ...methodFilterResult.warnings],
280
+ coverageStats: {
281
+ uncertainCallSites: methodFilterResult.uncertainCallSites,
282
+ statusFallbackHits: changedEntriesResult.statusFallbackHits,
283
+ },
284
+ changedEntriesBySource: changedEntriesResult.changedEntriesBySource,
285
+ forcedAllSpecs: false,
286
+ forcedAllSpecsReason: null,
287
+ globalWatchMatches: globalWatch.matchedPaths,
288
+ globalWatchResolvedFiles: globalWatch.resolvedFiles,
289
+ };
290
+ };
291
+
292
+ module.exports = {
293
+ analyzeImpactedSpecs,
294
+ };
@@ -0,0 +1,29 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+
5
+ /**
6
+ * Format selection reasons as log-friendly lines for CLI output.
7
+ * The output is deterministic and can be truncated with maxLines.
8
+ */
9
+ const formatSelectionReasonsForLog = ({ selectedSpecs, selectionReasons, repoRoot, maxLines = 40 }) => {
10
+ if (!selectionReasons || typeof selectionReasons.get !== 'function' || selectedSpecs.length === 0) return '';
11
+
12
+ const lines = [];
13
+ for (const specPath of selectedSpecs) {
14
+ const reason = selectionReasons.get(specPath);
15
+ if (!reason) continue;
16
+ lines.push(` - ${path.relative(repoRoot, specPath)}: ${reason}`);
17
+ }
18
+
19
+ if (lines.length === 0) return '';
20
+ if (lines.length <= maxLines) return lines.join('\n');
21
+
22
+ const visible = lines.slice(0, maxLines);
23
+ const hidden = lines.length - maxLines;
24
+ return `${visible.join('\n')}\n - ... ${hidden} more selected specs with reasons`;
25
+ };
26
+
27
+ module.exports = {
28
+ formatSelectionReasonsForLog,
29
+ };
package/src/index.d.ts ADDED
@@ -0,0 +1,74 @@
1
+ export type ChangedEntryStatus = 'A' | 'M' | 'D' | 'R';
2
+
3
+ export type ChangedEntry = {
4
+ status: ChangedEntryStatus;
5
+ oldPath: string | null;
6
+ newPath: string | null;
7
+ effectivePath: string;
8
+ rawStatus?: string;
9
+ };
10
+
11
+ export type AnalyzeProfile = {
12
+ testsRootRelative: string;
13
+ changedSpecPrefix: string;
14
+ isRelevantPomPath: (filePath: string) => boolean;
15
+ analysisRootsRelative?: string[];
16
+ fixturesTypesRelative?: string;
17
+ globalWatchPatterns?: string[];
18
+ globalWatchMode?: 'force-all-in-project' | 'disabled';
19
+ };
20
+
21
+ export type AnalyzeOptions = {
22
+ repoRoot: string;
23
+ baseRef?: string | null;
24
+ profile: AnalyzeProfile;
25
+ includeUntrackedSpecs?: boolean;
26
+ includeWorkingTreeWithBase?: boolean;
27
+ fileExtensions?: string[];
28
+ selectionBias?: 'fail-open' | 'balanced' | 'fail-closed';
29
+ };
30
+
31
+ export type AnalyzeResult = {
32
+ selectedSpecs: string[];
33
+ selectedSpecsRelative: string[];
34
+ changedPomEntries: ChangedEntry[];
35
+ directChangedSpecFiles: string[];
36
+ statusSummary: { A: number; M: number; D: number; R: number };
37
+ impactedClasses: Set<string>;
38
+ impactedMethodsByClass: Map<string, Set<string>>;
39
+ fixtureKeys: Set<string>;
40
+ stageASelectedCount: number;
41
+ semanticStats: {
42
+ changedPomEntriesByStatus: { A: number; M: number; D: number; R: number };
43
+ semanticChangedMethodsCount: number;
44
+ topLevelRuntimeChangedFiles: number;
45
+ };
46
+ propagationStats: { impactedMethodsTotal: number };
47
+ droppedByMethodFilter: number;
48
+ retainedWithoutMethodFilter: number;
49
+ selectionReasons: Map<string, string>;
50
+ hasAnythingToRun: boolean;
51
+ warnings: string[];
52
+ coverageStats: {
53
+ uncertainCallSites: number;
54
+ statusFallbackHits: number;
55
+ };
56
+ changedEntriesBySource: {
57
+ fromBaseHead: number;
58
+ fromWorkingTree: number;
59
+ fromUntracked: number;
60
+ };
61
+ forcedAllSpecs: boolean;
62
+ forcedAllSpecsReason: string | null;
63
+ globalWatchMatches: string[];
64
+ globalWatchResolvedFiles: string[];
65
+ };
66
+
67
+ export function analyzeImpactedSpecs(options: AnalyzeOptions): AnalyzeResult;
68
+
69
+ export function formatSelectionReasonsForLog(args: {
70
+ selectedSpecs: string[];
71
+ selectionReasons: Map<string, string>;
72
+ repoRoot: string;
73
+ maxLines?: number;
74
+ }): string;
package/src/index.js ADDED
@@ -0,0 +1,10 @@
1
+ 'use strict';
2
+
3
+ const { analyzeImpactedSpecs } = require('./analyze-impacted-specs');
4
+ const { formatSelectionReasonsForLog } = require('./format-analyze-result');
5
+
6
+ // Public library surface.
7
+ module.exports = {
8
+ analyzeImpactedSpecs,
9
+ formatSelectionReasonsForLog,
10
+ };
@@ -0,0 +1,86 @@
1
+ 'use strict';
2
+
3
+ const parseClassNames = (content) => {
4
+ if (!content) return new Set();
5
+ const names = new Set();
6
+ const re = /(?:export\s+)?class\s+([A-Za-z_]\w*)/g;
7
+ let match = re.exec(content);
8
+ while (match) {
9
+ names.add(match[1]);
10
+ match = re.exec(content);
11
+ }
12
+ return names;
13
+ };
14
+
15
+ /**
16
+ * Build simple inheritance lookup maps from source files.
17
+ * These maps are reused in class impact and method propagation stages.
18
+ */
19
+ const buildInheritanceGraph = (pageFiles, readFile) => {
20
+ // Build parent/child lookup once so both Stage A and method propagation can resolve lineage.
21
+ const parentsByChild = new Map();
22
+ const childrenByParent = new Map();
23
+
24
+ for (const filePath of pageFiles) {
25
+ const content = readFile(filePath, 'utf8');
26
+ const re = /(?:export\s+)?class\s+([A-Za-z_]\w*)\s+extends\s+([A-Za-z_]\w*)/g;
27
+ let match = re.exec(content);
28
+ while (match) {
29
+ const child = match[1];
30
+ const parent = match[2];
31
+ parentsByChild.set(child, parent);
32
+ if (!childrenByParent.has(parent)) childrenByParent.set(parent, new Set());
33
+ childrenByParent.get(parent).add(child);
34
+ match = re.exec(content);
35
+ }
36
+ }
37
+
38
+ return { parentsByChild, childrenByParent };
39
+ };
40
+
41
+ /**
42
+ * Collect changed classes from base/head content and include all descendants.
43
+ * Descendant expansion prevents missing specs bound to inherited behavior.
44
+ */
45
+ const collectImpactedClasses = ({ changedPomEntries, childrenByParent, baseRef, readChangeContents }) => {
46
+ // Seed impacted classes from both base and head versions, then include descendants.
47
+ // This keeps fixture preselection safe for renamed files and inheritance-heavy POM trees.
48
+ const impacted = new Set();
49
+
50
+ for (const entry of changedPomEntries) {
51
+ const { baseContent, headContent } = readChangeContents(entry, baseRef);
52
+ for (const className of parseClassNames(baseContent)) impacted.add(className);
53
+ for (const className of parseClassNames(headContent)) impacted.add(className);
54
+ }
55
+
56
+ const queue = [...impacted];
57
+ while (queue.length > 0) {
58
+ const current = queue.shift();
59
+ const children = childrenByParent.get(current) || new Set();
60
+ for (const child of children) {
61
+ if (impacted.has(child)) continue;
62
+ impacted.add(child);
63
+ queue.push(child);
64
+ }
65
+ }
66
+
67
+ return impacted;
68
+ };
69
+
70
+ /**
71
+ * Convert impacted classes to fixture keys used in Stage A spec preselection.
72
+ */
73
+ const getFixtureKeysForClasses = (impactedClasses, classToFixtureKeys) => {
74
+ const keys = new Set();
75
+ for (const className of impactedClasses) {
76
+ const mappedKeys = classToFixtureKeys.get(className) || new Set();
77
+ for (const key of mappedKeys) keys.add(key);
78
+ }
79
+ return keys;
80
+ };
81
+
82
+ module.exports = {
83
+ buildInheritanceGraph,
84
+ collectImpactedClasses,
85
+ getFixtureKeysForClasses,
86
+ };