Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/react-detect/src/bin/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { detect19 } from '../commands/detect19.js';
const args = process.argv.slice(2);
const argv = minimist(args, {
boolean: ['json', 'skipBuildTooling', 'skipDependencies', 'noErrorExitCode'],
string: ['pluginRoot'],
string: ['pluginRoot', 'distDir'],
default: {
json: false,
skipBuildTooling: false,
Expand Down
12 changes: 7 additions & 5 deletions packages/react-detect/src/commands/detect19.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import minimist from 'minimist';
import { join } from 'node:path';
import { findSourceMapFiles } from '../file-scanner.js';
import { generateAnalysisResults } from '../results.js';
import { DependencyContext } from '../utils/dependencies.js';
Expand All @@ -13,11 +14,12 @@ import { output } from '../utils/output.js';
export async function detect19(argv: minimist.ParsedArgs) {
try {
const pluginRoot = argv.pluginRoot || process.cwd();
const distDir = argv.distDir || join(pluginRoot, 'dist');
const skipDependencies = argv.skipDependencies || false;
const skipBuildTooling = argv.skipBuildTooling || false;
const jsonOutput = argv.json || false;

const allMatches = await getAllMatches(pluginRoot);
const allMatches = await getAllMatches(distDir);

// Conditionally load dependencies
let depContext: DependencyContext | null = null;
Expand Down Expand Up @@ -52,7 +54,7 @@ export async function detect19(argv: minimist.ParsedArgs) {
return match;
});

const results = generateAnalysisResults(matchesWithRootDependency, pluginRoot, depContext, {
const results = generateAnalysisResults(matchesWithRootDependency, distDir, pluginRoot, depContext, {
skipBuildTooling,
skipDependencies,
});
Expand All @@ -77,11 +79,11 @@ export async function detect19(argv: minimist.ParsedArgs) {
}
}

async function getAllMatches(pluginRoot: string) {
const sourcemapPaths = await findSourceMapFiles(pluginRoot);
async function getAllMatches(distDir: string) {
const sourcemapPaths = await findSourceMapFiles(distDir);

if (sourcemapPaths.length === 0) {
throw new Error('No source map files found in dist directory. Make sure to build your plugin first.');
throw new Error(`No source map files found in "${distDir}". Make sure to build your plugin first.`);
}

const sources = await extractAllSources(sourcemapPaths);
Expand Down
36 changes: 36 additions & 0 deletions packages/react-detect/src/file-scanner.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest';
import { findSourceMapFiles } from './file-scanner.js';
import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

describe('findSourceMapFiles', () => {
it('searches the given directory directly without appending a dist subdirectory', async () => {
const dir = mkdtempSync(join(tmpdir(), 'react-detect-test-'));
writeFileSync(join(dir, 'module.js.map'), '{}');

const files = await findSourceMapFiles(dir);

expect(files).toHaveLength(1);
expect(files[0]).toContain('module.js.map');
});

it('finds source map files recursively within the given directory', async () => {
const dir = mkdtempSync(join(tmpdir(), 'react-detect-test-'));
mkdirSync(join(dir, 'nested'));
writeFileSync(join(dir, 'a.js.map'), '{}');
writeFileSync(join(dir, 'nested', 'b.js.map'), '{}');

const files = await findSourceMapFiles(dir);

expect(files).toHaveLength(2);
});

it('returns empty array when no source map files exist', async () => {
const dir = mkdtempSync(join(tmpdir(), 'react-detect-test-'));

const files = await findSourceMapFiles(dir);

expect(files).toHaveLength(0);
});
});
9 changes: 3 additions & 6 deletions packages/react-detect/src/file-scanner.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,16 @@
import fg from 'fast-glob';
import { join } from 'node:path';

export async function findSourceMapFiles(directory: string): Promise<string[]> {
const distDirectory = join(directory, 'dist');

export async function findSourceMapFiles(distDir: string): Promise<string[]> {
try {
const files = await fg('**/*.js.map', {
cwd: distDirectory,
cwd: distDir,
absolute: true,
ignore: ['**/node_modules/**'],
});
return files;
} catch (error) {
throw new Error(
`Error finding source map files in ${distDirectory}: ${error instanceof Error ? error.message : 'Unknown error'}`
`Error finding source map files in "${distDir}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
13 changes: 7 additions & 6 deletions packages/react-detect/src/results.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ describe('generateAnalysisResults', () => {
bundledFilePath: `node_modules/${packageName}/index.js`,
});

const distDir = process.cwd();
const pluginRoot = process.cwd();
const depContext = new DependencyContext();
const options: AnalysisOptions = {
Expand Down Expand Up @@ -64,7 +65,7 @@ describe('generateAnalysisResults', () => {
bundledFilePath: 'src/components/MyComponent.tsx',
};
const matches: AnalyzedMatch[] = [sourceMatch, createDependencyMatch('react', 'react')];
const results = generateAnalysisResults(matches, pluginRoot, depContext, options);
const results = generateAnalysisResults(matches, distDir, pluginRoot, depContext, options);

expect(results.summary.sourceIssuesCount).toBe(1);
expect(results.summary.dependencyIssuesCount).toBe(0);
Expand All @@ -75,7 +76,7 @@ describe('generateAnalysisResults', () => {
createDependencyMatch('lodash', 'lodash'), // lodash is externalized by Grafana
createDependencyMatch('axios', 'axios'),
];
const results = generateAnalysisResults(matches, pluginRoot, depContext, options);
const results = generateAnalysisResults(matches, distDir, pluginRoot, depContext, options);

expect(results.issues.dependencies).toHaveLength(1);
expect(results.issues.dependencies[0].packageName).toBe('axios');
Expand All @@ -87,7 +88,7 @@ describe('generateAnalysisResults', () => {
createDependencyMatch('@grafana/ui', '@grafana/ui'),
createDependencyMatch('@custom/package', '@custom/package'),
];
const results = generateAnalysisResults(matches, pluginRoot, depContext, options);
const results = generateAnalysisResults(matches, distDir, pluginRoot, depContext, options);

expect(results.issues.dependencies).toHaveLength(1);
expect(results.issues.dependencies[0].packageName).toBe('@custom/package');
Expand All @@ -98,7 +99,7 @@ describe('generateAnalysisResults', () => {
createDependencyMatch('@grafana/data/utils', '@grafana/data'),
createDependencyMatch('@custom/package/utils', '@custom/package'),
];
const results = generateAnalysisResults(matches, pluginRoot, depContext, options);
const results = generateAnalysisResults(matches, distDir, pluginRoot, depContext, options);

expect(results.issues.dependencies).toHaveLength(1);
expect(results.issues.dependencies[0].packageName).toBe('@custom/package/utils');
Expand All @@ -111,7 +112,7 @@ describe('generateAnalysisResults', () => {
createDependencyMatch('scheduler', 'react'),
createDependencyMatch('debug', 'axios'), // Non-externalized root dependency
];
const results = generateAnalysisResults(matches, pluginRoot, depContext, options);
const results = generateAnalysisResults(matches, distDir, pluginRoot, depContext, options);

expect(results.issues.dependencies).toHaveLength(1);
expect(results.issues.dependencies[0].packageName).toBe('debug');
Expand All @@ -125,7 +126,7 @@ describe('generateAnalysisResults', () => {
createDependencyMatch('@grafana/data', '@grafana/data'),
createDependencyMatch('axios', 'axios'),
];
const results = generateAnalysisResults(matches, pluginRoot, depContext, options);
const results = generateAnalysisResults(matches, distDir, pluginRoot, depContext, options);
// Filter to only critical dep issues
const reportedDeps = results.issues.critical.filter((i) => i.location.type === 'dependency');
expect(reportedDeps).toHaveLength(1);
Expand Down
24 changes: 13 additions & 11 deletions packages/react-detect/src/results.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,13 @@ export interface AnalysisOptions {

export function generateAnalysisResults(
matches: AnalyzedMatch[],
distDir: string,
pluginRoot: string,
depContext: DependencyContext | null,
options: AnalysisOptions = { skipBuildTooling: false, skipDependencies: false }
Comment on lines 13 to 18
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

generateAnalysisResults now takes only distDir, but other parts of result generation still rely on process.cwd() (e.g., build-tooling checks via hasExternalisedJsxRuntime() and reported file paths). This means running the CLI from outside the plugin root (a key --distDir use case) can yield incorrect build-tooling detection and misleading file locations. Consider threading a pluginRoot/baseDir through results generation (separate from distDir) and using that instead of process.cwd() for config/file path resolution.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. generateAnalysisResults now accepts pluginRoot as a second argument (after distDir). It is threaded through to hasExternalisedJsxRuntime(pluginRoot), generateResult(m, pluginRoot), and buildDependencyIssues(..., pluginRoot) — replacing all process.cwd() calls in the results path.

): PluginAnalysisResults {
const filtered = filterMatches(matches, options.skipBuildTooling);
const pluginJson = getPluginJson(pluginRoot);
const filtered = filterMatches(matches, options.skipBuildTooling, pluginRoot);
const pluginJson = getPluginJson(distDir);

// Filter out externalized dependencies
const filteredWithoutExternals = filtered.filter((match) => shouldIncludeDependencyMatch(match, depContext));
Expand All @@ -35,9 +36,9 @@ export function generateAnalysisResults(
return pattern?.impactLevel === 'warning';
});

const critical = criticalMatches.map((m) => generateResult(m));
const warnings = warningMatches.map((m) => generateResult(m));
const dependencies = buildDependencyIssues(dependencyMatches, depContext);
const critical = criticalMatches.map((m) => generateResult(m, pluginRoot));
const warnings = warningMatches.map((m) => generateResult(m, pluginRoot));
const dependencies = buildDependencyIssues(dependencyMatches, depContext, pluginRoot);

const totalIssues = filteredWithoutExternals.length;
const affectedDeps = new Set(
Expand Down Expand Up @@ -78,9 +79,9 @@ function shouldIncludeDependencyMatch(match: AnalyzedMatch, _depContext: Depende
return true;
}

function filterMatches(matches: AnalyzedMatch[], skipBuildTooling: boolean): AnalyzedMatch[] {
function filterMatches(matches: AnalyzedMatch[], skipBuildTooling: boolean, pluginRoot: string): AnalyzedMatch[] {
// Only check webpack config if NOT skipping build tooling
const externalisedJsxRuntime = skipBuildTooling ? false : hasExternalisedJsxRuntime();
const externalisedJsxRuntime = skipBuildTooling ? false : hasExternalisedJsxRuntime(pluginRoot);
const filtered = matches.filter((match) => {
// TODO: add mode for strict / loose filtering
if (match.type === 'source' && (match.confidence === 'none' || match.confidence === 'unknown')) {
Expand Down Expand Up @@ -120,7 +121,7 @@ function filterMatches(matches: AnalyzedMatch[], skipBuildTooling: boolean): Ana
return filtered;
}

function generateResult(match: AnalyzedMatch): AnalysisResult {
function generateResult(match: AnalyzedMatch, pluginRoot: string): AnalysisResult {
const pattern = getPattern(match.pattern);

if (!pattern) {
Expand All @@ -135,7 +136,7 @@ function generateResult(match: AnalyzedMatch): AnalysisResult {
impactLevel: pattern.impactLevel,
location: {
type: match.type,
file: path.join(process.cwd(), cleanFilePath),
file: path.join(pluginRoot, cleanFilePath),
line: match.sourceLine,
column: match.sourceColumn,
},
Expand All @@ -162,7 +163,8 @@ function generateResult(match: AnalyzedMatch): AnalysisResult {
*/
function buildDependencyIssues(
dependencyMatches: AnalyzedMatch[],
depContext: DependencyContext | null
depContext: DependencyContext | null,
pluginRoot: string
): DependencyIssue[] {
// Group by package
const byPackage = new Map<string, AnalyzedMatch[]>();
Expand All @@ -185,7 +187,7 @@ function buildDependencyIssues(
packageName,
version: version || 'unknown',
rootDependency: rootDep,
issues: matches.map((m) => generateResult(m)),
issues: matches.map((m) => generateResult(m, pluginRoot)),
});
}

Expand Down
66 changes: 66 additions & 0 deletions packages/react-detect/src/utils/plugin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, it, expect } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { getPluginJson, hasExternalisedJsxRuntime } from './plugin.js';

describe('getPluginJson', () => {
it('reads plugin.json from the given distDir directly without appending /dist', () => {
const distDir = mkdtempSync(join(tmpdir(), 'react-detect-plugin-test-'));
const pluginJson = { id: 'my-plugin', name: 'My Plugin', type: 'app', info: { version: '2.0.0' } };
writeFileSync(join(distDir, 'plugin.json'), JSON.stringify(pluginJson));

const result = getPluginJson(distDir);

expect(result?.id).toBe('my-plugin');
expect(result?.info.version).toBe('2.0.0');
});

it('returns correct data for each distDir when called with different directories', () => {
const distDir1 = mkdtempSync(join(tmpdir(), 'react-detect-plugin-test-'));
writeFileSync(
join(distDir1, 'plugin.json'),
JSON.stringify({ id: 'plugin-1', name: 'P1', type: 'app', info: { version: '1.0.0' } })
);
const distDir2 = mkdtempSync(join(tmpdir(), 'react-detect-plugin-test-'));
writeFileSync(
join(distDir2, 'plugin.json'),
JSON.stringify({ id: 'plugin-2', name: 'P2', type: 'panel', info: { version: '2.0.0' } })
);

const result1 = getPluginJson(distDir1);
const result2 = getPluginJson(distDir2);

expect(result1?.id).toBe('plugin-1');
expect(result2?.id).toBe('plugin-2');
});

it('throws when plugin.json does not exist in the given distDir', () => {
const distDir = mkdtempSync(join(tmpdir(), 'react-detect-plugin-test-'));

expect(() => getPluginJson(distDir)).toThrow('plugin.json');
});
});

describe('hasExternalisedJsxRuntime', () => {
it('detects react/jsx-runtime in webpack externals using the given pluginRoot', () => {
const pluginRoot = mkdtempSync(join(tmpdir(), 'react-detect-plugin-test-'));
writeFileSync(join(pluginRoot, 'webpack.config.ts'), `module.exports = { externals: ['react/jsx-runtime'] };`);

expect(hasExternalisedJsxRuntime(pluginRoot)).toBe(true);
});

it('returns false when the given pluginRoot has no matching webpack config', () => {
const pluginRoot = mkdtempSync(join(tmpdir(), 'react-detect-plugin-test-'));

expect(hasExternalisedJsxRuntime(pluginRoot)).toBe(false);
});

it('detects react/jsx-runtime in bundler externals using the given pluginRoot', () => {
const pluginRoot = mkdtempSync(join(tmpdir(), 'react-detect-plugin-test-'));
mkdirSync(join(pluginRoot, '.config', 'bundler'), { recursive: true });
writeFileSync(join(pluginRoot, '.config', 'bundler', 'externals.ts'), `const externals = ['react/jsx-runtime'];`);

expect(hasExternalisedJsxRuntime(pluginRoot)).toBe(true);
});
});
33 changes: 8 additions & 25 deletions packages/react-detect/src/utils/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,9 @@ import fs from 'node:fs';
import { parseFile } from '../parser.js';
import { walk } from './ast.js';

interface PluginJson {
id: string;
type: string;
info: {
version: string;
};
name: string;
}

let cachedPluginJson: PluginJson | null = null;

export function getPluginJson(dir?: string) {
if (cachedPluginJson) {
return cachedPluginJson;
}

const srcPath = dir ? path.join(dir, 'dist') : path.join(process.cwd(), 'dist');
const pluginJsonPath = path.join(srcPath, 'plugin.json');
cachedPluginJson = readJsonFile(pluginJsonPath);
return cachedPluginJson;
export function getPluginJson(distDir: string) {
const pluginJsonPath = path.join(distDir, 'plugin.json');
return readJsonFile(pluginJsonPath);
}

function isFile(path: string) {
Expand All @@ -49,13 +32,13 @@ export function readJsonFile(filename: string) {
}
}

export function hasExternalisedJsxRuntime(): boolean {
export function hasExternalisedJsxRuntime(pluginRoot: string): boolean {
const webpackConfigPathsToCheck = ['webpack.config.ts', '.config/webpack/webpack.config.ts'];
const bundlerExternalPathsToCheck = ['.config/bundler/externals.ts'];
let found = false;
for (const webpackConfigPath of webpackConfigPathsToCheck) {
if (isFile(path.join(process.cwd(), webpackConfigPath))) {
const webpackConfig = fs.readFileSync(path.join(process.cwd(), webpackConfigPath)).toString();
if (isFile(path.join(pluginRoot, webpackConfigPath))) {
const webpackConfig = fs.readFileSync(path.join(pluginRoot, webpackConfigPath)).toString();
const webpackConfigAst = parseFile(webpackConfig, webpackConfigPath);

walk(webpackConfigAst, (node) => {
Expand All @@ -81,8 +64,8 @@ export function hasExternalisedJsxRuntime(): boolean {
}
}
for (const bundlerExternalPath of bundlerExternalPathsToCheck) {
if (isFile(path.join(process.cwd(), bundlerExternalPath))) {
const bundlerExternals = fs.readFileSync(path.join(process.cwd(), bundlerExternalPath)).toString();
if (isFile(path.join(pluginRoot, bundlerExternalPath))) {
const bundlerExternals = fs.readFileSync(path.join(pluginRoot, bundlerExternalPath)).toString();
const bundlerExternalsAst = parseFile(bundlerExternals, bundlerExternalPath);

walk(bundlerExternalsAst, (node) => {
Expand Down
Loading