184 lines
6.8 KiB
TypeScript
184 lines
6.8 KiB
TypeScript
/**
|
|
* Orphan component detection
|
|
*
|
|
* Every file under resources/js/components/ must be imported (directly or via
|
|
* a barrel re-export) somewhere in the codebase. If a component is never
|
|
* referenced it is dead code and this test will fail.
|
|
*
|
|
* How it works
|
|
* ------------
|
|
* 1. Collect all component files (the candidates).
|
|
* 2. Collect all source files that may consume components (pages, layouts,
|
|
* hooks, contexts, lib, app entry points, and other components that import
|
|
* siblings).
|
|
* 3. For each candidate, check every consumer file for a reference using:
|
|
* a) The @/ alias path (e.g. "@/components/foo/bar")
|
|
* b) The relative path from that consumer's directory to the component
|
|
* (e.g. "./bar", "../foo/bar", "./import-balances/bar")
|
|
* c) The barrel directory path if an index.* exists in the component's
|
|
* top-level subdirectory (e.g. "@/components/charts")
|
|
* 4. Fail if no consumer references the component.
|
|
*
|
|
* Exclusions
|
|
* ----------
|
|
* - index.ts / index.tsx barrel files are skipped as standalone candidates.
|
|
* - *.test.ts / *.test.tsx files are excluded from both candidates and
|
|
* consumers so test imports don't hide real orphans.
|
|
*/
|
|
|
|
import { readFileSync, readdirSync, statSync } from 'fs';
|
|
import { dirname, extname, join, relative, resolve } from 'path';
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// __dirname is resources/js/lib — go up one level to reach resources/js
|
|
const jsRoot = resolve(__dirname, '..');
|
|
const componentsRoot = join(jsRoot, 'components');
|
|
|
|
/** Recursively collect all files matching the given extensions. */
|
|
function collectFiles(dir: string, extensions: string[]): string[] {
|
|
const results: string[] = [];
|
|
|
|
for (const entry of readdirSync(dir)) {
|
|
const fullPath = join(dir, entry);
|
|
const stat = statSync(fullPath);
|
|
|
|
if (stat.isDirectory()) {
|
|
results.push(...collectFiles(fullPath, extensions));
|
|
} else if (extensions.includes(extname(entry))) {
|
|
results.push(fullPath);
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/** Return the `@/...` alias import path (no extension) for an absolute path. */
|
|
function toAliasPath(absolutePath: string): string {
|
|
const rel = relative(jsRoot, absolutePath);
|
|
return '@/' + rel.replace(/\.(tsx?|jsx?)$/, '');
|
|
}
|
|
|
|
/**
|
|
* Return all import strings that would constitute a valid reference to
|
|
* `componentFile` from within `consumerFile`.
|
|
*/
|
|
function importStrings(componentFile: string, consumerFile: string): string[] {
|
|
const aliasPath = toAliasPath(componentFile); // "@/components/foo/bar"
|
|
const consumerDir = dirname(consumerFile);
|
|
|
|
// Relative path from consumer's directory to component (no extension)
|
|
const rel = relative(consumerDir, componentFile).replace(
|
|
/\.(tsx?|jsx?)$/,
|
|
'',
|
|
);
|
|
// Ensure it starts with ./ or ../
|
|
const relPath = rel.startsWith('.') ? rel : './' + rel;
|
|
|
|
const variants = (p: string) => [
|
|
`'${p}'`,
|
|
`"${p}"`,
|
|
`'${p}.ts'`,
|
|
`"${p}.ts"`,
|
|
`'${p}.tsx'`,
|
|
`"${p}.tsx"`,
|
|
];
|
|
|
|
return [...variants(aliasPath), ...variants(relPath)];
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Build the candidate list (component files, excluding barrel index files)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const allComponentFiles = collectFiles(componentsRoot, ['.ts', '.tsx']).filter(
|
|
(f) => !f.endsWith('.test.ts') && !f.endsWith('.test.tsx'),
|
|
);
|
|
|
|
const candidateFiles = allComponentFiles.filter((f) => {
|
|
const base = f.split('/').pop()!;
|
|
return base !== 'index.ts' && base !== 'index.tsx';
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Build consumer list — everything in resources/js except test files
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const allSourceFiles = collectFiles(jsRoot, ['.ts', '.tsx']).filter(
|
|
(f) => !f.endsWith('.test.ts') && !f.endsWith('.test.tsx'),
|
|
);
|
|
|
|
// Pre-read all consumer file contents paired with their path
|
|
const consumers: { path: string; content: string }[] = allSourceFiles.map(
|
|
(f) => ({ path: f, content: readFileSync(f, 'utf-8') }),
|
|
);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Pre-compute barrel directories so we can accept directory-level imports
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const barrelDirs = new Set<string>();
|
|
for (const f of allComponentFiles) {
|
|
const base = f.split('/').pop()!;
|
|
if (base === 'index.ts' || base === 'index.tsx') {
|
|
barrelDirs.add(dirname(f));
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Orphan component detection', () => {
|
|
it('every component must be imported somewhere in the codebase', () => {
|
|
const orphans: string[] = [];
|
|
|
|
for (const componentFile of candidateFiles) {
|
|
const componentDir = dirname(componentFile);
|
|
|
|
const isUsed = consumers.some(({ path: consumerPath, content }) => {
|
|
// Check alias + relative import patterns for this consumer
|
|
const patterns = importStrings(componentFile, consumerPath);
|
|
|
|
// If the component sits inside a barrel directory, also accept
|
|
// an import of that directory (alias or relative)
|
|
if (barrelDirs.has(componentDir)) {
|
|
const barrelAlias = toAliasPath(
|
|
join(componentDir, 'index'),
|
|
).replace(/\/index$/, '');
|
|
const relBarrel = relative(
|
|
dirname(consumerPath),
|
|
componentDir,
|
|
);
|
|
const relBarrelPath = relBarrel.startsWith('.')
|
|
? relBarrel
|
|
: './' + relBarrel;
|
|
|
|
patterns.push(
|
|
`'${barrelAlias}'`,
|
|
`"${barrelAlias}"`,
|
|
`'${relBarrelPath}'`,
|
|
`"${relBarrelPath}"`,
|
|
);
|
|
}
|
|
|
|
return patterns.some((p) => content.includes(p));
|
|
});
|
|
|
|
if (!isUsed) {
|
|
orphans.push(toAliasPath(componentFile));
|
|
}
|
|
}
|
|
|
|
if (orphans.length > 0) {
|
|
const list = orphans.map((o) => ` - ${o}`).join('\n');
|
|
expect.fail(
|
|
`Found ${orphans.length} orphan component(s) that are never imported:\n\n${list}\n\nRemove unused components or add them to the codebase.`,
|
|
);
|
|
}
|
|
});
|
|
});
|