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 ADDED
@@ -0,0 +1,150 @@
1
+ # impacted
2
+
3
+ Find test files impacted by code changes using static dependency analysis.
4
+
5
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
+ [![npm version](https://img.shields.io/npm/v/impacted)](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
@@ -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
+ }
@@ -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
+ }