mirror of
https://github.com/srid/nixos-config.git
synced 2026-07-16 22:01:33 +08:00
Upstreamed
This commit is contained in:
parent
6bb678cd11
commit
235885202b
4 changed files with 0 additions and 848 deletions
|
|
@ -1,89 +0,0 @@
|
|||
---
|
||||
name: github-pr
|
||||
description: Write engaging GitHub PR titles and descriptions. Use when creating or updating PRs. Avoids boring bullet lists; uses narrative paragraphs with bold/italic for emphasis.
|
||||
---
|
||||
|
||||
# GitHub PR Writing
|
||||
|
||||
Write PR descriptions that fellow devs actually want to read.
|
||||
|
||||
## Anti-patterns (what LLMs typically produce)
|
||||
|
||||
- Flat bullet lists of every file changed
|
||||
- Implementation-detail dumps ("added `foo` parameter to `bar` function")
|
||||
- Generic titles like "Update configuration" or "Fix bug in module"
|
||||
- "## Changes" / "## Testing" / "## Summary" boilerplate headers
|
||||
- Restating the diff in English
|
||||
|
||||
## What to write instead
|
||||
|
||||
**Title**: Short, specific, interesting. Convey *what changed from a user/dev perspective*, not which files were touched. Use imperative mood. Under 70 chars.
|
||||
|
||||
**Body**: Write in **paragraphs**, not bullet lists. Structure:
|
||||
|
||||
1. **Opening paragraph** — What this PR does and *why*, in 2-3 sentences. Bold the key behavioral change. If there's a motivating problem, state it directly.
|
||||
|
||||
2. **Details paragraph(s)** — Only if the approach is non-obvious or has trade-offs worth calling out. Use *italics* for subtle points. Keep it high-level; reviewers can read the diff for implementation details.
|
||||
|
||||
3. **Anything notable** — Breaking changes, migration steps, or things reviewers should pay attention to. Only if applicable. Use `> blockquote` for callouts.
|
||||
|
||||
## Style rules
|
||||
|
||||
- Write for a dev skimming their PR feed — they should get the gist in 5 seconds
|
||||
- **Bold** the most important phrase in each paragraph
|
||||
- *Italics* for nuance, caveats, secondary points
|
||||
- No bullet lists unless listing 3+ discrete items that genuinely aren't a narrative
|
||||
- No "## Summary" or "## Changes" headers — just write
|
||||
- No filler: "This PR...", "In this change...", "As part of..." — start with the substance
|
||||
- Link to issues/discussions where relevant (`Closes #123`, `See #456`)
|
||||
- If the PR is trivial (typo fix, version bump), a one-liner body is fine
|
||||
|
||||
## Try it locally
|
||||
|
||||
If the repo is a Nix flake and the PR branch contains a buildable output (package, NixOS config, etc.), include a "Try it locally" section at the end of the body. Use the GitHub owner/repo and branch name to construct the command:
|
||||
|
||||
```
|
||||
### Try it locally
|
||||
`nix run github:<owner>/<repo>/<branch>`
|
||||
```
|
||||
|
||||
Adjust the command as needed — `nix build` for non-runnable outputs, add `#<output>` if the default package isn't the relevant one. Omit this section entirely if the change isn't meaningfully testable via `nix run/build` (e.g., CI-only changes, documentation, non-Nix repos).
|
||||
|
||||
## Updating existing PRs
|
||||
|
||||
When the user pushes further changes to an already-PR'd branch:
|
||||
|
||||
1. Check if the PR title/description still accurately reflects the full scope
|
||||
2. If new commits meaningfully change what the PR does, update the title and/or body via `gh pr edit`
|
||||
3. Don't rewrite from scratch — amend the existing description to cover new ground
|
||||
4. Add a brief note about what changed if the scope expanded significantly
|
||||
|
||||
## Examples
|
||||
|
||||
### Bad (typical LLM output)
|
||||
```
|
||||
Title: Update NixOS configuration and add new service
|
||||
|
||||
## Summary
|
||||
- Added `kolu` service configuration
|
||||
- Updated `flake.lock`
|
||||
- Modified port from 8080 to 8090
|
||||
- Added health check endpoint
|
||||
- Updated README
|
||||
|
||||
## Testing
|
||||
- Tested locally
|
||||
```
|
||||
|
||||
### Good
|
||||
```
|
||||
Title: Add kolu service with health monitoring
|
||||
|
||||
**Kolu now runs as a standalone NixOS service** with its own systemd
|
||||
unit and a dedicated health-check endpoint. Previously it was bolted
|
||||
onto the main app process, which made restarts disruptive.
|
||||
|
||||
The service binds to port 8090 to avoid clashing with the dev server.
|
||||
*Health checks hit `/healthz` every 30s — systemd restarts the
|
||||
service on three consecutive failures.*
|
||||
```
|
||||
|
|
@ -1,151 +0,0 @@
|
|||
---
|
||||
name: hickey
|
||||
description: Evaluate code (especially LLM-generated) for structural simplicity using Rich Hickey's "Simple Made Easy" framework. Use this skill whenever reviewing a PR, diff, or code snippet for accidental complexity — particularly when the code was generated by an AI coding assistant and line-by-line review isn't feasible. Also use when the user asks about complecting, simplicity vs. easiness, structural coupling, or wants architecture fitness functions for a codebase. Trigger on phrases like "is this simple", "does this complect", "review for complexity", "structural analysis", "fitness function", or any reference to Hickey, Simple Made Easy, or grey-box review.
|
||||
---
|
||||
|
||||
# Hickey: Structural Simplicity Evaluation
|
||||
|
||||
Evaluate code for **structural simplicity** using Rich Hickey's "Simple Made Easy" framework. This skill is designed for the world where AI generates code faster than humans can read it — where functional tests pass but accidental complexity accumulates silently.
|
||||
|
||||
The core premise: **tests tell you code works; they tell you nothing about whether it's simple.** Every bug that ever existed passed the type checker and passed all the tests. Complected code can be perfectly correct today. The damage shows up when you try to change it, reason about it, or extend it.
|
||||
|
||||
## Key Definitions
|
||||
|
||||
Before evaluating anything, internalize these distinctions:
|
||||
|
||||
**Simple** (sim-plex, "one fold/braid"): A construct that addresses one concern, one role, one concept. Simplicity is *objective* — you can look at code and ask "how many independent concerns are braided together here?" and get a defensible answer. Simple does not mean "one of a kind" — an interface with five methods can be simple if they all serve one concern.
|
||||
|
||||
**Easy** (adjacens, "nearby"): Familiar, at hand, within our skillset. Easy is *relative* to the person. Most things developers call "simple" they actually mean "easy" — they mean familiar.
|
||||
|
||||
**Complect** (com-plectere, "to braid together"): To interleave, entangle, or entwine independent concerns so they cannot be reasoned about in isolation. Complecting is the root cause of accidental complexity. It raises cognitive load combinatorially — if A is braided with B and B with C, understanding A now requires understanding B and C too.
|
||||
|
||||
**Compose** (com-ponere, "to place together"): To combine independent things side by side. Composition preserves the ability to reason about parts in isolation. Composing is what we want; complecting is what we get by default.
|
||||
|
||||
## The Evaluation Process
|
||||
|
||||
When reviewing code (a PR, diff, file, or snippet), work through these layers in order.
|
||||
|
||||
### Layer 1: Identify the Concerns
|
||||
|
||||
Before looking at structure, name the independent concerns the code addresses. Write them out explicitly. For example: "This code does three things: (1) watches the filesystem for changes, (2) resolves git metadata from a path, (3) manages watcher lifecycle across CWD changes."
|
||||
|
||||
If you can't cleanly name distinct concerns, that is itself a finding — the concerns may already be entangled beyond easy identification.
|
||||
|
||||
### Layer 2: Check the Complecting Catalog
|
||||
|
||||
Scan the code for known complecting patterns. These are constructs that structurally braid independent things together:
|
||||
|
||||
| Construct | What it complects | Simpler alternative |
|
||||
|-----------|------------------|---------------------|
|
||||
| Mutable state | Value + time + identity; everything that touches it | Values (immutable data), managed refs |
|
||||
| Objects | State + identity + value + namespace | Functions + data + namespaces |
|
||||
| Methods | Function + state; function + namespace | Stateless functions, protocols |
|
||||
| Inheritance | Types with types | Polymorphism via protocols/interfaces |
|
||||
| Switch/case on type | Who + what (multiple caller/behavior pairs) | Polymorphic dispatch |
|
||||
| Mutable variables | Value + time | Final/const bindings, values |
|
||||
| Imperative loops | What + how + when | Set functions, declarative transforms (map/filter/reduce) |
|
||||
| Actors | What + who (message handling braided with identity) | Queues + stateless handlers |
|
||||
| ORM | Object identity + relational model + query language | Data + declarative queries |
|
||||
| Conditionals scattered across code | One decision braided across many sites | Rules, declarative policies |
|
||||
| Callbacks/closures over mutable state | Control flow + state + time | Async iterables, queues, values |
|
||||
|
||||
Not every use of these constructs is wrong. The question is whether the complecting is *essential* (required by the problem) or *accidental* (an artifact of the chosen approach).
|
||||
|
||||
### Layer 3: Structural Entanglement Analysis
|
||||
|
||||
For each file or module touched, answer:
|
||||
|
||||
1. **Concern count per module.** How many distinct concerns does this module address? A module that mixes pure queries with stateful long-lived side effects (e.g., a file that exports both `getInfo(path)` and `watchForChanges(path, signal)`) is structurally braiding two concerns. Flag it.
|
||||
|
||||
2. **Mutable binding count per function/handler.** Count `let` bindings, reassigned variables, and closed-over mutable state. Each mutable binding is a temporal entanglement — correctness now depends on *when* things happen, not just *what* happens. Zero mutable bindings in a function = simple data flow. 3+ in a single scope = worth scrutinizing.
|
||||
|
||||
3. **Closure depth.** Functions defined inside functions that capture mutable state from the enclosing scope braid the inner function's behavior with the outer function's timeline. Closures over immutable values are fine; closures over mutable state complect.
|
||||
|
||||
4. **Data flow topology.** Is the data flow a pipeline (A → B → C), a DAG, or does it contain cycles? Specifically: does any handler/consumer emit events back onto a channel it is itself consuming? Cyclic data flow complects producer and consumer — the "who" question (who is producing? who is consuming?) no longer has a clean answer.
|
||||
|
||||
5. **Lifecycle nesting.** Does the code create new lifecycle scopes (AbortControllers, watchers, subscriptions, timers) inside a handler that already has its own lifecycle? Each nested lifecycle is a new "when" concern braided into the existing control flow. One level of lifecycle (the framework-provided signal) is normal. Two or more levels means the handler is managing concurrent subprocess lifetimes, which is a distinct concern from its primary job.
|
||||
|
||||
6. **Temporal coupling.** Does correctness depend on the *order* in which things execute, beyond what the type system or data flow enforces? Examples: relying on microtask ordering for a non-null assertion, assuming an event fires before a callback runs, depending on a debounce timer to prevent re-entrance. Each temporal assumption is an invisible braid.
|
||||
|
||||
### Layer 4: Assess Severity
|
||||
|
||||
Not all complecting matters equally. Assess using:
|
||||
|
||||
- **Blast radius.** If this entanglement causes a bug, how much of the system does the developer need to understand to diagnose it? State that leaks across module boundaries has high blast radius. A mutable counter inside a 10-line function has low blast radius.
|
||||
|
||||
- **Change friction.** If requirements change, can the complected concerns be modified independently? If changing the filesystem-watching strategy requires rewriting the router handler, the concerns are too entangled.
|
||||
|
||||
- **Reasoning load.** Can you explain what this code does without using the words "and then" or "but only if"? If the explanation requires temporal qualifiers, the code is likely complecting time into its logic.
|
||||
|
||||
### Layer 5: Suggest Simplifications
|
||||
|
||||
For each significant finding, you MUST propose a concrete structural alternative — not a vague gesture at "could be simpler." Write out what the simplified version would actually look like, even as pseudocode. This is mandatory, not optional. Never skip this step by labeling the finding as "essential complexity." Follow Hickey's design questions:
|
||||
|
||||
- **What**: Can we define a cleaner abstraction boundary? An interface that does one thing?
|
||||
- **Who**: Can we pass subcomponents as arguments instead of hardwiring them?
|
||||
- **How**: Can we isolate the implementation so changing it doesn't ripple?
|
||||
- **When/Where**: Can we decouple components with a queue or channel instead of direct calls?
|
||||
- **Why**: Can we express policies declaratively instead of with scattered conditionals?
|
||||
|
||||
Always frame simplifications as *structural refactors*, not style preferences. "Extract this into a separate module" is a simplification if it separates concerns. "Rename this variable" is not.
|
||||
|
||||
## 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.
|
||||
|
||||
### 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
|
||||
|
||||
**Mutable state density per handler:**
|
||||
Using `ts-morph`, count `let` declarations + reassignments inside async generator handlers or exported functions. Flag when count exceeds a threshold (suggest starting at 2).
|
||||
|
||||
**Circular event flow:**
|
||||
Static analysis rule: if a function subscribes to an emitter event E and the same function (or a closure it creates) calls `emitter.emit(E)`, flag it. Implementable as a custom ESLint rule or `ts-morph` script.
|
||||
|
||||
**Module concern mixing:**
|
||||
Track the "kind" of each export per module: pure function, async generator, class with state, side-effecting function. If a module mixes kinds (e.g., pure function + stateful generator), flag it for human review.
|
||||
|
||||
**Lifecycle nesting depth:**
|
||||
Count `new AbortController()` or `new EventEmitter()` instances created inside handler bodies (as opposed to framework-provided). Flag depth > 0 in simple handlers, depth > 1 anywhere.
|
||||
|
||||
**Dependency direction enforcement:**
|
||||
Using `dependency-cruiser` or similar, enforce that certain modules (like pure data-resolution modules) never import from stateful or lifecycle-managing modules.
|
||||
|
||||
### For any language
|
||||
|
||||
**Closure-over-mutable-state detector:**
|
||||
Any inner function that captures a `let`/`var`/mutable ref from its enclosing scope. Language-specific tooling varies, but the pattern is universal.
|
||||
|
||||
**Fan-in/fan-out per module:**
|
||||
Track how many other modules import from this module (fan-out) and how many this module imports (fan-in). A module with high fan-in that also has mutable state is a high-risk complecting node — changes to its state affect everything that depends on it.
|
||||
|
||||
**Handler complexity trend:**
|
||||
Track per-handler metrics over time (across commits): mutable binding count, closure depth, number of distinct event channels touched, lifecycle nesting depth. Surface as a dashboard or CI annotation. The goal is not to block PRs but to make structural drift visible.
|
||||
|
||||
## Output Format
|
||||
|
||||
Structure your evaluation as:
|
||||
|
||||
1. **Concerns identified** — Name the distinct concerns in the code.
|
||||
2. **What's simple** — Acknowledge what the code does well structurally. Not everything is a problem.
|
||||
3. **Complecting found** — Specific findings from Layers 2–3, with line references or code quotes.
|
||||
4. **Severity** — For each finding: blast radius, change friction, reasoning load.
|
||||
5. **Suggested simplifications** — Concrete structural alternatives, not style nits.
|
||||
6. **Fitness functions** — If appropriate, propose automatable checks the team could add.
|
||||
|
||||
Keep the tone constructive. Hickey's framework is about making better design choices, not about shaming code that works. Code that complects may still be the right tradeoff given time constraints — but the tradeoff should be conscious, not accidental.
|
||||
|
||||
## Important Caveats
|
||||
|
||||
- **Simple ≠ easy, simple ≠ short.** A longer function that separates concerns cleanly is simpler than a short one that braids them. Never recommend reducing line count as a simplification.
|
||||
- **Simple ≠ familiar.** Unfamiliar constructs (async iterables, algebraic data types, persistent data structures) may be simple even if the team doesn't know them yet. Familiarity can be acquired; simplicity must be designed.
|
||||
- **Never assume complexity is "essential" without proof.** Do not hand-wave findings as "essential complexity" or "acceptable given the problem domain." Before labeling any complecting as essential, you MUST first design what the Hickey-simplified version would look like — write it out concretely. Assume the existing code patterns came from someone who didn't know better. Reason from first principles: what would this look like if we started fresh with simplicity as the goal? Only after you have a concrete simplified alternative in hand can you evaluate whether the current approach is actually justified. If the simplified version is viable, the complexity was accidental. If it's genuinely not viable, explain exactly why — "the problem requires X" is not sufficient; show that the simplified design fails and why.
|
||||
- **Tests are necessary but not sufficient.** A passing test suite is evidence that the code works *now*. It is not evidence that the code is simple. Both matter. Don't let the grey-box model's focus on evidence-based review obscure the need for structural evaluation.
|
||||
- **This is not a replacement for functional review.** Simplicity analysis complements correctness review; it doesn't replace it. A perfectly simple program that does the wrong thing is useless.
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -1,533 +0,0 @@
|
|||
#!/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?.() ?? "<anonymous>";
|
||||
}
|
||||
// arrow / expression assigned to a variable
|
||||
const parent = node.getParent();
|
||||
if (parent && Node.isVariableDeclaration(parent)) {
|
||||
return parent.getName();
|
||||
}
|
||||
return "<anonymous>";
|
||||
};
|
||||
|
||||
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<string, number>();
|
||||
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<string>();
|
||||
const emittedEvents = new Set<string>();
|
||||
|
||||
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,
|
||||
);
|
||||
Loading…
Add table
Add a link
Reference in a new issue