diff --git a/packages/shared-skills/skills/programming/scripts/typescript/check-no-excuse-rules.ts b/packages/shared-skills/skills/programming/scripts/typescript/check-no-excuse-rules.ts new file mode 100644 index 000000000..4ad7875f4 --- /dev/null +++ b/packages/shared-skills/skills/programming/scripts/typescript/check-no-excuse-rules.ts @@ -0,0 +1,282 @@ +#!/usr/bin/env bun +/** + * Check TypeScript files for no-excuse violations. + * + * Rules: + * no-any-assertion - `as any` + * no-unknown-assertion - `as unknown` + * no-ts-ignore - `@ts-ignore` comments + * no-ts-expect-error - `@ts-expect-error` comments + * no-enum - `enum` declarations + * no-non-null-assertion - `x!` postfix operator + * no-throw-literal - `throw "string"` / `throw 123` + * no-mutable-export - `export let` / `export var` + * no-any-annotation - `: any` in annotations (opt out: `// no-excuse-ok: any`) + * no-explicit-any-return - `(): any` return types (opt out: `// no-excuse-ok: any`) + * empty-catch - `catch { }` or `catch (e) { }` with empty body + * catch-without-narrowing - catch block that uses error without instanceof narrowing + * + * Usage: + * bun run scripts/check-no-excuse-rules.ts ... + * + * Exit codes: + * 0 - no violations + * 1 - violations found + * 2 - input error + */ + +import fs from "node:fs" +import path from "node:path" +import process from "node:process" +import ts from "typescript" + +type RuleId = + | "no-any-assertion" + | "no-unknown-assertion" + | "no-ts-ignore" + | "no-ts-expect-error" + | "no-enum" + | "no-non-null-assertion" + | "no-throw-literal" + | "no-mutable-export" + | "no-any-annotation" + | "no-explicit-any-return" + | "empty-catch" + | "catch-without-narrowing" + +type Violation = { + readonly ruleId: RuleId + readonly filePath: string + readonly line: number + readonly column: number + readonly message: string +} + +const INCLUDED_EXTENSIONS = new Set([".ts", ".tsx", ".mts", ".cts"]) +const IGNORED_DIRECTORIES = new Set([ + ".git", ".next", ".nuxt", ".turbo", ".yarn", + "coverage", "dist", "build", "node_modules", +]) + +const OPT_OUT_RE = /\/\/\s*no-excuse-ok:\s*any/ +const CATCH_OK_RE = /\/\/\s*no-excuse-ok:\s*catch/ + +function isIncludedFile(filePath: string): boolean { + return INCLUDED_EXTENSIONS.has(path.extname(filePath).toLowerCase()) +} + +function isDeclarationFile(filePath: string): boolean { + return filePath.endsWith(".d.ts") || filePath.endsWith(".d.mts") || filePath.endsWith(".d.cts") +} + +function discoverFiles(inputs: string[]): string[] { + const files: string[] = [] + for (const input of inputs) { + const resolved = path.resolve(input) + if (!fs.existsSync(resolved)) { + console.error(`Path does not exist: ${resolved}`) + process.exit(2) + } + if (fs.statSync(resolved).isFile()) { + if (isIncludedFile(resolved) && !isDeclarationFile(resolved)) files.push(resolved) + continue + } + const walk = (dir: string): void => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (!IGNORED_DIRECTORIES.has(entry.name)) walk(path.join(dir, entry.name)) + } else if (isIncludedFile(entry.name) && !isDeclarationFile(entry.name)) { + files.push(path.join(dir, entry.name)) + } + } + } + walk(resolved) + } + return files +} + +function getLineText(sourceFile: ts.SourceFile, line: number): string { + const lineStarts = sourceFile.getLineStarts() + const start = lineStarts[line] + const end = line + 1 < lineStarts.length ? lineStarts[line + 1] : sourceFile.getEnd() + return sourceFile.text.slice(start, end) +} + +function analyzeFile(filePath: string): Violation[] { + const source = fs.readFileSync(filePath, "utf-8") + const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true) + const violations: Violation[] = [] + + function pos(node: ts.Node): { line: number; column: number } { + const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)) + return { line: line + 1, column: character + 1 } + } + + function lineHasOptOut(node: ts.Node): boolean { + const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)) + return OPT_OUT_RE.test(getLineText(sourceFile, line)) + } + + function visit(node: ts.Node): void { + // ── as any / as unknown ── + if (ts.isAsExpression(node)) { + const typeText = node.type.getText(sourceFile) + if (typeText === "any") { + const p = pos(node) + violations.push({ ruleId: "no-any-assertion", filePath, ...p, message: "`as any` — narrow with type guards or redesign the types" }) + } + if (typeText === "unknown") { + const p = pos(node) + violations.push({ ruleId: "no-unknown-assertion", filePath, ...p, message: "`as unknown` — redesign the types" }) + } + } + + // ── enum ── + if (ts.isEnumDeclaration(node)) { + const p = pos(node) + violations.push({ ruleId: "no-enum", filePath, ...p, message: "`enum` — use `as const` object + literal union type" }) + } + + // ── x! non-null assertion ── + if (ts.isNonNullExpression(node)) { + const p = pos(node) + violations.push({ ruleId: "no-non-null-assertion", filePath, ...p, message: "`x!` — use narrowing or optional chaining" }) + } + + // ── throw "literal" ── + if (ts.isThrowStatement(node) && node.expression) { + const expr = node.expression + if (ts.isStringLiteral(expr) || ts.isNumericLiteral(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) { + const p = pos(node) + violations.push({ ruleId: "no-throw-literal", filePath, ...p, message: "`throw literal` — throw an Error subclass" }) + } + if (ts.isTemplateExpression(expr)) { + const p = pos(node) + violations.push({ ruleId: "no-throw-literal", filePath, ...p, message: "`throw template` — throw an Error subclass" }) + } + } + + // ── export let / export var ── + if (ts.isVariableStatement(node)) { + const hasExport = node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) + if (hasExport) { + const flags = node.declarationList.flags + if (!(flags & ts.NodeFlags.Const)) { + const p = pos(node) + violations.push({ ruleId: "no-mutable-export", filePath, ...p, message: "`export let/var` — use `export const`" }) + } + } + } + + // ── : any in annotations ── + if (ts.isTypeReferenceNode(node) || node.kind === ts.SyntaxKind.AnyKeyword) { + if (node.kind === ts.SyntaxKind.AnyKeyword && !lineHasOptOut(node)) { + const parent = node.parent + // Skip `as any` — already caught by no-any-assertion + if (parent && ts.isAsExpression(parent)) { + // already handled + } else if (parent && ( + ts.isParameter(parent) || + ts.isVariableDeclaration(parent) || + ts.isPropertyDeclaration(parent) || + ts.isPropertySignature(parent) + )) { + const p = pos(node) + violations.push({ ruleId: "no-any-annotation", filePath, ...p, message: "`: any` annotation — use `unknown` and narrow" }) + } else if (parent && ( + ts.isFunctionDeclaration(parent) || + ts.isMethodDeclaration(parent) || + ts.isArrowFunction(parent) || + ts.isFunctionExpression(parent) + )) { + const p = pos(node) + violations.push({ ruleId: "no-explicit-any-return", filePath, ...p, message: "`(): any` return — use a specific type" }) + } + } + } + + // ── empty catch / catch without narrowing ── + if (ts.isCatchClause(node)) { + const catchLine = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + const catchLineText = getLineText(sourceFile, catchLine) + if (!CATCH_OK_RE.test(catchLineText)) { + const body = node.block + const stmts = body.statements + + if (stmts.length === 0) { + // Empty catch — swallows everything silently + const p = pos(node) + violations.push({ ruleId: "empty-catch", filePath, ...p, message: "empty `catch` block — handle, re-throw, or remove the try/catch" }) + } else if (node.variableDeclaration) { + // Has a bound variable — check if it's narrowed with instanceof + const varName = node.variableDeclaration.name.getText(sourceFile) + const blockText = body.getText(sourceFile) + const hasInstanceof = blockText.includes(`instanceof`) + const hasRethrow = blockText.includes(`throw ${varName}`) || blockText.includes(`throw new`) + if (!hasInstanceof && !hasRethrow) { + const p = pos(node) + violations.push({ ruleId: "catch-without-narrowing", filePath, ...p, message: "`catch` without `instanceof` narrowing or re-throw — narrow the error type or re-throw" }) + } + } + } + } + + ts.forEachChild(node, visit) + } + + visit(sourceFile) + + // ── @ts-ignore / @ts-expect-error in comments ── + const commentRanges = [ + ...(ts.getLeadingCommentRanges(source, 0) ?? []), + ] + // Scan all comments via regex for reliability + const commentRegex = /\/\/\s*@ts-(ignore|expect-error)/g + let match: RegExpExecArray | null + while ((match = commentRegex.exec(source)) !== null) { + const { line, character } = sourceFile.getLineAndCharacterOfPosition(match.index) + const kind = match[1] + violations.push({ + ruleId: kind === "ignore" ? "no-ts-ignore" : "no-ts-expect-error", + filePath, + line: line + 1, + column: character + 1, + message: `\`@ts-${kind}\` — fix the underlying type`, + }) + } + + return violations +} + +function formatViolation(v: Violation): string { + return `${v.filePath}:${v.line}:${v.column}: [${v.ruleId}] ${v.message}` +} + +function main(): void { + const args = process.argv.slice(2) + if (args.length === 0) { + console.error("usage: check-no-excuse-rules.ts ...") + process.exit(2) + } + + const files = discoverFiles(args) + if (files.length === 0) { + console.error("No TypeScript files found.") + process.exit(2) + } + + const violations = files.flatMap((f) => analyzeFile(f)) + + if (violations.length === 0) { + console.log(`No violations in ${files.length} file(s).`) + return + } + + for (const v of violations) { + console.error(formatViolation(v)) + } + console.error(`\n${violations.length} violation(s) in ${files.length} file(s).`) + process.exit(1) +} + +main() diff --git a/packages/shared-skills/skills/programming/scripts/typescript/new-project.ts b/packages/shared-skills/skills/programming/scripts/typescript/new-project.ts new file mode 100644 index 000000000..9bb2a3e76 --- /dev/null +++ b/packages/shared-skills/skills/programming/scripts/typescript/new-project.ts @@ -0,0 +1,177 @@ +#!/usr/bin/env bun +/** + * Scaffold a new TypeScript project with ultra-strict defaults. + * + * ─── How to run ─── + * 1. Install Bun: curl -fsSL https://bun.sh/install | bash + * 2. Run: + * bun run scripts/new-project.ts my-api + * bun run scripts/new-project.ts my-api --path ./projects + * ────────────────── + * + * Creates: + * / + * package.json (Bun + Hono + Zod + Drizzle + Biome) + * tsconfig.json (ultra-strict from tsconfig-strict.md) + * biome.json (strict from tsconfig-strict.md) + * src/index.ts (minimal Hono entrypoint) + * .gitignore + */ + +import { mkdirSync, writeFileSync, existsSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { parseArgs } from "node:util"; + +const { values, positionals } = parseArgs({ + args: Bun.argv.slice(2), + options: { + path: { type: "string", default: "." }, + help: { type: "boolean", short: "h", default: false }, + }, + allowPositionals: true, + strict: true, +}); + +if (values.help || positionals.length === 0) { + console.log(`Usage: bun run new-project.ts [--path ] + +Arguments: + name Project directory name (kebab-case) + +Options: + --path Parent directory (default: current dir) + -h, --help Show this help`); + process.exit(positionals.length === 0 ? 2 : 0); +} + +const name = positionals[0]!; +const root = resolve(values.path!, name); + +if (existsSync(root)) { + console.error(`Error: ${root} already exists`); + process.exit(1); +} + +// ── Directory structure ── +mkdirSync(join(root, "src"), { recursive: true }); + +// ── package.json ── +const pkg = { + name, + version: "0.0.1", + private: true, + type: "module", + scripts: { + dev: "bun --hot src/index.ts", + start: "bun src/index.ts", + check: "bunx biome check . && bunx tsc --noEmit && bun test", + "check:fix": "bunx biome check --write .", + test: "bun test", + }, + dependencies: { + hono: "^4.12.5", + zod: "^3.24.0", + }, + devDependencies: { + "@biomejs/biome": "^1.9.0", + "@types/bun": "latest", + typescript: "^5.8.0", + }, +}; +writeFileSync(join(root, "package.json"), JSON.stringify(pkg, null, 2) + "\n"); + +// ── tsconfig.json (ultra-strict) ── +const tsconfig = { + compilerOptions: { + strict: true, + noUncheckedIndexedAccess: true, + exactOptionalPropertyTypes: true, + noFallthroughCasesInSwitch: true, + forceConsistentCasingInFileNames: true, + verbatimModuleSyntax: true, + isolatedModules: true, + esModuleInterop: true, + resolveJsonModule: true, + target: "ESNext", + lib: ["ESNext"], + declaration: true, + declarationMap: true, + sourceMap: true, + outDir: "dist", + rootDir: "src", + module: "ESNext", + moduleResolution: "bundler", + types: ["bun-types"], + skipLibCheck: true, + noEmit: true, + }, + include: ["src/**/*.ts"], + exclude: ["node_modules", "dist"], +}; +writeFileSync( + join(root, "tsconfig.json"), + JSON.stringify(tsconfig, null, 2) + "\n", +); + +// ── biome.json (strict) ── +const biome = { + $schema: "https://biomejs.dev/schemas/1.9.0/schema.json", + organizeImports: { enabled: true }, + formatter: { + enabled: true, + indentStyle: "space", + indentWidth: 2, + lineWidth: 100, + }, + linter: { + enabled: true, + rules: { + recommended: true, + complexity: { + noBannedTypes: "error", + noExtraBooleanCast: "error", + noUselessConstructor: "error", + noUselessRename: "error", + noVoid: "error", + }, + correctness: { + noUnusedVariables: "error", + noUnusedImports: "error", + useExhaustiveDependencies: "warn", + }, + style: { + noNonNullAssertion: "error", + useConst: "error", + noParameterAssign: "error", + }, + suspicious: { + noExplicitAny: "error", + noAssertion: "warn", + }, + }, + }, +}; +writeFileSync(join(root, "biome.json"), JSON.stringify(biome, null, 2) + "\n"); + +// ── src/index.ts ── +const indexTs = `import { Hono } from "hono"; + +const app = new Hono(); + +app.get("/", (c) => c.json({ status: "ok" })); + +export default app; +`; +writeFileSync(join(root, "src/index.ts"), indexTs); + +// ── .gitignore ── +const gitignore = `node_modules/ +dist/ +*.tsbuildinfo +.env +.env.* +`; +writeFileSync(join(root, ".gitignore"), gitignore); + +console.log(`✓ Created: ${root}`); +console.log(` cd ${name} && bun install && bun run check`); diff --git a/src/features/builtin-skills/skills/init-deep.ts b/src/features/builtin-skills/skills/init-deep.ts new file mode 100644 index 000000000..5c3f560f0 --- /dev/null +++ b/src/features/builtin-skills/skills/init-deep.ts @@ -0,0 +1,9 @@ +import { loadSharedSkillTemplate } from "../skill-file-loader" +import type { BuiltinSkill } from "../types" + +export const initDeepSkill: BuiltinSkill = { + name: "init-deep", + description: "(builtin) Initialize hierarchical AGENTS.md knowledge base", + template: loadSharedSkillTemplate("init-deep"), + argumentHint: "[--create-new] [--max-depth=N]", +} diff --git a/src/features/builtin-skills/skills/remove-ai-slops.ts b/src/features/builtin-skills/skills/remove-ai-slops.ts new file mode 100644 index 000000000..00c1867a5 --- /dev/null +++ b/src/features/builtin-skills/skills/remove-ai-slops.ts @@ -0,0 +1,9 @@ +import { loadSharedSkillTemplate } from "../skill-file-loader" +import type { BuiltinSkill } from "../types" + +export const removeAiSlopsSkill: BuiltinSkill = { + name: "remove-ai-slops", + description: + 'Remove AI-generated code smells (slop) from branch changes or an explicit file list. Locks behavior with regression tests FIRST, then runs categorized cleanup via parallel `deep` agents in batches of 5, then verifies with quality gates. Covers 10 slop categories including performance equivalences, excessive complexity (object annotations, if/elif variant chains), and oversized modules (250+ pure LOC with mandatory modular refactoring). MUST USE when the user asks to "remove slop", "clean AI code", "deslop", "clean up AI-generated code", "remove AI slop", or wants to clean up AI-generated patterns from recent changes. Triggers - "remove ai slops", "clean ai code", "deslop", "cleanup AI generated", "remove AI slop", "clean up AI-generated code", "strip slop", "ai-slop cleanup".', + template: loadSharedSkillTemplate("remove-ai-slops"), +}