diff --git a/AI/skills/hickey/SKILL.md b/AI/skills/hickey/SKILL.md index 655ebe3..98f3e1d 100644 --- a/AI/skills/hickey/SKILL.md +++ b/AI/skills/hickey/SKILL.md @@ -91,7 +91,15 @@ Always frame simplifications as *structural refactors*, not style preferences. " ## Automated Fitness Functions -When the user asks for fitness functions, structural checks, or CI-enforceable rules, generate concrete implementations. Here are the patterns to draw from, adapted to the codebase's language and tooling: +When the user asks for fitness functions, structural checks, or CI-enforceable rules, generate concrete implementations. Here are the patterns to draw from, adapted to the codebase's language and tooling. + +### Ready-made scripts + +The `scripts/` directory (relative to this skill) contains ready-to-use tooling: + +- **`scripts/complect-detect.ts`** — a ts-morph script that runs five structural checks against a TypeScript project: mutable state density, closure-over-mutable-state, circular event flow, module concern mixing, and lifecycle nesting. Run with `npx tsx complect-detect.ts --project ./tsconfig.json`. See `scripts/README.md` for full usage and CI integration. + +When working with a TypeScript project, prefer pointing the user to this script (or running it directly) rather than generating checks from scratch. ### For TypeScript/JavaScript codebases diff --git a/AI/skills/hickey/scripts/README.md b/AI/skills/hickey/scripts/README.md new file mode 100644 index 0000000..04fb4e4 --- /dev/null +++ b/AI/skills/hickey/scripts/README.md @@ -0,0 +1,75 @@ +# complect-detect + +Structural simplicity checks for TypeScript projects, based on Rich Hickey's "Simple Made Easy" framework. Detects accidental complexity that tests can't catch. + +## Install + +```sh +npm install -D ts-morph tsx +``` + +## Usage + +```sh +npx tsx complect-detect.ts --project ./tsconfig.json --threshold 3 +``` + +- `--project` — path to `tsconfig.json` (default: `./tsconfig.json`) +- `--threshold` — minimum mutable ops per function to report (default: 3) + +JSON report goes to stdout, human-readable summary to stderr. Exit 0 = clean, exit 1 = findings. + +## Checks + +### 1. Mutable state density + +Counts `let` declarations + reassignments (`=`, `+=`, `++`, etc.) per exported function and async generator. Functions at or above the threshold are flagged. Each mutable binding is a temporal entanglement — correctness depends on *when*, not just *what*. + +### 2. Closure over mutable state + +Finds inner functions (arrows, function expressions) that reference a `let`-declared variable from an enclosing scope. Reports the variable name and both locations. These closures braid the inner function's behavior with the outer function's timeline. + +### 3. Circular event flow + +Finds functions that both subscribe to an EventEmitter event (`.on`, `.addEventListener`, `for await...of subscribeAndYield(...)`) AND emit on the same event. Catches feedback loops that no test suite surfaces — the producer/consumer distinction collapses. + +### 4. Module concern mixing + +Classifies each export as `pure-function`, `async-generator`, `stateful`, or `side-effect`. Reports files mixing categories. A module that exports both pure queries and stateful watchers is braiding two concerns. + +### 5. Lifecycle nesting + +Finds `new AbortController()`, `fs.watch(`, `setTimeout(`, `setInterval(` inside async generator bodies. Reports generators with 2+ lifecycle constructs — each nested lifecycle is a "when" concern braided into existing control flow. + +## CI integration + +### GitHub Actions + +```yaml +name: Structural Simplicity +on: [pull_request] + +jobs: + complect-detect: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - run: npm ci + - name: Run complect-detect + run: | + npx tsx path/to/complect-detect.ts \ + --project ./tsconfig.json \ + --threshold 3 \ + > complect-report.json + - name: Upload report + if: always() + uses: actions/upload-artifact@v4 + with: + name: complect-report + path: complect-report.json +``` + +The script exits 1 when findings exist, failing the CI step. Adjust `--threshold` to control sensitivity. Start permissive and tighten over time. diff --git a/AI/skills/hickey/scripts/complect-detect.ts b/AI/skills/hickey/scripts/complect-detect.ts new file mode 100644 index 0000000..7adb589 --- /dev/null +++ b/AI/skills/hickey/scripts/complect-detect.ts @@ -0,0 +1,533 @@ +#!/usr/bin/env npx tsx +/** + * complect-detect — structural simplicity checks for TypeScript projects. + * + * Implements five checks from the Hickey "Simple Made Easy" framework: + * 1. Mutable state density per function + * 2. Closure-over-mutable-state + * 3. Circular event flow + * 4. Module concern mixing + * 5. Lifecycle nesting in async generators + * + * Usage: npx tsx complect-detect.ts [--project ./tsconfig.json] [--threshold 3] + */ + +import { Project, SyntaxKind, Node, SourceFile, ts } from "ts-morph"; +import { parseArgs } from "node:util"; + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +const { values: args } = parseArgs({ + options: { + project: { type: "string", default: "./tsconfig.json" }, + threshold: { type: "string", default: "3" }, + }, + strict: true, +}); + +const tsconfigPath = args.project!; +const mutableThreshold = parseInt(args.threshold!, 10); + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface MutableStateFinding { + file: string; + function: string; + line: number; + letCount: number; + reassignmentCount: number; + total: number; +} + +interface ClosureMutableFinding { + file: string; + outerFunction: string; + innerFunction: string; + variable: string; + outerLine: number; + innerLine: number; +} + +interface CircularEventFinding { + file: string; + function: string; + line: number; + event: string; +} + +type ExportKind = "pure-function" | "async-generator" | "stateful" | "side-effect"; + +interface ModuleConcernFinding { + file: string; + kinds: ExportKind[]; + exports: Array<{ name: string; kind: ExportKind }>; +} + +interface LifecycleNestingFinding { + file: string; + generator: string; + line: number; + constructs: string[]; + count: number; +} + +interface Report { + mutableStateDensity: MutableStateFinding[]; + closureOverMutable: ClosureMutableFinding[]; + circularEventFlow: CircularEventFinding[]; + moduleConcernMixing: ModuleConcernFinding[]; + lifecycleNesting: LifecycleNestingFinding[]; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const filePath = (sf: SourceFile): string => sf.getFilePath(); + +const isFunctionLike = (node: Node): boolean => + Node.isFunctionDeclaration(node) || + Node.isFunctionExpression(node) || + Node.isArrowFunction(node) || + Node.isMethodDeclaration(node); + +const functionName = (node: Node): string => { + if (Node.isFunctionDeclaration(node) || Node.isMethodDeclaration(node)) { + return (node as any).getName?.() ?? ""; + } + // arrow / expression assigned to a variable + const parent = node.getParent(); + if (parent && Node.isVariableDeclaration(parent)) { + return parent.getName(); + } + return ""; +}; + +const isAsyncGenerator = (node: Node): boolean => { + if ( + Node.isFunctionDeclaration(node) || + Node.isFunctionExpression(node) || + Node.isMethodDeclaration(node) + ) { + const decl = node as any; + return !!decl.isAsync?.() && !!decl.isGenerator?.(); + } + return false; +}; + +// --------------------------------------------------------------------------- +// Check 1: Mutable state density +// --------------------------------------------------------------------------- + +const countMutableState = (fn: Node): { lets: number; reassigns: number } => { + let lets = 0; + let reassigns = 0; + + fn.forEachDescendant((child) => { + // Skip nested function-like nodes (they get their own count) + if (isFunctionLike(child) && child !== fn) return; + + if (Node.isVariableDeclaration(child)) { + const stmt = child.getParent(); + if (stmt && Node.isVariableDeclarationList(stmt)) { + if (stmt.getDeclarationKind() === ts.NodeFlags.None) { + // "let" shows up as None in some ts-morph versions; check text + } + const kindText = stmt.getFlags(); + // More reliable: check the keyword directly + const text = stmt.getText(); + if (text.startsWith("let ")) lets++; + } + } + + // Count binary assignments that aren't initializers + if (Node.isBinaryExpression(child)) { + const op = child.getOperatorToken().getKind(); + if ( + op === SyntaxKind.EqualsToken || + op === SyntaxKind.PlusEqualsToken || + op === SyntaxKind.MinusEqualsToken + ) { + reassigns++; + } + } + + // Prefix/postfix increment/decrement + if ( + Node.isPrefixUnaryExpression(child) || + Node.isPostfixUnaryExpression(child) + ) { + const op = (child as any).getOperatorToken?.() ?? child.getChildAtIndex(0); + const opKind = child.compilerNode.operator; + if ( + opKind === ts.SyntaxKind.PlusPlusToken || + opKind === ts.SyntaxKind.MinusMinusToken + ) { + reassigns++; + } + } + }); + + return { lets, reassigns }; +}; + +const checkMutableStateDensity = ( + sourceFiles: SourceFile[], + threshold: number, +): MutableStateFinding[] => + sourceFiles.flatMap((sf) => { + const findings: MutableStateFinding[] = []; + sf.forEachDescendant((node) => { + if (!isFunctionLike(node)) return; + // Only check exported functions and async generators at top level + const isExported = + Node.isFunctionDeclaration(node) && node.isExported(); + const isTopLevelVar = + node.getParent() && + Node.isVariableDeclaration(node.getParent()!) && + (node.getParent()!.getParent()?.getParent() as any)?.isExported?.(); + + if (!isExported && !isTopLevelVar && !isAsyncGenerator(node)) return; + + const { lets, reassigns } = countMutableState(node); + const total = lets + reassigns; + if (total >= threshold) { + findings.push({ + file: filePath(sf), + function: functionName(node), + line: node.getStartLineNumber(), + letCount: lets, + reassignmentCount: reassigns, + total, + }); + } + }); + return findings; + }); + +// --------------------------------------------------------------------------- +// Check 2: Closure over mutable state +// --------------------------------------------------------------------------- + +const checkClosureOverMutable = ( + sourceFiles: SourceFile[], +): ClosureMutableFinding[] => + sourceFiles.flatMap((sf) => { + const findings: ClosureMutableFinding[] = []; + + sf.forEachDescendant((outer) => { + if (!isFunctionLike(outer)) return; + + // Collect let-declared variables in this scope + const letVars = new Map(); + outer.forEachChild((child) => { + if (Node.isVariableStatement(child)) { + const decl = child.getDeclarationList(); + if (decl.getText().startsWith("let ")) { + for (const v of decl.getDeclarations()) { + letVars.set(v.getName(), v.getStartLineNumber()); + } + } + } + }); + + if (letVars.size === 0) return; + + // Find inner functions that reference these variables + outer.forEachDescendant((inner) => { + if (!isFunctionLike(inner) || inner === outer) return; + inner.forEachDescendant((ref) => { + if (Node.isIdentifier(ref)) { + const name = ref.getText(); + if (letVars.has(name)) { + findings.push({ + file: filePath(sf), + outerFunction: functionName(outer), + innerFunction: functionName(inner), + variable: name, + outerLine: letVars.get(name)!, + innerLine: ref.getStartLineNumber(), + }); + } + } + }); + }); + }); + return findings; + }); + +// --------------------------------------------------------------------------- +// Check 3: Circular event flow +// --------------------------------------------------------------------------- + +const extractEventName = (callExpr: Node): string | undefined => { + if (!Node.isCallExpression(callExpr)) return undefined; + const args = callExpr.getArguments(); + if (args.length === 0) return undefined; + const first = args[0]; + if (Node.isStringLiteral(first)) return first.getLiteralText(); + return undefined; +}; + +const checkCircularEventFlow = ( + sourceFiles: SourceFile[], +): CircularEventFinding[] => + sourceFiles.flatMap((sf) => { + const findings: CircularEventFinding[] = []; + + sf.forEachDescendant((fn) => { + if (!isFunctionLike(fn)) return; + + const subscribedEvents = new Set(); + const emittedEvents = new Set(); + + fn.forEachDescendant((node) => { + if (!Node.isCallExpression(node)) return; + const expr = node.getExpression(); + if (!Node.isPropertyAccessExpression(expr)) return; + + const methodName = expr.getName(); + const eventName = extractEventName(node); + if (!eventName) return; + + if (methodName === "on" || methodName === "addEventListener" || methodName === "addListener") { + subscribedEvents.add(eventName); + } + if (methodName === "emit" || methodName === "dispatchEvent") { + emittedEvents.add(eventName); + } + }); + + // Also check for subscribeAndYield pattern in for-await + fn.forEachDescendant((node) => { + if (Node.isForOfStatement(node) && node.isAwaited()) { + const initializer = node.getInitializer(); + const iterExpr = node.getExpression(); + if (Node.isCallExpression(iterExpr)) { + const callee = iterExpr.getExpression(); + if (Node.isIdentifier(callee) && callee.getText() === "subscribeAndYield") { + const eventName = iterExpr.getArguments()[1]; + if (eventName && Node.isStringLiteral(eventName)) { + subscribedEvents.add(eventName.getLiteralText()); + } + } + } + } + }); + + // Find overlap + for (const event of subscribedEvents) { + if (emittedEvents.has(event)) { + findings.push({ + file: filePath(sf), + function: functionName(fn), + line: fn.getStartLineNumber(), + event, + }); + } + } + }); + return findings; + }); + +// --------------------------------------------------------------------------- +// Check 4: Module concern mixing +// --------------------------------------------------------------------------- + +const SIDE_EFFECT_MODULES = new Set([ + "fs", "node:fs", "fs/promises", "node:fs/promises", + "net", "node:net", "http", "node:http", "https", "node:https", + "child_process", "node:child_process", + "process", +]); + +const classifyExport = (node: Node, sf: SourceFile): ExportKind => { + if (isAsyncGenerator(node)) return "async-generator"; + + // Check for class / mutable module-level state + if (Node.isClassDeclaration(node)) return "stateful"; + if (Node.isVariableDeclaration(node)) { + const init = node.getInitializer(); + if (init && Node.isNewExpression(init)) return "stateful"; + } + + // Check if function body references side-effect modules + if (isFunctionLike(node)) { + const text = node.getText(); + const imports = sf.getImportDeclarations(); + for (const imp of imports) { + const mod = imp.getModuleSpecifierValue(); + if (SIDE_EFFECT_MODULES.has(mod)) { + // Check if any imported name is used in this function + for (const named of imp.getNamedImports()) { + if (text.includes(named.getName())) return "side-effect"; + } + const ns = imp.getNamespaceImport(); + if (ns && text.includes(ns.getText())) return "side-effect"; + const def = imp.getDefaultImport(); + if (def && text.includes(def.getText())) return "side-effect"; + } + } + return "pure-function"; + } + + return "pure-function"; +}; + +const checkModuleConcernMixing = ( + sourceFiles: SourceFile[], +): ModuleConcernFinding[] => + sourceFiles.flatMap((sf) => { + const exports: Array<{ name: string; kind: ExportKind }> = []; + + for (const decl of sf.getExportedDeclarations()) { + const [name, nodes] = decl; + for (const node of nodes) { + exports.push({ name, kind: classifyExport(node, sf) }); + } + } + + const kinds = [...new Set(exports.map((e) => e.kind))]; + if (kinds.length > 1) { + return [{ file: filePath(sf), kinds, exports }]; + } + return []; + }); + +// --------------------------------------------------------------------------- +// Check 5: Lifecycle nesting in async generators +// --------------------------------------------------------------------------- + +const LIFECYCLE_PATTERNS = [ + "new AbortController", + "fs.watch(", + "setTimeout(", + "setInterval(", + "fs.watchFile(", +]; + +const checkLifecycleNesting = ( + sourceFiles: SourceFile[], +): LifecycleNestingFinding[] => + sourceFiles.flatMap((sf) => { + const findings: LifecycleNestingFinding[] = []; + + sf.forEachDescendant((node) => { + if (!isAsyncGenerator(node)) return; + + const body = node.getText(); + const constructs: string[] = []; + + for (const pattern of LIFECYCLE_PATTERNS) { + if (body.includes(pattern)) { + constructs.push(pattern.replace("(", "").trim()); + } + } + + // Also check for new AbortController via AST + node.forEachDescendant((child) => { + if ( + Node.isNewExpression(child) && + child.getExpression().getText() === "AbortController" && + !constructs.includes("new AbortController") + ) { + constructs.push("new AbortController"); + } + }); + + if (constructs.length >= 2) { + findings.push({ + file: filePath(sf), + generator: functionName(node), + line: node.getStartLineNumber(), + constructs, + count: constructs.length, + }); + } + }); + return findings; + }); + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +const run = (tsconfigPath: string, threshold: number): Report => { + const project = new Project({ tsConfigFilePath: tsconfigPath }); + const sourceFiles = project + .getSourceFiles() + .filter((sf) => !sf.isDeclarationFile() && !sf.getFilePath().includes("node_modules")); + + return { + mutableStateDensity: checkMutableStateDensity(sourceFiles, threshold), + closureOverMutable: checkClosureOverMutable(sourceFiles), + circularEventFlow: checkCircularEventFlow(sourceFiles), + moduleConcernMixing: checkModuleConcernMixing(sourceFiles), + lifecycleNesting: checkLifecycleNesting(sourceFiles), + }; +}; + +const summarize = (report: Report): void => { + const log = (msg: string) => process.stderr.write(msg + "\n"); + log("\n=== Complect Detect — Structural Simplicity Report ===\n"); + + const section = (name: string, items: unknown[]) => { + log(`${name}: ${items.length} finding(s)`); + }; + + section("Mutable state density", report.mutableStateDensity); + for (const f of report.mutableStateDensity) { + log(` ${f.file}:${f.line} — ${f.function} (${f.total} mutable ops: ${f.letCount} lets, ${f.reassignmentCount} reassignments)`); + } + + section("Closure over mutable state", report.closureOverMutable); + for (const f of report.closureOverMutable) { + log(` ${f.file}:${f.innerLine} — inner fn "${f.innerFunction}" captures \`${f.variable}\` from "${f.outerFunction}" (declared line ${f.outerLine})`); + } + + section("Circular event flow", report.circularEventFlow); + for (const f of report.circularEventFlow) { + log(` ${f.file}:${f.line} — ${f.function} subscribes and emits "${f.event}"`); + } + + section("Module concern mixing", report.moduleConcernMixing); + for (const f of report.moduleConcernMixing) { + log(` ${f.file} — mixes: ${f.kinds.join(", ")}`); + for (const e of f.exports) { + log(` ${e.name}: ${e.kind}`); + } + } + + section("Lifecycle nesting", report.lifecycleNesting); + for (const f of report.lifecycleNesting) { + log(` ${f.file}:${f.line} — ${f.generator} (${f.count} lifecycle constructs: ${f.constructs.join(", ")})`); + } + + const total = + report.mutableStateDensity.length + + report.closureOverMutable.length + + report.circularEventFlow.length + + report.moduleConcernMixing.length + + report.lifecycleNesting.length; + + log(`\nTotal findings: ${total}`); + log(total === 0 ? "✓ No structural simplicity issues detected." : "✗ Findings above — review for accidental complexity."); +}; + +// Run +const report = run(tsconfigPath, mutableThreshold); +process.stdout.write(JSON.stringify(report, null, 2) + "\n"); +summarize(report); +process.exit( + report.mutableStateDensity.length + + report.closureOverMutable.length + + report.circularEventFlow.length + + report.moduleConcernMixing.length + + report.lifecycleNesting.length > 0 + ? 1 + : 0, +);