mirror of
https://github.com/nexu-io/open-design.git
synced 2026-07-06 14:01:52 +08:00
* refactor: move daemon routes into routes dir * fix: restore components typecheck dependencies * fix: update host tools route test import Generated-By: looper 0.9.4 (runner=fixer, agent=codex) * fix: restore review follow-up patches * fix: tighten daemon route typing --------- Co-authored-by: Looper <looper@noreply.github.com>
481 lines
16 KiB
TypeScript
481 lines
16 KiB
TypeScript
import { execFile } from "node:child_process";
|
|
import { readFile, stat } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { promisify } from "node:util";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
const repoRoot = path.resolve(import.meta.dirname, "..");
|
|
|
|
const SOURCE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".css", ".scss", ".sass"]);
|
|
const GENERATED_OR_RUNTIME_SEGMENTS = new Set([
|
|
".od",
|
|
".tmp",
|
|
"build",
|
|
"coverage",
|
|
"dist",
|
|
"node_modules",
|
|
"playwright-report",
|
|
]);
|
|
|
|
type SourceKind = "ts" | "js" | "style" | "test";
|
|
|
|
type SourceRecord = {
|
|
repositoryPath: string;
|
|
extension: string;
|
|
lines: number;
|
|
bytes: number;
|
|
kind: SourceKind;
|
|
scope: string;
|
|
resourceLike: boolean;
|
|
};
|
|
|
|
type Bucket = {
|
|
label: string;
|
|
min: number;
|
|
maxExclusive: number | null;
|
|
};
|
|
|
|
type BucketStats = {
|
|
bucket: Bucket;
|
|
files: number;
|
|
lines: number;
|
|
bytes: number;
|
|
nonTestFiles: number;
|
|
testFiles: number;
|
|
};
|
|
|
|
type GroupStats = {
|
|
key: string;
|
|
files: number;
|
|
lines: number;
|
|
bytes: number;
|
|
};
|
|
|
|
type RecommendationAction = "split-now" | "survey-first" | "defer-resource";
|
|
|
|
type Recommendation = {
|
|
action: RecommendationAction;
|
|
reason: string;
|
|
record: SourceRecord;
|
|
};
|
|
|
|
type OutputFormat = "text" | "markdown" | "json";
|
|
|
|
type Options = {
|
|
binSize: number;
|
|
format: OutputFormat;
|
|
top: number;
|
|
largeThreshold: number;
|
|
};
|
|
|
|
const defaultOptions: Options = {
|
|
binSize: 5_000,
|
|
format: "text",
|
|
top: 40,
|
|
largeThreshold: 5_000,
|
|
};
|
|
|
|
function usage(): string {
|
|
return [
|
|
"Usage: tsx scripts/source-check.ts [--format text|markdown|json] [--bin-size 5000] [--top 40] [--large-threshold 5000]",
|
|
"",
|
|
"Reports tracked code/style source file sizes. Docs, JSON/YAML data, HTML templates, and runtime output are excluded.",
|
|
].join("\n");
|
|
}
|
|
|
|
function parsePositiveInteger(raw: string, flag: string): number {
|
|
const value = Number(raw);
|
|
if (!Number.isInteger(value) || value <= 0) {
|
|
throw new Error(`${flag} must be a positive integer, got "${raw}".`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function parseArgs(argv: string[]): Options {
|
|
const options = { ...defaultOptions };
|
|
for (let i = 0; i < argv.length; i += 1) {
|
|
const arg = argv[i];
|
|
if (arg === "--help" || arg === "-h") {
|
|
console.log(usage());
|
|
process.exit(0);
|
|
}
|
|
if (arg === "--format") {
|
|
const value = argv[i + 1];
|
|
if (value !== "text" && value !== "markdown" && value !== "json") {
|
|
throw new Error("--format must be one of: text, markdown, json.");
|
|
}
|
|
options.format = value;
|
|
i += 1;
|
|
continue;
|
|
}
|
|
if (arg === "--bin-size") {
|
|
const value = argv[i + 1];
|
|
if (value === undefined) throw new Error("--bin-size requires a value.");
|
|
options.binSize = parsePositiveInteger(value, "--bin-size");
|
|
i += 1;
|
|
continue;
|
|
}
|
|
if (arg === "--top") {
|
|
const value = argv[i + 1];
|
|
if (value === undefined) throw new Error("--top requires a value.");
|
|
options.top = parsePositiveInteger(value, "--top");
|
|
i += 1;
|
|
continue;
|
|
}
|
|
if (arg === "--large-threshold") {
|
|
const value = argv[i + 1];
|
|
if (value === undefined) throw new Error("--large-threshold requires a value.");
|
|
options.largeThreshold = parsePositiveInteger(value, "--large-threshold");
|
|
i += 1;
|
|
continue;
|
|
}
|
|
throw new Error(`Unknown argument: ${arg}`);
|
|
}
|
|
return options;
|
|
}
|
|
|
|
function toRepositoryPath(filePath: string): string {
|
|
return path.relative(repoRoot, filePath).split(path.sep).join("/");
|
|
}
|
|
|
|
function hasSkippedSegment(repositoryPath: string): boolean {
|
|
return repositoryPath.split("/").some((segment) => GENERATED_OR_RUNTIME_SEGMENTS.has(segment));
|
|
}
|
|
|
|
function isTestSource(repositoryPath: string): boolean {
|
|
return (
|
|
/(^|\/)(tests|__tests__|e2e|test)(\/|$)/.test(repositoryPath)
|
|
|| /\.(test|spec)\.[cm]?[jt]sx?$/.test(repositoryPath)
|
|
);
|
|
}
|
|
|
|
function isResourceLike(repositoryPath: string): boolean {
|
|
return (
|
|
/\/i18n\/(locales|types)\.ts$/.test(repositoryPath)
|
|
|| /apps\/landing-page\/app\/(i18n|home-page-i18n|info-page-i18n)\.ts$/.test(repositoryPath)
|
|
|| /apps\/landing-page\/app\/_lib\/[\w-]*i18n\.ts$/.test(repositoryPath)
|
|
|| /remixicon\.css$/.test(repositoryPath)
|
|
);
|
|
}
|
|
|
|
function sourceKind(repositoryPath: string, extension: string): SourceKind {
|
|
if (isTestSource(repositoryPath)) return "test";
|
|
if (extension === ".css" || extension === ".scss" || extension === ".sass") return "style";
|
|
if (extension === ".js" || extension === ".jsx" || extension === ".mjs" || extension === ".cjs") return "js";
|
|
return "ts";
|
|
}
|
|
|
|
function sourceScope(repositoryPath: string): string {
|
|
const parts = repositoryPath.split("/");
|
|
const first = parts[0] ?? "root";
|
|
const second = parts[1];
|
|
return second === undefined ? first : `${first}/${second}`;
|
|
}
|
|
|
|
function lineCount(source: string): number {
|
|
if (source.length === 0) return 0;
|
|
return source.split(/\r?\n/).length;
|
|
}
|
|
|
|
async function gitTrackedFiles(): Promise<string[]> {
|
|
const { stdout } = await execFileAsync("git", ["ls-files"], {
|
|
cwd: repoRoot,
|
|
encoding: "utf8",
|
|
maxBuffer: 32 * 1024 * 1024,
|
|
});
|
|
return String(stdout).split("\n").filter(Boolean);
|
|
}
|
|
|
|
async function collectSourceRecords(): Promise<SourceRecord[]> {
|
|
const files = await gitTrackedFiles();
|
|
const records: SourceRecord[] = [];
|
|
for (const repositoryPath of files) {
|
|
if (hasSkippedSegment(repositoryPath)) continue;
|
|
const extension = path.extname(repositoryPath);
|
|
if (!SOURCE_EXTENSIONS.has(extension)) continue;
|
|
const absolutePath = path.join(repoRoot, repositoryPath);
|
|
const [source, fileStat] = await Promise.all([readFile(absolutePath, "utf8"), stat(absolutePath)]);
|
|
records.push({
|
|
repositoryPath,
|
|
extension,
|
|
lines: lineCount(source),
|
|
bytes: fileStat.size,
|
|
kind: sourceKind(repositoryPath, extension),
|
|
scope: sourceScope(repositoryPath),
|
|
resourceLike: isResourceLike(repositoryPath),
|
|
});
|
|
}
|
|
return records.sort((a, b) => a.repositoryPath.localeCompare(b.repositoryPath));
|
|
}
|
|
|
|
function bucketFor(lines: number, binSize: number): Bucket {
|
|
const min = Math.floor(lines / binSize) * binSize;
|
|
const maxExclusive = min + binSize;
|
|
if (min === 0) {
|
|
return {
|
|
label: `<${binSize}`,
|
|
min,
|
|
maxExclusive,
|
|
};
|
|
}
|
|
return {
|
|
label: `${min}-${maxExclusive - 1}`,
|
|
min,
|
|
maxExclusive,
|
|
};
|
|
}
|
|
|
|
function bucketKey(bucket: Bucket): string {
|
|
return `${bucket.min}:${bucket.maxExclusive ?? "up"}`;
|
|
}
|
|
|
|
function bucketStats(records: SourceRecord[], binSize: number): BucketStats[] {
|
|
const byBucket = new Map<string, BucketStats>();
|
|
for (const record of records) {
|
|
const bucket = bucketFor(record.lines, binSize);
|
|
const key = bucketKey(bucket);
|
|
const stats = byBucket.get(key) ?? {
|
|
bucket,
|
|
files: 0,
|
|
lines: 0,
|
|
bytes: 0,
|
|
nonTestFiles: 0,
|
|
testFiles: 0,
|
|
};
|
|
stats.files += 1;
|
|
stats.lines += record.lines;
|
|
stats.bytes += record.bytes;
|
|
if (record.kind === "test") stats.testFiles += 1;
|
|
else stats.nonTestFiles += 1;
|
|
byBucket.set(key, stats);
|
|
}
|
|
return [...byBucket.values()].sort((a, b) => a.bucket.min - b.bucket.min);
|
|
}
|
|
|
|
function groupStats(records: SourceRecord[], getKey: (record: SourceRecord) => string): GroupStats[] {
|
|
const groups = new Map<string, GroupStats>();
|
|
for (const record of records) {
|
|
const key = getKey(record);
|
|
const stats = groups.get(key) ?? { key, files: 0, lines: 0, bytes: 0 };
|
|
stats.files += 1;
|
|
stats.lines += record.lines;
|
|
stats.bytes += record.bytes;
|
|
groups.set(key, stats);
|
|
}
|
|
return [...groups.values()].sort((a, b) => b.lines - a.lines || b.files - a.files || a.key.localeCompare(b.key));
|
|
}
|
|
|
|
function recommendedAction(record: SourceRecord): Recommendation {
|
|
if (record.resourceLike) {
|
|
return {
|
|
action: "defer-resource",
|
|
reason: "size appears driven by locale data, icon CSS, or another mechanical resource surface",
|
|
record,
|
|
};
|
|
}
|
|
if (/^apps\/web\/src\/components\/[^/]+\.tsx$/.test(record.repositoryPath)) {
|
|
return {
|
|
action: "split-now",
|
|
reason: "large React component with likely extractable panels, hooks, or providers and nearby component tests",
|
|
record,
|
|
};
|
|
}
|
|
if (record.repositoryPath === "apps/daemon/src/cli.ts") {
|
|
return {
|
|
action: "split-now",
|
|
reason: "CLI subcommand families usually split cleanly while preserving the shared command map",
|
|
record,
|
|
};
|
|
}
|
|
return {
|
|
action: "survey-first",
|
|
reason: "shared runtime boundary; read ownership, routes, and validation coverage before editing",
|
|
record,
|
|
};
|
|
}
|
|
|
|
function recommendations(records: SourceRecord[], threshold: number): Recommendation[] {
|
|
return records
|
|
.filter((record) => record.kind !== "test" && record.lines >= threshold)
|
|
.map((record) => recommendedAction(record))
|
|
.sort((a, b) => {
|
|
const priority: Record<RecommendationAction, number> = {
|
|
"split-now": 0,
|
|
"survey-first": 1,
|
|
"defer-resource": 2,
|
|
};
|
|
return priority[a.action] - priority[b.action] || b.record.lines - a.record.lines;
|
|
});
|
|
}
|
|
|
|
function number(value: number): string {
|
|
return value.toLocaleString("en-US");
|
|
}
|
|
|
|
function formatText(records: SourceRecord[], options: Options): string {
|
|
const totalLines = records.reduce((sum, record) => sum + record.lines, 0);
|
|
const totalBytes = records.reduce((sum, record) => sum + record.bytes, 0);
|
|
const largeRecords = records.filter((record) => record.lines >= options.largeThreshold);
|
|
const largeNonTest = largeRecords.filter((record) => record.kind !== "test");
|
|
const candidateRecords = largeNonTest.filter((record) => !record.resourceLike);
|
|
const recommended = recommendations(records, options.largeThreshold);
|
|
const lines: string[] = [
|
|
`Source check (${options.binSize} LOC buckets)`,
|
|
`Files: ${number(records.length)} Lines: ${number(totalLines)} Bytes: ${number(totalBytes)}`,
|
|
`Large threshold: >=${number(options.largeThreshold)} LOC (${number(largeRecords.length)} files, ${number(largeNonTest.length)} non-test)`,
|
|
"",
|
|
"Buckets:",
|
|
];
|
|
for (const stats of bucketStats(records, options.binSize)) {
|
|
lines.push(
|
|
` ${stats.bucket.label.padEnd(12)} files=${String(stats.files).padStart(5)} lines=${number(stats.lines).padStart(9)} non-test=${String(stats.nonTestFiles).padStart(4)} test=${String(stats.testFiles).padStart(4)}`,
|
|
);
|
|
}
|
|
lines.push("", "Large non-test files by scope:");
|
|
for (const stats of groupStats(largeNonTest, (record) => record.scope)) {
|
|
lines.push(` ${stats.key.padEnd(32)} files=${String(stats.files).padStart(3)} lines=${number(stats.lines).padStart(9)}`);
|
|
}
|
|
lines.push("", `Top ${options.top} raw files:`);
|
|
for (const record of [...records].sort((a, b) => b.lines - a.lines).slice(0, options.top)) {
|
|
const resource = record.resourceLike ? " resource-like" : "";
|
|
lines.push(` ${String(record.lines).padStart(6)} ${record.kind.padEnd(5)}${resource.padEnd(14)} ${record.repositoryPath}`);
|
|
}
|
|
lines.push("", `Top ${Math.min(options.top, candidateRecords.length)} decomposition candidates (non-test, non-resource-like, >=${number(options.largeThreshold)} LOC):`);
|
|
for (const record of [...candidateRecords].sort((a, b) => b.lines - a.lines).slice(0, options.top)) {
|
|
lines.push(` ${String(record.lines).padStart(6)} ${record.scope.padEnd(24)} ${record.repositoryPath}`);
|
|
}
|
|
lines.push("", "Recommended actions:");
|
|
for (const item of recommended.slice(0, options.top)) {
|
|
lines.push(
|
|
` ${item.action.padEnd(15)} ${String(item.record.lines).padStart(6)} ${item.record.repositoryPath} - ${item.reason}`,
|
|
);
|
|
}
|
|
lines.push(
|
|
"",
|
|
"Helper: start with split-now items that have nearby tests. Treat resource-like files as data organization work, not source decomposition. For survey-first items, read the owning AGENTS.md and map route/CLI/UI boundaries before moving code.",
|
|
);
|
|
return lines.join("\n");
|
|
}
|
|
|
|
function markdownTable(headers: string[], rows: string[][]): string {
|
|
return [
|
|
`| ${headers.join(" | ")} |`,
|
|
`| ${headers.map(() => "---").join(" | ")} |`,
|
|
...rows.map((row) => `| ${row.join(" | ")} |`),
|
|
].join("\n");
|
|
}
|
|
|
|
function formatMarkdown(records: SourceRecord[], options: Options): string {
|
|
const totalLines = records.reduce((sum, record) => sum + record.lines, 0);
|
|
const totalBytes = records.reduce((sum, record) => sum + record.bytes, 0);
|
|
const largeRecords = records.filter((record) => record.lines >= options.largeThreshold);
|
|
const largeNonTest = largeRecords.filter((record) => record.kind !== "test");
|
|
const candidateRecords = largeNonTest.filter((record) => !record.resourceLike);
|
|
const recommended = recommendations(records, options.largeThreshold);
|
|
return [
|
|
`# Source Check`,
|
|
"",
|
|
`- Bucket size: ${number(options.binSize)} LOC`,
|
|
`- Files: ${number(records.length)}`,
|
|
`- Lines: ${number(totalLines)}`,
|
|
`- Bytes: ${number(totalBytes)}`,
|
|
`- Large threshold: >=${number(options.largeThreshold)} LOC (${number(largeRecords.length)} files, ${number(largeNonTest.length)} non-test)`,
|
|
"",
|
|
"## Buckets",
|
|
"",
|
|
markdownTable(
|
|
["Bucket", "Files", "Lines", "Non-test", "Test"],
|
|
bucketStats(records, options.binSize).map((stats) => [
|
|
stats.bucket.label,
|
|
number(stats.files),
|
|
number(stats.lines),
|
|
number(stats.nonTestFiles),
|
|
number(stats.testFiles),
|
|
]),
|
|
),
|
|
"",
|
|
"## Large Non-test Files By Scope",
|
|
"",
|
|
markdownTable(
|
|
["Scope", "Files", "Lines"],
|
|
groupStats(largeNonTest, (record) => record.scope).map((stats) => [stats.key, number(stats.files), number(stats.lines)]),
|
|
),
|
|
"",
|
|
`## Top ${options.top} Raw Files`,
|
|
"",
|
|
markdownTable(
|
|
["LOC", "Kind", "File"],
|
|
[...records].sort((a, b) => b.lines - a.lines).slice(0, options.top).map((record) => [
|
|
number(record.lines),
|
|
`${record.kind}${record.resourceLike ? " / resource-like" : ""}`,
|
|
`\`${record.repositoryPath}\``,
|
|
]),
|
|
),
|
|
"",
|
|
`## Decomposition Candidates`,
|
|
"",
|
|
markdownTable(
|
|
["LOC", "Scope", "File"],
|
|
[...candidateRecords].sort((a, b) => b.lines - a.lines).slice(0, options.top).map((record) => [
|
|
number(record.lines),
|
|
record.scope,
|
|
`\`${record.repositoryPath}\``,
|
|
]),
|
|
),
|
|
"",
|
|
"## Recommended Actions",
|
|
"",
|
|
markdownTable(
|
|
["Action", "LOC", "File", "Reason"],
|
|
recommended.slice(0, options.top).map((item) => [
|
|
`\`${item.action}\``,
|
|
number(item.record.lines),
|
|
`\`${item.record.repositoryPath}\``,
|
|
item.reason,
|
|
]),
|
|
),
|
|
"",
|
|
"Helper: start with `split-now` items that have nearby tests. Treat resource-like files as data organization work, not source decomposition. For `survey-first` items, read the owning `AGENTS.md` and map route/CLI/UI boundaries before moving code.",
|
|
].join("\n");
|
|
}
|
|
|
|
function formatJson(records: SourceRecord[], options: Options): string {
|
|
const largeRecords = records.filter((record) => record.lines >= options.largeThreshold);
|
|
const largeNonTest = largeRecords.filter((record) => record.kind !== "test");
|
|
return JSON.stringify(
|
|
{
|
|
options,
|
|
summary: {
|
|
files: records.length,
|
|
lines: records.reduce((sum, record) => sum + record.lines, 0),
|
|
bytes: records.reduce((sum, record) => sum + record.bytes, 0),
|
|
largeFiles: largeRecords.length,
|
|
largeNonTestFiles: largeNonTest.length,
|
|
},
|
|
buckets: bucketStats(records, options.binSize),
|
|
largeNonTestByScope: groupStats(largeNonTest, (record) => record.scope),
|
|
recommendations: recommendations(records, options.largeThreshold),
|
|
top: [...records].sort((a, b) => b.lines - a.lines).slice(0, options.top),
|
|
},
|
|
null,
|
|
2,
|
|
);
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
const options = parseArgs(process.argv.slice(2));
|
|
const records = await collectSourceRecords();
|
|
if (options.format === "json") {
|
|
console.log(formatJson(records, options));
|
|
} else if (options.format === "markdown") {
|
|
console.log(formatMarkdown(records, options));
|
|
} else {
|
|
console.log(formatText(records, options));
|
|
}
|
|
}
|
|
|
|
main().catch((error: unknown) => {
|
|
console.error(error instanceof Error ? error.message : error);
|
|
process.exitCode = 1;
|
|
});
|