129 lines
4.3 KiB
TypeScript
129 lines
4.3 KiB
TypeScript
#!/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();
|