impacted 0.0.1
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 +150 -0
- package/bin/impacted.js +43 -0
- package/package.json +49 -0
- package/src/cache.js +136 -0
- package/src/graph.js +171 -0
- package/src/index.js +91 -0
- package/src/parser.js +75 -0
- package/src/resolver.js +116 -0
package/README.md
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# impacted
|
|
2
|
+
|
|
3
|
+
Find test files impacted by code changes using static dependency analysis.
|
|
4
|
+
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
[](https://www.npmjs.com/package/impacted)
|
|
7
|
+
|
|
8
|
+
## About
|
|
9
|
+
|
|
10
|
+
`impacted` analyzes your codebase's import graph to determine which test files are affected by a set of changed files. Run only the tests that matter instead of your entire test suite.
|
|
11
|
+
|
|
12
|
+
This project is a userland implementation of [predictive test selection for Node.js test runner](https://github.com/nodejs/node/issues/54173).
|
|
13
|
+
|
|
14
|
+
## Features
|
|
15
|
+
|
|
16
|
+
- Static analysis of ESM and CommonJS (import, require, dynamic import, re-exports)
|
|
17
|
+
- Supports Node.js subpath imports (`#imports`)
|
|
18
|
+
- Transitive dependency tracking
|
|
19
|
+
- Optional caching for faster repeated runs
|
|
20
|
+
- CLI and programmatic API
|
|
21
|
+
- GitHub Action for CI integration
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm install impacted
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
### CLI
|
|
32
|
+
|
|
33
|
+
Pipe changed files to `impacted`:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
git diff --name-only HEAD~1 | npx impacted
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
With a custom test pattern:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
git diff --name-only main | npx impacted -p "src/**/*.spec.js"
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Use with any test runner:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
# Node.js test runner
|
|
49
|
+
node --test $(git diff --name-only main | npx impacted)
|
|
50
|
+
|
|
51
|
+
# Vitest
|
|
52
|
+
vitest $(git diff --name-only main | npx impacted)
|
|
53
|
+
|
|
54
|
+
# Jest
|
|
55
|
+
jest $(git diff --name-only main | npx impacted)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### GitHub Action
|
|
59
|
+
|
|
60
|
+
```yaml
|
|
61
|
+
- uses: sozua/impacted@v1
|
|
62
|
+
id: impacted
|
|
63
|
+
with:
|
|
64
|
+
pattern: '**/*.test.js'
|
|
65
|
+
|
|
66
|
+
- name: Run impacted tests
|
|
67
|
+
if: steps.impacted.outputs.has-impacted == 'true'
|
|
68
|
+
run: node --test ${{ steps.impacted.outputs.files }}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
#### Inputs
|
|
72
|
+
|
|
73
|
+
| Input | Description | Default |
|
|
74
|
+
|-------|-------------|---------|
|
|
75
|
+
| `base` | Base ref to compare against | PR base or `HEAD~1` |
|
|
76
|
+
| `head` | Head ref | `HEAD` |
|
|
77
|
+
| `pattern` | Glob pattern for test files | `**/*.test.js` |
|
|
78
|
+
| `working-directory` | Working directory | `.` |
|
|
79
|
+
|
|
80
|
+
#### Outputs
|
|
81
|
+
|
|
82
|
+
| Output | Description |
|
|
83
|
+
|--------|-------------|
|
|
84
|
+
| `files` | Space-separated list of impacted test files |
|
|
85
|
+
| `files-json` | JSON array of impacted test files |
|
|
86
|
+
| `count` | Number of impacted test files |
|
|
87
|
+
| `has-impacted` | Whether any tests are impacted (`true`/`false`) |
|
|
88
|
+
|
|
89
|
+
### Programmatic API
|
|
90
|
+
|
|
91
|
+
```javascript
|
|
92
|
+
import { findImpacted } from 'impacted';
|
|
93
|
+
|
|
94
|
+
const tests = await findImpacted({
|
|
95
|
+
changedFiles: ['src/utils.js', 'src/parser.js'],
|
|
96
|
+
testFiles: 'test/**/*.test.js',
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
console.log(tests);
|
|
100
|
+
// ['/project/test/utils.test.js', '/project/test/parser.test.js']
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
#### With caching
|
|
104
|
+
|
|
105
|
+
```javascript
|
|
106
|
+
const tests = await findImpacted({
|
|
107
|
+
changedFiles: ['src/utils.js'],
|
|
108
|
+
testFiles: 'test/**/*.test.js',
|
|
109
|
+
cacheFile: '.impacted-cache.json',
|
|
110
|
+
});
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
#### Options
|
|
114
|
+
|
|
115
|
+
| Option | Type | Description |
|
|
116
|
+
|--------|------|-------------|
|
|
117
|
+
| `changedFiles` | `string[]` | Files that changed (relative or absolute) |
|
|
118
|
+
| `testFiles` | `string \| string[]` | Glob pattern(s) or explicit file list |
|
|
119
|
+
| `cwd` | `string` | Working directory (default: `process.cwd()`) |
|
|
120
|
+
| `excludePaths` | `string[]` | Paths to exclude (default: `['node_modules', 'dist']`) |
|
|
121
|
+
| `cacheFile` | `string` | Path to persist cache between runs |
|
|
122
|
+
|
|
123
|
+
## How it works
|
|
124
|
+
|
|
125
|
+
1. Build a dependency graph starting from your test files
|
|
126
|
+
2. Invert the graph to map each file to its dependents
|
|
127
|
+
3. Walk the inverted graph from changed files to find impacted tests
|
|
128
|
+
|
|
129
|
+
```
|
|
130
|
+
src/utils.js (changed)
|
|
131
|
+
↓ imported by
|
|
132
|
+
src/parser.js
|
|
133
|
+
↓ imported by
|
|
134
|
+
test/parser.test.js (impacted)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Known limitations
|
|
138
|
+
|
|
139
|
+
- **JavaScript only** - Supports `.js`, `.mjs`, `.cjs`, and `.jsx`. TypeScript files are not yet supported.
|
|
140
|
+
- **Static analysis** - Cannot detect dynamic imports with variables (e.g., `require(getModuleName())`). Only string literals are resolved.
|
|
141
|
+
- **Local files only** - Dependencies in `node_modules` are excluded from the graph. Changes to dependencies won't trigger impacted tests.
|
|
142
|
+
- **No TypeScript path aliases** - Only Node.js subpath imports (`#imports` in package.json) are supported, not `tsconfig.json` paths.
|
|
143
|
+
|
|
144
|
+
## Requirements
|
|
145
|
+
|
|
146
|
+
- Node.js >= 18
|
|
147
|
+
|
|
148
|
+
## License
|
|
149
|
+
|
|
150
|
+
MIT
|
package/bin/impacted.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { findImpacted } from '../src/index.js';
|
|
4
|
+
|
|
5
|
+
const args = process.argv.slice(2);
|
|
6
|
+
|
|
7
|
+
// Parse -p/--pattern flag
|
|
8
|
+
let pattern = '**/*.test.js';
|
|
9
|
+
const patternIndex = args.findIndex((arg) => arg === '-p' || arg === '--pattern');
|
|
10
|
+
if (patternIndex !== -1 && args[patternIndex + 1]) {
|
|
11
|
+
pattern = args[patternIndex + 1];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Read changed files from stdin
|
|
15
|
+
let input = '';
|
|
16
|
+
process.stdin.setEncoding('utf8');
|
|
17
|
+
|
|
18
|
+
process.stdin.on('readable', () => {
|
|
19
|
+
let chunk;
|
|
20
|
+
while ((chunk = process.stdin.read()) !== null) {
|
|
21
|
+
input += chunk;
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
process.stdin.on('end', async () => {
|
|
26
|
+
const changedFiles = input
|
|
27
|
+
.split('\n')
|
|
28
|
+
.map((line) => line.trim())
|
|
29
|
+
.filter((line) => line.length > 0);
|
|
30
|
+
|
|
31
|
+
if (changedFiles.length === 0) {
|
|
32
|
+
process.exit(0);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const impacted = await findImpacted({
|
|
36
|
+
changedFiles,
|
|
37
|
+
testFiles: pattern,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
for (const file of impacted) {
|
|
41
|
+
console.log(file);
|
|
42
|
+
}
|
|
43
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "impacted",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Find test files impacted by code changes using static dependency analysis",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"impacted": "./bin/impacted.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"src",
|
|
15
|
+
"bin"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "node --test"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"test",
|
|
22
|
+
"testing",
|
|
23
|
+
"affected",
|
|
24
|
+
"impacted",
|
|
25
|
+
"dependency",
|
|
26
|
+
"graph",
|
|
27
|
+
"ci",
|
|
28
|
+
"predictive",
|
|
29
|
+
"test-selection"
|
|
30
|
+
],
|
|
31
|
+
"author": "sozua",
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/sozua/impacted.git"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://github.com/sozua/impacted#readme",
|
|
38
|
+
"bugs": {
|
|
39
|
+
"url": "https://github.com/sozua/impacted/issues"
|
|
40
|
+
},
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=18.0.0"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"acorn": "^8.14.0",
|
|
46
|
+
"acorn-walk": "^8.3.4",
|
|
47
|
+
"fast-glob": "^3.3.3"
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/cache.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, statSync } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cache for import extraction results with mtime-based invalidation
|
|
5
|
+
*/
|
|
6
|
+
export class ImportCache {
|
|
7
|
+
/**
|
|
8
|
+
* @param {Object} options
|
|
9
|
+
* @param {string} [options.cacheFile] - Path to persist cache (optional)
|
|
10
|
+
*/
|
|
11
|
+
constructor(options = {}) {
|
|
12
|
+
this.cacheFile = options.cacheFile;
|
|
13
|
+
this.data = new Map();
|
|
14
|
+
this.hits = 0;
|
|
15
|
+
this.misses = 0;
|
|
16
|
+
|
|
17
|
+
if (this.cacheFile) {
|
|
18
|
+
this._load();
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Get cached imports for a file, or null if not cached/stale
|
|
24
|
+
* @param {string} filePath - Absolute file path
|
|
25
|
+
* @returns {string[] | null}
|
|
26
|
+
*/
|
|
27
|
+
get(filePath) {
|
|
28
|
+
const entry = this.data.get(filePath);
|
|
29
|
+
if (!entry) {
|
|
30
|
+
this.misses++;
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Check if file has been modified
|
|
35
|
+
let mtime;
|
|
36
|
+
try {
|
|
37
|
+
mtime = statSync(filePath).mtimeMs;
|
|
38
|
+
} catch {
|
|
39
|
+
// File doesn't exist anymore, invalidate
|
|
40
|
+
this.data.delete(filePath);
|
|
41
|
+
this.misses++;
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (entry.mtime !== mtime) {
|
|
46
|
+
this.data.delete(filePath);
|
|
47
|
+
this.misses++;
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
this.hits++;
|
|
52
|
+
return entry.imports;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Store imports for a file
|
|
57
|
+
* @param {string} filePath - Absolute file path
|
|
58
|
+
* @param {string[]} imports - Extracted import specifiers
|
|
59
|
+
*/
|
|
60
|
+
set(filePath, imports) {
|
|
61
|
+
let mtime;
|
|
62
|
+
try {
|
|
63
|
+
mtime = statSync(filePath).mtimeMs;
|
|
64
|
+
} catch {
|
|
65
|
+
// Can't stat file, don't cache
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
this.data.set(filePath, { mtime, imports });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Get cache statistics
|
|
74
|
+
* @returns {{ hits: number, misses: number, size: number }}
|
|
75
|
+
*/
|
|
76
|
+
stats() {
|
|
77
|
+
return {
|
|
78
|
+
hits: this.hits,
|
|
79
|
+
misses: this.misses,
|
|
80
|
+
size: this.data.size,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Persist cache to disk
|
|
86
|
+
*/
|
|
87
|
+
save() {
|
|
88
|
+
if (!this.cacheFile) return;
|
|
89
|
+
|
|
90
|
+
const serialized = {};
|
|
91
|
+
for (const [key, value] of this.data) {
|
|
92
|
+
serialized[key] = value;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
writeFileSync(this.cacheFile, JSON.stringify(serialized), 'utf8');
|
|
97
|
+
} catch {
|
|
98
|
+
// Ignore write errors (e.g., read-only filesystem)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Clear all cached data
|
|
104
|
+
*/
|
|
105
|
+
clear() {
|
|
106
|
+
this.data.clear();
|
|
107
|
+
this.hits = 0;
|
|
108
|
+
this.misses = 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Load cache from disk
|
|
113
|
+
* @private
|
|
114
|
+
*/
|
|
115
|
+
_load() {
|
|
116
|
+
try {
|
|
117
|
+
const content = readFileSync(this.cacheFile, 'utf8');
|
|
118
|
+
const parsed = JSON.parse(content);
|
|
119
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
120
|
+
this.data.set(key, value);
|
|
121
|
+
}
|
|
122
|
+
} catch {
|
|
123
|
+
// No cache file or invalid, start fresh
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Create a cache instance
|
|
130
|
+
* @param {Object} [options]
|
|
131
|
+
* @param {string} [options.cacheFile] - Path to persist cache
|
|
132
|
+
* @returns {ImportCache}
|
|
133
|
+
*/
|
|
134
|
+
export function createCache(options) {
|
|
135
|
+
return new ImportCache(options);
|
|
136
|
+
}
|
package/src/graph.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { extname } from 'node:path';
|
|
3
|
+
import { parseImports } from './parser.js';
|
|
4
|
+
import { resolveSpecifier } from './resolver.js';
|
|
5
|
+
|
|
6
|
+
const SUPPORTED_EXTENSIONS = ['.js', '.mjs', '.cjs', '.jsx'];
|
|
7
|
+
|
|
8
|
+
/** @typedef {import('./cache.js').ImportCache} ImportCache */
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Check if a file has a supported extension
|
|
12
|
+
* @param {string} filePath
|
|
13
|
+
* @returns {boolean}
|
|
14
|
+
*/
|
|
15
|
+
function isSupportedFile(filePath) {
|
|
16
|
+
return SUPPORTED_EXTENSIONS.includes(extname(filePath));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Extract imports from a file
|
|
21
|
+
* @param {string} filePath - Absolute path to the file
|
|
22
|
+
* @returns {string[]} Array of import specifiers
|
|
23
|
+
*/
|
|
24
|
+
export function extractImports(filePath) {
|
|
25
|
+
let source;
|
|
26
|
+
try {
|
|
27
|
+
source = readFileSync(filePath, 'utf8');
|
|
28
|
+
} catch {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
return parseImports(source);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const DEFAULT_EXCLUDE_PATHS = ['node_modules', 'dist'];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Check if a file path matches any excluded path pattern
|
|
38
|
+
* @param {string} filePath
|
|
39
|
+
* @param {string[]} excludePaths
|
|
40
|
+
* @returns {boolean}
|
|
41
|
+
*/
|
|
42
|
+
function isExcluded(filePath, excludePaths) {
|
|
43
|
+
return excludePaths.some((pattern) => filePath.includes(pattern));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Build a dependency graph starting from entry files
|
|
48
|
+
* @param {string[]} entryFiles - Array of absolute file paths
|
|
49
|
+
* @param {Object} options
|
|
50
|
+
* @param {string[]} [options.excludePaths=['node_modules', 'dist']] - Paths to exclude from graph
|
|
51
|
+
* @param {ImportCache} [options.cache] - Optional cache for import extraction
|
|
52
|
+
* @returns {Map<string, Set<string>>} Map of file -> dependencies
|
|
53
|
+
*/
|
|
54
|
+
export function buildDependencyGraph(entryFiles, options = {}) {
|
|
55
|
+
const { excludePaths = DEFAULT_EXCLUDE_PATHS, cache } = options;
|
|
56
|
+
|
|
57
|
+
const graph = new Map();
|
|
58
|
+
const visited = new Set();
|
|
59
|
+
const pending = [...entryFiles];
|
|
60
|
+
|
|
61
|
+
for (let i = 0; i < pending.length; i++) {
|
|
62
|
+
const file = pending[i];
|
|
63
|
+
|
|
64
|
+
if (visited.has(file)) continue;
|
|
65
|
+
visited.add(file);
|
|
66
|
+
|
|
67
|
+
if (isExcluded(file, excludePaths)) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!isSupportedFile(file)) {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Try cache first
|
|
76
|
+
let imports = cache?.get(file);
|
|
77
|
+
if (imports === null || imports === undefined) {
|
|
78
|
+
imports = extractImports(file);
|
|
79
|
+
cache?.set(file, imports);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const dependencies = new Set();
|
|
83
|
+
|
|
84
|
+
for (const specifier of imports) {
|
|
85
|
+
const resolved = resolveSpecifier(specifier, file);
|
|
86
|
+
if (!resolved || isExcluded(resolved, excludePaths)) continue;
|
|
87
|
+
|
|
88
|
+
dependencies.add(resolved);
|
|
89
|
+
if (!visited.has(resolved)) {
|
|
90
|
+
pending.push(resolved);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
graph.set(file, dependencies);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return graph;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Invert the graph: file -> files that depend on it
|
|
102
|
+
* @param {Map<string, Set<string>>} graph
|
|
103
|
+
* @returns {Map<string, Set<string>>}
|
|
104
|
+
*/
|
|
105
|
+
export function invertGraph(graph) {
|
|
106
|
+
const inverted = new Map();
|
|
107
|
+
|
|
108
|
+
for (const [file, deps] of graph) {
|
|
109
|
+
for (const dep of deps) {
|
|
110
|
+
if (!inverted.has(dep)) {
|
|
111
|
+
inverted.set(dep, new Set());
|
|
112
|
+
}
|
|
113
|
+
inverted.get(dep).add(file);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return inverted;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Find all files impacted by changes to the given files
|
|
122
|
+
* @param {string[]} changedFiles - Changed file paths (absolute)
|
|
123
|
+
* @param {string[]} testFiles - Test file paths (absolute)
|
|
124
|
+
* @param {Map<string, Set<string>>} graph - Dependency graph
|
|
125
|
+
* @returns {string[]} Impacted test files
|
|
126
|
+
*/
|
|
127
|
+
export function findImpactedTests(changedFiles, testFiles, graph) {
|
|
128
|
+
const inverted = invertGraph(graph);
|
|
129
|
+
const impacted = new Set();
|
|
130
|
+
const visited = new Set();
|
|
131
|
+
const pending = [...changedFiles];
|
|
132
|
+
const testSet = new Set(testFiles);
|
|
133
|
+
|
|
134
|
+
for (let i = 0; i < pending.length; i++) {
|
|
135
|
+
const file = pending[i];
|
|
136
|
+
|
|
137
|
+
if (visited.has(file)) continue;
|
|
138
|
+
visited.add(file);
|
|
139
|
+
|
|
140
|
+
// If this file is a test file, it's impacted
|
|
141
|
+
if (testSet.has(file)) {
|
|
142
|
+
impacted.add(file);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Add all files that depend on this file
|
|
146
|
+
const dependents = inverted.get(file);
|
|
147
|
+
if (dependents) {
|
|
148
|
+
for (const dependent of dependents) {
|
|
149
|
+
if (!visited.has(dependent)) {
|
|
150
|
+
pending.push(dependent);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return [...impacted];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Main entry: find test files impacted by changed files
|
|
161
|
+
* @param {string[]} changedFiles - Changed files (absolute paths)
|
|
162
|
+
* @param {string[]} testFiles - All test files (absolute paths)
|
|
163
|
+
* @param {Object} options
|
|
164
|
+
* @param {string[]} [options.excludePaths=['node_modules', 'dist']] - Paths to exclude from graph
|
|
165
|
+
* @param {ImportCache} [options.cache] - Optional cache for import extraction
|
|
166
|
+
* @returns {string[]} Impacted test files
|
|
167
|
+
*/
|
|
168
|
+
export function getImpactedTests(changedFiles, testFiles, options = {}) {
|
|
169
|
+
const graph = buildDependencyGraph(testFiles, options);
|
|
170
|
+
return findImpactedTests(changedFiles, testFiles, graph);
|
|
171
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import fg from 'fast-glob';
|
|
3
|
+
import { getImpactedTests } from './graph.js';
|
|
4
|
+
import { createCache, ImportCache } from './cache.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Find test files impacted by code changes
|
|
8
|
+
*
|
|
9
|
+
* @param {Object} options
|
|
10
|
+
* @param {string[]} options.changedFiles - Files that changed (relative or absolute)
|
|
11
|
+
* @param {string|string[]} options.testFiles - Test file glob pattern(s) or explicit file list
|
|
12
|
+
* @param {string} [options.cwd=process.cwd()] - Working directory
|
|
13
|
+
* @param {string[]} [options.excludePaths=['node_modules', 'dist']] - Paths to exclude from graph
|
|
14
|
+
* @param {ImportCache} [options.cache] - Cache instance for import extraction
|
|
15
|
+
* @param {string} [options.cacheFile] - Path to persist cache (creates cache automatically)
|
|
16
|
+
* @returns {Promise<string[]>} Impacted test file paths (absolute)
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* import { findImpacted } from 'impacted';
|
|
20
|
+
*
|
|
21
|
+
* const tests = await findImpacted({
|
|
22
|
+
* changedFiles: ['src/utils.js'],
|
|
23
|
+
* testFiles: 'test/** /*.test.js',
|
|
24
|
+
* });
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* // With persistent caching
|
|
28
|
+
* const tests = await findImpacted({
|
|
29
|
+
* changedFiles: ['src/utils.js'],
|
|
30
|
+
* testFiles: 'test/** /*.test.js',
|
|
31
|
+
* cacheFile: '.impacted-cache.json',
|
|
32
|
+
* });
|
|
33
|
+
*/
|
|
34
|
+
export async function findImpacted(options) {
|
|
35
|
+
const {
|
|
36
|
+
changedFiles,
|
|
37
|
+
testFiles,
|
|
38
|
+
cwd = process.cwd(),
|
|
39
|
+
excludePaths,
|
|
40
|
+
cache: providedCache,
|
|
41
|
+
cacheFile,
|
|
42
|
+
} = options;
|
|
43
|
+
|
|
44
|
+
if (!changedFiles || changedFiles.length === 0) {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Resolve changed files to absolute paths
|
|
49
|
+
const absoluteChangedFiles = changedFiles.map((f) => resolve(cwd, f));
|
|
50
|
+
|
|
51
|
+
// Resolve test files - either glob or explicit list
|
|
52
|
+
let absoluteTestFiles;
|
|
53
|
+
if (typeof testFiles === 'string' || (Array.isArray(testFiles) && testFiles.some((t) => t.includes('*')))) {
|
|
54
|
+
// It's a glob pattern
|
|
55
|
+
const patterns = Array.isArray(testFiles) ? testFiles : [testFiles];
|
|
56
|
+
absoluteTestFiles = await fg(patterns, { cwd, absolute: true });
|
|
57
|
+
} else if (Array.isArray(testFiles)) {
|
|
58
|
+
// Explicit file list
|
|
59
|
+
absoluteTestFiles = testFiles.map((f) => resolve(cwd, f));
|
|
60
|
+
} else {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (absoluteTestFiles.length === 0) {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Set up cache
|
|
69
|
+
let cache = providedCache;
|
|
70
|
+
if (!cache && cacheFile) {
|
|
71
|
+
cache = createCache({ cacheFile: resolve(cwd, cacheFile) });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const result = getImpactedTests(absoluteChangedFiles, absoluteTestFiles, {
|
|
75
|
+
excludePaths,
|
|
76
|
+
cache,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Persist cache if using cacheFile option
|
|
80
|
+
if (!providedCache && cacheFile && cache) {
|
|
81
|
+
cache.save();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Re-export lower-level APIs for advanced usage
|
|
88
|
+
export { buildDependencyGraph, findImpactedTests, invertGraph } from './graph.js';
|
|
89
|
+
export { parseImports } from './parser.js';
|
|
90
|
+
export { resolveSpecifier } from './resolver.js';
|
|
91
|
+
export { createCache, ImportCache } from './cache.js';
|
package/src/parser.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import * as acorn from 'acorn';
|
|
2
|
+
import * as walk from 'acorn-walk';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Extract all import/require specifiers from source code
|
|
6
|
+
* Handles ESM (import/export) and CJS (require) patterns
|
|
7
|
+
* @param {string} source - The source code
|
|
8
|
+
* @returns {string[]} Array of import specifiers
|
|
9
|
+
*/
|
|
10
|
+
export function parseImports(source) {
|
|
11
|
+
const imports = [];
|
|
12
|
+
|
|
13
|
+
let ast;
|
|
14
|
+
try {
|
|
15
|
+
ast = acorn.parse(source, {
|
|
16
|
+
ecmaVersion: 'latest',
|
|
17
|
+
sourceType: 'module',
|
|
18
|
+
allowHashBang: true,
|
|
19
|
+
allowReturnOutsideFunction: true,
|
|
20
|
+
allowAwaitOutsideFunction: true,
|
|
21
|
+
});
|
|
22
|
+
} catch {
|
|
23
|
+
// If module mode fails, try script mode for pure CJS files
|
|
24
|
+
try {
|
|
25
|
+
ast = acorn.parse(source, {
|
|
26
|
+
ecmaVersion: 'latest',
|
|
27
|
+
sourceType: 'script',
|
|
28
|
+
allowHashBang: true,
|
|
29
|
+
allowReturnOutsideFunction: true,
|
|
30
|
+
});
|
|
31
|
+
} catch {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
walk.simple(ast, {
|
|
37
|
+
// require('specifier')
|
|
38
|
+
CallExpression(node) {
|
|
39
|
+
if (
|
|
40
|
+
node.callee.name === 'require' &&
|
|
41
|
+
node.arguments.length > 0 &&
|
|
42
|
+
node.arguments[0].type === 'Literal' &&
|
|
43
|
+
typeof node.arguments[0].value === 'string'
|
|
44
|
+
) {
|
|
45
|
+
imports.push(node.arguments[0].value);
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
// import('specifier') - dynamic import
|
|
49
|
+
ImportExpression(node) {
|
|
50
|
+
if (node.source.type === 'Literal' && typeof node.source.value === 'string') {
|
|
51
|
+
imports.push(node.source.value);
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
// import x from 'specifier'
|
|
55
|
+
ImportDeclaration(node) {
|
|
56
|
+
if (node.source.type === 'Literal' && typeof node.source.value === 'string') {
|
|
57
|
+
imports.push(node.source.value);
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
// export * from 'specifier'
|
|
61
|
+
ExportAllDeclaration(node) {
|
|
62
|
+
if (node.source?.type === 'Literal' && typeof node.source.value === 'string') {
|
|
63
|
+
imports.push(node.source.value);
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
// export { x } from 'specifier'
|
|
67
|
+
ExportNamedDeclaration(node) {
|
|
68
|
+
if (node.source?.type === 'Literal' && typeof node.source.value === 'string') {
|
|
69
|
+
imports.push(node.source.value);
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
return imports;
|
|
75
|
+
}
|
package/src/resolver.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { builtinModules } from 'node:module';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Find the nearest package.json from a given path
|
|
8
|
+
* @param {string} startPath
|
|
9
|
+
* @returns {{ path: string, pkg: object } | null}
|
|
10
|
+
*/
|
|
11
|
+
function findPackageJson(startPath) {
|
|
12
|
+
let dir = dirname(startPath);
|
|
13
|
+
const root = resolve('/');
|
|
14
|
+
|
|
15
|
+
while (dir !== root) {
|
|
16
|
+
const pkgPath = join(dir, 'package.json');
|
|
17
|
+
try {
|
|
18
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
19
|
+
return { path: pkgPath, pkg, dir };
|
|
20
|
+
} catch {
|
|
21
|
+
dir = dirname(dir);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Resolve subpath imports (#imports) from package.json imports field
|
|
29
|
+
* @param {string} specifier - The import specifier starting with #
|
|
30
|
+
* @param {string} parentPath - Absolute path to the parent file
|
|
31
|
+
* @returns {string|null}
|
|
32
|
+
*/
|
|
33
|
+
function resolveSubpathImport(specifier, parentPath) {
|
|
34
|
+
const pkgInfo = findPackageJson(parentPath);
|
|
35
|
+
if (!pkgInfo || !pkgInfo.pkg.imports) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const { imports } = pkgInfo.pkg;
|
|
40
|
+
|
|
41
|
+
// Direct match
|
|
42
|
+
if (imports[specifier]) {
|
|
43
|
+
const target = resolveImportTarget(imports[specifier]);
|
|
44
|
+
if (target) {
|
|
45
|
+
return resolve(pkgInfo.dir, target);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Pattern match (e.g., "#utils/*" -> "./src/utils/*")
|
|
50
|
+
for (const [pattern, target] of Object.entries(imports)) {
|
|
51
|
+
if (pattern.includes('*')) {
|
|
52
|
+
const prefix = pattern.slice(0, pattern.indexOf('*'));
|
|
53
|
+
const suffix = pattern.slice(pattern.indexOf('*') + 1);
|
|
54
|
+
|
|
55
|
+
if (specifier.startsWith(prefix) && specifier.endsWith(suffix)) {
|
|
56
|
+
const matched = specifier.slice(prefix.length, specifier.length - suffix.length || undefined);
|
|
57
|
+
const resolvedTarget = resolveImportTarget(target);
|
|
58
|
+
if (resolvedTarget) {
|
|
59
|
+
const finalPath = resolvedTarget.replace('*', matched);
|
|
60
|
+
return resolve(pkgInfo.dir, finalPath);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Resolve an import target (handles conditional exports)
|
|
71
|
+
* @param {string|object} target
|
|
72
|
+
* @returns {string|null}
|
|
73
|
+
*/
|
|
74
|
+
function resolveImportTarget(target) {
|
|
75
|
+
if (typeof target === 'string') {
|
|
76
|
+
return target;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (typeof target === 'object' && target !== null) {
|
|
80
|
+
// Priority: import > node > default > require
|
|
81
|
+
const conditions = ['import', 'node', 'default', 'require'];
|
|
82
|
+
for (const condition of conditions) {
|
|
83
|
+
if (target[condition]) {
|
|
84
|
+
return resolveImportTarget(target[condition]);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Resolve a specifier to an absolute file path
|
|
94
|
+
* Handles: CJS require, ESM import, subpath imports (#)
|
|
95
|
+
* @param {string} specifier - The import/require specifier
|
|
96
|
+
* @param {string} parentPath - Absolute path to the parent file
|
|
97
|
+
* @returns {string|null} Resolved absolute path or null if resolution fails
|
|
98
|
+
*/
|
|
99
|
+
export function resolveSpecifier(specifier, parentPath) {
|
|
100
|
+
// Skip built-in modules
|
|
101
|
+
if (specifier.startsWith('node:') || builtinModules.includes(specifier)) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Handle subpath imports (#imports)
|
|
106
|
+
if (specifier.startsWith('#')) {
|
|
107
|
+
return resolveSubpathImport(specifier, parentPath);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
const require = createRequire(parentPath);
|
|
112
|
+
return require.resolve(specifier);
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|