109 lines
4.5 KiB
TypeScript
109 lines
4.5 KiB
TypeScript
/**
|
|||
|
|
* Best-practice engine — embedded skills, Conventional Commits validation,
|
||
|
|
* and lightweight architecture/quality scans. Mirrors `crate::best_practice`.
|
||
|
|
*/
|
||
|
|
import * as fs from "node:fs";
|
||
|
|
import * as path from "node:path";
|
||
|
|
|
||
|
|
interface Skill {
|
||
|
|
name: string;
|
||
|
|
description: string;
|
||
|
|
content: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
const SKILLS: Skill[] = [
|
||
|
|
{
|
||
|
|
name: "clean-code",
|
||
|
|
description: "Clean Code principles and naming conventions",
|
||
|
|
content:
|
||
|
|
"Clean Code:\n- Use meaningful, intention-revealing names.\n- Functions should do one thing and be short.\n- Avoid duplication (DRY).\n- Comments should explain 'why', not 'what'.\n- Favor composition over deep inheritance.",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: "commit-convention",
|
||
|
|
description: "Conventional Commits (Bahasa Indonesia) rules",
|
||
|
|
content:
|
||
|
|
"Conventional Commits:\n- Format: <type>(<scope>): <subject>\n- Types: feat, fix, chore, docs, refactor, test, style, perf, ci, build, revert\n- Subject: imperative, lowercase, no trailing period.\n- Example: 'feat(auth): tambah login PKCE'",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: "layering",
|
||
|
|
description: "Clean Architecture layering rules (domain → application → infrastructure → interfaces)",
|
||
|
|
content:
|
||
|
|
"Clean Architecture:\n- domain: pure types, zero I/O.\n- application: ports + use cases.\n- infrastructure: concrete adapters.\n- interfaces: presentation.\n- Dependencies point inward; domain depends on nothing.",
|
||
|
|
},
|
||
|
|
];
|
||
|
|
|
||
|
|
const COMMIT_TYPES = ["feat", "fix", "chore", "docs", "refactor", "test", "style", "perf", "ci", "build", "revert"];
|
||
|
|
|
||
|
|
export class BestPracticeEngine {
|
||
|
|
listSkills(): string[] {
|
||
|
|
return SKILLS.map((s) => s.name);
|
||
|
|
}
|
||
|
|
skillSummaries(): Array<[string, string]> {
|
||
|
|
return SKILLS.map((s) => [s.name, s.description] as [string, string]);
|
||
|
|
}
|
||
|
|
getSkill(name: string): string | undefined {
|
||
|
|
return SKILLS.find((s) => s.name === name)?.content;
|
||
|
|
}
|
||
|
|
|
||
|
|
parseCommit(msg: string): { type_: string; scope: string | null; breaking: boolean; description: string } | null {
|
||
|
|
const m = /^([a-z]+)(?:\(([^)]+)\))?(!)?:\s*(.+)$/.exec(msg.trim().split("\n")[0] ?? "");
|
||
|
|
if (!m) return null;
|
||
|
|
return { type_: m[1]!, scope: m[2] ?? null, breaking: m[3] === "!", description: (m[4] ?? "").trim() };
|
||
|
|
}
|
||
|
|
|
||
|
|
validateCommit(msg: string): string[] {
|
||
|
|
const errors: string[] = [];
|
||
|
|
const first = (msg.trim().split("\n")[0] ?? "").trim();
|
||
|
|
if (first === "") { errors.push("Commit message is empty."); return errors; }
|
||
|
|
const parsed = this.parseCommit(first);
|
||
|
|
if (!parsed) {
|
||
|
|
errors.push("Does not match Conventional Commits: <type>(<scope>): <subject>");
|
||
|
|
return errors;
|
||
|
|
}
|
||
|
|
if (!COMMIT_TYPES.includes(parsed.type_)) {
|
||
|
|
errors.push(`Unknown type '${parsed.type_}'. Valid types: ${COMMIT_TYPES.join(", ")}`);
|
||
|
|
}
|
||
|
|
if (parsed.description === "") {
|
||
|
|
errors.push("Subject is empty.");
|
||
|
|
} else if (!/^[a-z]/.test(parsed.description)) {
|
||
|
|
errors.push("Subject should start with a lowercase letter.");
|
||
|
|
}
|
||
|
|
if (parsed.description.endsWith(".")) {
|
||
|
|
errors.push("Subject should not end with a period.");
|
||
|
|
}
|
||
|
|
if (first.length > 72) {
|
||
|
|
errors.push("Subject is longer than 72 characters.");
|
||
|
|
}
|
||
|
|
return errors;
|
||
|
|
}
|
||
|
|
|
||
|
|
suggestCommitTemplate(type_: string, scope: string | null): string {
|
||
|
|
if (!COMMIT_TYPES.includes(type_)) type_ = "feat";
|
||
|
|
return `${type_}${scope ? `(${scope})` : ""}: <imperative description>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
auditLayering(ws: string): { filesScanned: number; violations: Array<{ severity: string; file: string; line: number; message: string }> } {
|
||
|
|
const violations: Array<{ severity: string; file: string; line: number; message: string }> = [];
|
||
|
|
let filesScanned = 0;
|
||
|
|
const scan = (dir: string) => {
|
||
|
|
let entries: fs.Dirent[];
|
||
|
|
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
||
|
|
for (const e of entries) {
|
||
|
|
if (e.name === ".git" || e.name === "node_modules" || e.name === "target") continue;
|
||
|
|
const full = path.join(dir, e.name);
|
||
|
|
if (e.isDirectory()) scan(full);
|
||
|
|
else if (e.name.endsWith(".ts") || e.name.endsWith(".tsx")) {
|
||
|
|
filesScanned++;
|
||
|
|
const lines = fs.readFileSync(full, "utf8").split("\n");
|
||
|
|
lines.forEach((line, i) => {
|
||
|
|
if (line.includes("@zesdex/infrastructure") && full.includes("/domain/")) {
|
||
|
|
violations.push({ severity: "error", file: full, line: i + 1, message: "domain must not depend on infrastructure" });
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
};
|
||
|
|
scan(ws);
|
||
|
|
return { filesScanned, violations };
|
||
|
|
}
|
||
|
|
}
|