Initial Commit
This commit is contained in:
128
scripts/lint/check-feature-layout.ts
Normal file
128
scripts/lint/check-feature-layout.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Fails when an implementation+test pair lives flat at a package's `src/`
|
||||
* root instead of inside a feature folder.
|
||||
*
|
||||
* CLAUDE.md §"Package Structure" mandates the 5-files-per-feature layout:
|
||||
*
|
||||
* src/<feature>/
|
||||
* index.ts
|
||||
* <feature>.ts
|
||||
* <feature>.test.ts
|
||||
* <feature>.types.ts
|
||||
* <feature>.md
|
||||
*
|
||||
* The only flat exceptions allowed at `src/` root are:
|
||||
* - `index.ts` (package barrel)
|
||||
* - `types/`, `utils/`, `constants/` (per the documented exemption list)
|
||||
*
|
||||
* Any other `<name>.ts` paired with `<name>.test.ts` directly under `src/`
|
||||
* (without a `<name>/` folder) is a layout violation and fails CI.
|
||||
*
|
||||
* Exit codes: 0 = clean, 1 = at least one violation.
|
||||
*/
|
||||
|
||||
import { readdirSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const PACKAGES_DIR = "packages";
|
||||
const ROOT_FLAT_EXEMPT = new Set(["index.ts"]);
|
||||
const ROOT_DIR_EXEMPT = new Set(["types", "utils", "constants"]);
|
||||
|
||||
interface Violation {
|
||||
readonly pkg: string;
|
||||
readonly src: string;
|
||||
readonly feature: string;
|
||||
readonly files: readonly string[];
|
||||
}
|
||||
|
||||
function listPackages(): readonly string[] {
|
||||
const out: string[] = [];
|
||||
for (const cat of readdirSync(PACKAGES_DIR)) {
|
||||
const catDir = join(PACKAGES_DIR, cat);
|
||||
if (!statSync(catDir).isDirectory()) continue;
|
||||
for (const plugin of readdirSync(catDir)) {
|
||||
const pluginDir = join(catDir, plugin);
|
||||
if (!statSync(pluginDir).isDirectory()) continue;
|
||||
for (const pkg of readdirSync(pluginDir)) {
|
||||
const pkgDir = join(pluginDir, pkg);
|
||||
if (!statSync(pkgDir).isDirectory()) continue;
|
||||
const srcDir = join(pkgDir, "src");
|
||||
try {
|
||||
if (statSync(srcDir).isDirectory()) out.push(pkgDir);
|
||||
} catch {
|
||||
// no src/ — skip
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function checkPackage(pkgDir: string): Violation[] {
|
||||
const srcDir = join(pkgDir, "src");
|
||||
const entries = readdirSync(srcDir);
|
||||
const featuresFound = new Map<string, string[]>();
|
||||
|
||||
for (const name of entries) {
|
||||
const full = join(srcDir, name);
|
||||
if (statSync(full).isDirectory()) continue;
|
||||
if (ROOT_FLAT_EXEMPT.has(name)) continue;
|
||||
const match = name.match(/^(.+?)(\.test|\.types|\.md|)\.(ts|tsx|md)$/);
|
||||
if (!match) continue;
|
||||
const feature = match[1] as string;
|
||||
const list = featuresFound.get(feature) ?? [];
|
||||
list.push(name);
|
||||
featuresFound.set(feature, list);
|
||||
}
|
||||
|
||||
const violations: Violation[] = [];
|
||||
for (const [feature, files] of featuresFound) {
|
||||
const hasImpl = files.some((f) => f === `${feature}.ts`);
|
||||
const hasTest = files.some((f) => f === `${feature}.test.ts`);
|
||||
if (hasImpl && hasTest) {
|
||||
violations.push({
|
||||
pkg: pkgDir,
|
||||
src: srcDir,
|
||||
feature,
|
||||
files,
|
||||
});
|
||||
}
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const pkgs = listPackages();
|
||||
const allViolations: Violation[] = [];
|
||||
for (const pkg of pkgs) {
|
||||
allViolations.push(...checkPackage(pkg));
|
||||
}
|
||||
if (allViolations.length === 0) {
|
||||
console.log(
|
||||
`OK ${pkgs.length} packages: every <feature>.ts + <feature>.test.ts pair lives in a feature folder.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.error(
|
||||
`Found ${allViolations.length} layout violation${allViolations.length === 1 ? "" : "s"}:\n`
|
||||
);
|
||||
for (const v of allViolations) {
|
||||
console.error(`FAIL ${v.pkg}`);
|
||||
console.error(
|
||||
` feature \`${v.feature}\` is flat at src/ root; should live in src/${v.feature}/`
|
||||
);
|
||||
for (const f of v.files) {
|
||||
console.error(` - src/${f}`);
|
||||
}
|
||||
}
|
||||
console.error(
|
||||
`\nCLAUDE.md §"Package Structure" mandates 5-files-per-feature layout. Only ${[...ROOT_DIR_EXEMPT].map((d) => `\`${d}/\``).join(", ")} dirs and \`index.ts\` are exempt at src/ root.`
|
||||
);
|
||||
console.error(
|
||||
"Move each violation into a folder: mkdir src/<feature>/ && git mv src/<feature>.{ts,test.ts,md} src/<feature>/, then add a src/<feature>/index.ts barrel and update sibling imports."
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user