feat(shared-skills): add user skill sources

This commit is contained in:
YeonGyu-Kim
2026-05-26 20:28:24 +09:00
parent 74ece8181a
commit 3ccbe2fd4f
94 changed files with 24640 additions and 0 deletions
@@ -0,0 +1,173 @@
#!/usr/bin/env bash
# No-excuse rule checker for Go files.
# Mirrors the philosophy of python-programmer / typescript-programmer / rust-programmer scripts:
# only rules that can be enforced via pure text matching live here.
# Everything semantic is on golangci-lint + nilaway + go test -race.
set -euo pipefail
if [ $# -eq 0 ]; then
echo "Usage: $0 <file.go> [file.go ...]" >&2
exit 2
fi
violations=0
report() {
local file="$1"
local line="$2"
local rule="$3"
local detail="$4"
echo "::error file=${file},line=${line}::[${rule}] ${detail}" >&2
violations=$((violations + 1))
}
is_test_file() {
case "$1" in
*_test.go) return 0 ;;
esac
return 1
}
is_generated_file() {
local file="$1"
case "$file" in
*.pb.go|*.connect.go|*.gen.go) return 0 ;;
*_string.go) return 0 ;;
esac
# First-line check for "Code generated ... DO NOT EDIT." (the official marker)
if [ -f "$file" ]; then
head -n 5 "$file" 2>/dev/null | grep -qE "^// Code generated .* DO NOT EDIT\.$" && return 0
fi
return 1
}
for file in "$@"; do
[ -f "$file" ] || continue
case "$file" in
*.go) ;;
*) continue ;;
esac
if is_generated_file "$file"; then
continue
fi
in_test=0
if is_test_file "$file"; then
in_test=1
fi
line_no=0
while IFS= read -r raw_line || [ -n "$raw_line" ]; do
line_no=$((line_no + 1))
line="$raw_line"
# Strip line comments before pattern checks
# (block comments are not handled — keep the rules robust to that limitation).
code_only="${line%%//*}"
# ── Exemption marker: // no-excuse-ok: <reason> ──────────────────
if [[ "$line" =~ //[[:space:]]*no-excuse-ok:[[:space:]]*.+ ]]; then
continue
fi
# ── Rule: no `_ = err` (silent error swallow) ────────────────────
# The errcheck linter catches most of these but the `_ = err` form
# specifically slips through if used with named returns.
if [[ "$code_only" =~ ^[[:space:]]*_[[:space:]]*=[[:space:]]*err[[:space:]]*$ ]] ||
[[ "$code_only" =~ ^[[:space:]]*_[[:space:]]*=[[:space:]]*err[[:space:]]*[^a-zA-Z0-9_].*$ ]]; then
if [ "$in_test" -eq 0 ]; then
report "$file" "$line_no" "silent-err" "discarding err with '_ = err' — handle the error"
fi
fi
# ── Rule: no `panic(` in non-test, non-main code ─────────────────
# Allowed in main(), allowed in tests, allowed with explicit marker.
if [[ "$code_only" =~ [^a-zA-Z0-9_]panic\( ]] || [[ "$code_only" =~ ^[[:space:]]*panic\( ]]; then
if [ "$in_test" -eq 0 ]; then
# main package main.go is the one exception
pkg_line=$(head -n 5 "$file" 2>/dev/null | grep -m1 "^package ")
if [[ "$pkg_line" != "package main" ]]; then
report "$file" "$line_no" "panic-in-lib" "panic outside main/test — return error instead"
fi
fi
fi
# ── Rule: no `log.Fatal` / `log.Panic` in library code ───────────
if [[ "$code_only" =~ log\.(Fatal|Panic)(f|ln)?\( ]]; then
if [ "$in_test" -eq 0 ]; then
pkg_line=$(head -n 5 "$file" 2>/dev/null | grep -m1 "^package ")
if [[ "$pkg_line" != "package main" ]]; then
report "$file" "$line_no" "log-fatal-in-lib" "log.Fatal/Panic outside main — return error"
fi
fi
fi
# ── Rule: no init() functions ─────────────────────────────────
# init() ruins testability and creates hidden global state.
# Exception: //go:build constraint files and generated code.
if [[ "$code_only" =~ ^func[[:space:]]+init\(\)[[:space:]]*\{ ]]; then
report "$file" "$line_no" "no-init-func" "init() ruins testability — use explicit constructor"
fi
# ── Rule: no `time.Sleep` in non-test code ──────────────────────
if [[ "$code_only" =~ time\.Sleep\( ]]; then
if [ "$in_test" -eq 0 ]; then
report "$file" "$line_no" "time-sleep" "time.Sleep in production code — use ticker/timer with ctx"
fi
fi
# ── Rule: no `context.Background()` inside functions (only in main/init/test) ──
if [[ "$code_only" =~ context\.Background\(\) ]]; then
if [ "$in_test" -eq 0 ]; then
pkg_line=$(head -n 5 "$file" 2>/dev/null | grep -m1 "^package ")
if [[ "$pkg_line" != "package main" ]]; then
report "$file" "$line_no" "ctx-background-in-lib" "context.Background() outside main — propagate ctx as parameter"
fi
fi
fi
# ── Rule: no `interface{}` (use `any`, the alias from Go 1.18+) ──
if [[ "$code_only" =~ interface\{\} ]]; then
report "$file" "$line_no" "old-interface-empty" "use 'any' instead of 'interface{}' (Go 1.18+)"
fi
# ── Rule: no bare `fmt.Println` for logging (use slog) ───────────
# Acceptable in main.go (CLI output) and tests. Reject in libraries.
if [[ "$code_only" =~ fmt\.(Print|Println|Printf)\( ]]; then
if [ "$in_test" -eq 0 ]; then
pkg_line=$(head -n 5 "$file" 2>/dev/null | grep -m1 "^package ")
if [[ "$pkg_line" != "package main" ]]; then
report "$file" "$line_no" "fmt-print-in-lib" "fmt.Print* in library — use slog for structured logs"
fi
fi
fi
# ── Rule: no `nolint` directive without reason ───────────────────
if [[ "$line" =~ //nolint(:|$| ) ]]; then
if ! [[ "$line" =~ //nolint:[a-zA-Z0-9_,-]+[[:space:]]+//[[:space:]]*[^[:space:]] ]]; then
report "$file" "$line_no" "nolint-no-reason" "//nolint requires a // reason after the linter list"
fi
fi
# ── Rule: no TODO / FIXME without an issue link or owner ─────────
# Check the full line — TODOs live in comments, which $code_only has stripped.
if echo "$line" | grep -qE '(TODO|FIXME|XXX)([[:space:]]|:)'; then
if ! echo "$line" | grep -qE '(TODO|FIXME|XXX).*[(@[]'; then
report "$file" "$line_no" "todo-no-owner" "TODO/FIXME requires (#issue) or @owner attribution"
fi
fi
done < "$file"
done
if [ "$violations" -gt 0 ]; then
echo "" >&2
echo "go-programmer: $violations violation(s). Run also:" >&2
echo " gofumpt -l ." >&2
echo " golangci-lint run --timeout 5m ./..." >&2
echo " nilaway ./..." >&2
echo " go test -race -shuffle=on -count=1 ./..." >&2
exit 1
fi
echo "go-programmer: no-excuse rules passed for $# file(s)."
@@ -0,0 +1,138 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "typer",
# "rich",
# ]
# ///
# ─── How to run ───
# 1. Install uv (if not installed):
# curl -LsSf https://astral.sh/uv/install.sh | sh
# 2. Run:
# uv run new-project.py myservice
# uv run new-project.py myservice --module github.com/your-org/myservice
# ──────────────────
#
# Creates a new Go project with the canonical strict layout:
# - go.mod with go 1.23
# - .golangci.yml (v2, strict bundle)
# - Taskfile.yml (fmt + lint + test + build)
# - cmd/server/main.go entrypoint
# - internal/{cmd,config,api,domain,obs} skeletons
# - .github/workflows/ci.yml
#
# Templates live in ./templates/ — keep this script under 250 pure LOC.
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
from string import Template
import typer
from rich.console import Console
console = Console(stderr=True)
TEMPLATES_DIR = Path(__file__).parent / "templates"
def _render(template_file: str, **subs: str) -> str:
"""Read a template file and apply $placeholder substitutions.
Uses string.Template ($name) so Go/YAML curly braces stay literal.
"""
raw = (TEMPLATES_DIR / template_file).read_text()
if not subs:
return raw
return Template(raw).substitute(**subs)
# (template-file → relative output path; is_format = .format() is run)
FILES: list[tuple[str, str, bool]] = [
(".golangci.yml", ".golangci.yml", False),
("Taskfile.yml", "Taskfile.yml", False),
(".editorconfig", ".editorconfig", False),
("gitignore", ".gitignore", False),
("ci.yml", ".github/workflows/ci.yml", False),
("run.go", "internal/cmd/run.go", False),
("config.go", "internal/config/config.go", False),
("main.go.tmpl", "cmd/server/main.go", True),
("AGENTS.md.tmpl", "AGENTS.md", True),
("README.md.tmpl", "README.md", True),
]
def _init_go_module(project_dir: Path, module: str) -> None:
try:
subprocess.run(
["go", "mod", "init", module],
cwd=project_dir,
check=True,
capture_output=True,
)
console.print(f" [dim]ran[/] go mod init {module}")
except (subprocess.CalledProcessError, FileNotFoundError) as e:
console.print(f" [yellow]warn[/] go mod init failed ({e}); writing fallback go.mod")
(project_dir / "go.mod").write_text(f"module {module}\n\ngo 1.23\n")
def _create_layout(project_dir: Path) -> None:
"""Create the canonical internal/ tree."""
subdirs = [
"cmd/server",
"internal/cmd",
"internal/config",
"internal/api",
"internal/domain",
"internal/obs",
".github/workflows",
]
for sd in subdirs:
(project_dir / sd).mkdir(parents=True)
def _write_files(project_dir: Path, name: str, module: str, purpose: str) -> None:
"""Render every template into the project tree."""
for tmpl_name, out_rel, is_format in FILES:
subs = (
{"name": name, "module": module, "short_purpose": purpose}
if is_format
else {}
)
content = _render(tmpl_name, **subs)
out_path = project_dir / out_rel
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(content)
console.print(f" [dim]wrote[/] {out_rel}")
def main(
name: str,
path: str = typer.Option(".", help="Parent dir"),
module: str = typer.Option("", help="Go module path; default: <name>"),
purpose: str = typer.Option("HTTP", help="Short purpose for AGENTS.md"),
) -> None:
"""Scaffold a new Go project with the strict toolchain."""
project_dir = Path(path) / name
if project_dir.exists():
console.print(f"[red]✗[/red] {project_dir} already exists")
sys.exit(1)
module_path = module or name
project_dir.mkdir(parents=True)
_create_layout(project_dir)
_init_go_module(project_dir, module_path)
_write_files(project_dir, name, module_path, purpose)
console.print(f"\n[bold green]Done![/] cd {project_dir}")
console.print(" go get github.com/caarlos0/env/v11")
console.print(" task # fmt + lint + test")
if __name__ == "__main__":
typer.run(main)
@@ -0,0 +1,13 @@
root = true
[*]
indent_style = tab
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.{yml,yaml,json,md}]
indent_style = space
indent_size = 2
@@ -0,0 +1,95 @@
version: "2"
run:
timeout: 5m
tests: true
modules-download-mode: readonly
linters:
default: none
enable:
- govet
- staticcheck
- errcheck
- errorlint
- nilerr
- nilnil
- bodyclose
- rowserrcheck
- sqlclosecheck
- contextcheck
- fatcontext
- copyloopvar
- intrange
- usetesting
- testifylint
- gofumpt
- goimports
- whitespace
- misspell
- unconvert
- unparam
- ineffassign
- dupword
- gocognit
- gocyclo
- funlen
- lll
- nestif
- dupl
- revive
- unused
- exhaustive
- gosec
- sloglint
- perfsprint
- prealloc
- makezero
linters-settings:
errcheck:
check-type-assertions: true
check-blank: true
errorlint:
errorf: true
asserts: true
comparison: true
gocognit:
min-complexity: 25
gocyclo:
min-complexity: 15
funlen:
lines: 90
statements: 60
lll:
line-length: 120
tab-width: 4
nestif:
min-complexity: 4
exhaustive:
default-signifies-exhaustive: false
check: [switch, map]
sloglint:
no-mixed-args: true
attr-only: true
no-global: all
context: scope
static-msg: true
no-raw-keys: true
key-naming-case: snake
formatters:
enable:
- gofumpt
- goimports
issues:
max-issues-per-linter: 0
max-same-issues: 0
exclude-rules:
- path: _test\\.go
linters: [funlen, lll, dupl, gosec]
- path: \\.pb\\.go$
linters: [all]
- path: \\.connect\\.go$
linters: [all]
@@ -0,0 +1,24 @@
# AGENTS.md
Go 1.23+ $short_purpose service.
## Commands
- `task` — fmt + lint + test
- `task build` — produce ./bin/server
- `task ci` — full CI pipeline locally
## Architecture
- `cmd/server/main.go` — entrypoint, ≤50 LOC
- `internal/cmd/` — root command, signal wiring
- `internal/api/` — HTTP handlers + middleware (gin)
- `internal/domain/` — smart-constructor types, no I/O
- `internal/store/` — DB layer (sqlc-generated, never hand-edited)
- `internal/config/` — env-driven Config
- `internal/obs/` — slog setup, observability
## Conventions
- `slog` for all logs; never `log.*`, never `fmt.Println` in libs
- `context.Context` first arg for every public function with I/O
- Errors wrapped with `%w`; check with `errors.Is/As`
- 250 pure LOC ceiling per file
- Tests follow Given/When/Then; less mock the better
@@ -0,0 +1,12 @@
# $name
Bootstrapped with the `programming` skill's Go scaffold.
## Run
```bash
task # fmt + lint + test
task run # build + run server
```
See `AGENTS.md` for architecture conventions.
@@ -0,0 +1,40 @@
version: '3'
vars:
BINARY: server
PKG: ./cmd/server
tasks:
default:
deps: [fmt, lint, test]
fmt:
cmds:
- gofumpt -w .
- goimports -w -local "$(go list -m)" .
lint:
cmds:
- golangci-lint run --timeout 5m ./...
- nilaway ./... || true
test:
cmds:
- go test -race -shuffle=on -count=1 ./...
test-cover:
cmds:
- go test -race -shuffle=on -count=1 -coverprofile=coverage.out ./...
- go tool cover -html=coverage.out -o coverage.html
build:
cmds:
- go build -trimpath -ldflags="-s -w" -o bin/{{.BINARY}} {{.PKG}}
run:
deps: [build]
cmds:
- ./bin/{{.BINARY}}
ci:
deps: [fmt, lint, test, build]
@@ -0,0 +1,37 @@
name: ci
on:
pull_request:
push:
branches: [main]
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.23'
cache: true
- name: Install tools
run: |
go install mvdan.cc/gofumpt@latest
go install github.com/golangci/golangci-lint/cmd/golangci-lint@v2.0.0
go install go.uber.org/nilaway/cmd/nilaway@latest
go install github.com/go-task/task/v3/cmd/task@latest
- name: Format check
run: gofumpt -l . | (! grep .)
- name: Lint
run: golangci-lint run --timeout 5m ./...
- name: Nilaway
run: nilaway ./... || true
- name: Test
run: go test -race -shuffle=on -count=1 ./...
- name: Build
run: go build -trimpath ./...
@@ -0,0 +1,24 @@
// Package config loads typed config from env.
package config
import (
"time"
"github.com/caarlos0/env/v11"
)
type Config struct {
Host string `env:"HOST" envDefault:"0.0.0.0"`
Port int `env:"PORT" envDefault:"8080"`
ShutdownTimeout time.Duration `env:"SHUTDOWN_TIMEOUT" envDefault:"20s"`
LogLevel string `env:"LOG_LEVEL" envDefault:"info"`
LogFormat string `env:"LOG_FORMAT" envDefault:"json"`
}
func Load() (Config, error) {
var cfg Config
if err := env.Parse(&cfg); err != nil {
return Config{}, err
}
return cfg, nil
}
@@ -0,0 +1,15 @@
bin/
coverage.out
coverage.html
*.test
*.prof
.idea/
.vscode/
*.swp
.env
.env.local
*.pem
*.key
@@ -0,0 +1,22 @@
package main
import (
"context"
"log/slog"
"os"
"os/signal"
"syscall"
"$module/internal/cmd"
)
func main() {
ctx, stop := signal.NotifyContext(context.Background(),
syscall.SIGINT, syscall.SIGTERM)
defer stop()
if err := cmd.Execute(ctx); err != nil {
slog.Error("fatal", slog.Any("err", err))
os.Exit(1)
}
}
@@ -0,0 +1,15 @@
// Package cmd wires the root command and subcommands.
package cmd
import (
"context"
"log/slog"
"os"
)
// Execute runs the root command. Wire cobra/subcommands here.
func Execute(ctx context.Context) error {
slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
slog.InfoContext(ctx, "starting")
return nil
}
@@ -0,0 +1,687 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
# noqa: SIZE_OK — single self-contained checker, splitting adds import ceremony for no readability gain
"""Check Python files for no-excuse violations.
The python-programmer skill enforces these rules. Run after editing.
Rules:
cast-any - cast(Any, ...) / cast(typing.Any, ...) / typing.cast(Any, ...)
type-ignore - `# type: ignore` comments (any variant)
pyright-ignore - `# pyright: ignore` comments (any variant)
bare-except - `except:` with no class
silent-except - `except X: pass` or `except X: ...` (single statement)
no-asyncio - `import asyncio` / `from asyncio import ...`
Opt out per import line: trailing `# noqa: ANYIO_OK`
no-pandas - `import pandas` / `from pandas import ...`
Opt out per import line: trailing `# noqa: PANDAS_OK`
mutable-dataclass - @dataclass without frozen=True
Opt out: trailing `# noqa: MUTABLE_OK`
missing-slots - @dataclass without slots=True
Opt out: trailing `# noqa: SLOTS_OK`
raw-dict-return - function returns bare `dict` type
Opt out: trailing `# noqa: DICT_OK`
missing-assert-never - match statement without assert_never in default case
Opt out: `# noqa: MATCH_OK` on the match line
generic-exception - raise ValueError/TypeError/RuntimeError with bare string
Opt out: trailing `# noqa: GENERIC_ERR_OK`
no-object - `object` used as type annotation (param, return, variable)
Opt out: trailing `# noqa: OBJECT_OK`
if-elif-on-variant - isinstance/enum-comparison if/elif chain (should be match/case)
Opt out: trailing `# noqa: IF_VARIANT_OK`
oversized-module - file exceeds 250 pure LOC (non-blank, non-comment)
Opt out: `# noqa: SIZE_OK` in first 10 lines
broad-except - `except Exception` / `except BaseException` (too broad)
Opt out: trailing `# noqa: BROAD_EXCEPT_OK`
Usage:
check-no-excuse-rules.py <file-or-dir>...
Exit codes:
0 - no violations
1 - one or more violations
2 - input error (path missing, etc.)
"""
from __future__ import annotations
import ast
import io
import re
import sys
import tokenize
from collections.abc import Iterable
from dataclasses import dataclass
from pathlib import Path
EXCLUDED_DIRS = frozenset({
".git", ".hg", ".svn", ".venv", "venv", "env", ".env",
"__pycache__", ".tox", ".nox", "dist", "build", ".eggs",
".ruff_cache", ".mypy_cache", ".pytest_cache", ".basedpyright",
"node_modules",
})
SUPPRESSION_RE = re.compile(r"#\s*(type|pyright)\s*:\s*ignore\b")
ANYIO_OK_RE = re.compile(r"#\s*noqa:\s*ANYIO_OK\b")
PANDAS_OK_RE = re.compile(r"#\s*noqa:\s*PANDAS_OK\b")
BANNED_IMPORTS: dict[str, tuple[str, re.Pattern[str], str]] = {
"asyncio": (
"no-asyncio",
ANYIO_OK_RE,
"import asyncio - use anyio (opt out: trailing `# noqa: ANYIO_OK`)",
),
"pandas": (
"no-pandas",
PANDAS_OK_RE,
"import pandas - use polars (opt out: trailing `# noqa: PANDAS_OK`)",
),
}
# Opt-out patterns for new Rust-like rules
MUTABLE_OK_RE: re.Pattern[str] = re.compile(r"#\s*noqa:\s*MUTABLE_OK")
SLOTS_OK_RE: re.Pattern[str] = re.compile(r"#\s*noqa:\s*SLOTS_OK")
DICT_OK_RE: re.Pattern[str] = re.compile(r"#\s*noqa:\s*DICT_OK")
MATCH_OK_RE: re.Pattern[str] = re.compile(r"#\s*noqa:\s*MATCH_OK")
GENERIC_ERR_OK_RE: re.Pattern[str] = re.compile(r"#\s*noqa:\s*GENERIC_ERR_OK")
OBJECT_OK_RE: re.Pattern[str] = re.compile(r"#\s*noqa:\s*OBJECT_OK")
IF_VARIANT_OK_RE: re.Pattern[str] = re.compile(r"#\s*noqa:\s*IF_VARIANT_OK")
SIZE_OK_RE: re.Pattern[str] = re.compile(r"#\s*noqa:\s*SIZE_OK")
BROAD_EXCEPT_OK_RE: re.Pattern[str] = re.compile(r"#\s*noqa:\s*BROAD_EXCEPT_OK")
PURE_LOC_LIMIT: int = 250
@dataclass(frozen=True, slots=True)
class Violation:
rule: str
file: Path
line: int
col: int
message: str
def render(self) -> str:
return f"{self.file}:{self.line}:{self.col}: [{self.rule}] {self.message}"
def discover_files(inputs: Iterable[Path]) -> list[Path]:
seen: set[Path] = set()
for raw in inputs:
path = raw.resolve()
if not path.exists():
print(f"check-no-excuse-rules: input does not exist: {path}", file=sys.stderr)
sys.exit(2)
if path.is_file():
if path.suffix == ".py":
seen.add(path)
continue
for child in path.rglob("*.py"):
if any(part in EXCLUDED_DIRS for part in child.parts):
continue
seen.add(child)
return sorted(seen)
def is_any_node(node: ast.AST) -> bool:
if isinstance(node, ast.Name):
return node.id == "Any"
if isinstance(node, ast.Attribute):
return node.attr == "Any"
return False
def is_cast_callable(node: ast.AST) -> bool:
if isinstance(node, ast.Name):
return node.id == "cast"
if isinstance(node, ast.Attribute):
return node.attr == "cast"
return False
def find_node_violations(tree: ast.AST, file: Path) -> list[Violation]:
violations: list[Violation] = []
for node in ast.walk(tree):
if (
isinstance(node, ast.Call)
and is_cast_callable(node.func)
and node.args
and is_any_node(node.args[0])
):
violations.append(Violation(
rule="cast-any",
file=file,
line=node.lineno,
col=node.col_offset + 1,
message="cast(Any, ...) - narrow with isinstance/TypeGuard or use a Protocol/TypedDict",
))
if isinstance(node, ast.ExceptHandler):
if node.type is None:
violations.append(Violation(
rule="bare-except",
file=file,
line=node.lineno,
col=node.col_offset + 1,
message="bare `except:` - catch the narrowest exception you mean",
))
if len(node.body) != 1:
continue
body = node.body[0]
if isinstance(body, ast.Pass):
violations.append(Violation(
rule="silent-except",
file=file,
line=body.lineno,
col=body.col_offset + 1,
message="silent `except: pass` - log, re-raise, or actually handle the error",
))
elif (
isinstance(body, ast.Expr)
and isinstance(body.value, ast.Constant)
and body.value.value is Ellipsis
):
violations.append(Violation(
rule="silent-except",
file=file,
line=body.lineno,
col=body.col_offset + 1,
message="silent `except: ...` - log, re-raise, or actually handle the error",
))
return violations
def find_import_violations(tree: ast.AST, source_lines: list[str], file: Path) -> list[Violation]:
violations: list[Violation] = []
def line_text(lineno: int) -> str:
index = lineno - 1
return source_lines[index] if 0 <= index < len(source_lines) else ""
for node in ast.walk(tree):
if isinstance(node, ast.Import): # noqa: IF_VARIANT_OK — filtering walk, not closed union
for alias in node.names:
top = alias.name.split(".")[0]
if top not in BANNED_IMPORTS:
continue
rule, opt_re, message = BANNED_IMPORTS[top]
if opt_re.search(line_text(node.lineno)):
continue
violations.append(Violation(
rule=rule,
file=file,
line=node.lineno,
col=node.col_offset + 1,
message=message,
))
elif isinstance(node, ast.ImportFrom):
top = (node.module or "").split(".")[0]
if top not in BANNED_IMPORTS:
continue
rule, opt_re, message = BANNED_IMPORTS[top]
if opt_re.search(line_text(node.lineno)):
continue
violations.append(Violation(
rule=rule,
file=file,
line=node.lineno,
col=node.col_offset + 1,
message=message,
))
return violations
def find_comment_violations(source: str, file: Path) -> list[Violation]:
"""Use tokenize so we don't false-match `# type: ignore` inside string literals."""
violations: list[Violation] = []
try:
tokens = list(tokenize.generate_tokens(io.StringIO(source).readline))
except tokenize.TokenError as exc:
print(f"check-no-excuse-rules: tokenize failed for {file}: {exc}", file=sys.stderr)
return violations
for tok in tokens:
if tok.type != tokenize.COMMENT:
continue
match = SUPPRESSION_RE.search(tok.string)
if not match:
continue
kind = match.group(1)
rule = "type-ignore" if kind == "type" else "pyright-ignore"
violations.append(Violation(
rule=rule,
file=file,
line=tok.start[0],
col=tok.start[1] + match.start() + 1,
message=f"`# {kind}: ignore` - fix the underlying type instead",
))
return violations
# ─────────────────────────────────────────────────────────────────
# Rust-like pattern checks
# ─────────────────────────────────────────────────────────────────
def _has_keyword(decorator_node: ast.Call, keyword: str) -> bool | None:
"""Check if a decorator call has a specific keyword argument.
Returns True if keyword is True, False if keyword is False or absent, None if not a Call.
"""
for kw in decorator_node.keywords:
if kw.arg == keyword and isinstance(kw.value, ast.Constant):
return bool(kw.value.value)
return False
def _is_dataclass_decorator(node: ast.expr) -> tuple[bool, ast.Call | None]:
"""Return (is_dataclass, call_node_or_None)."""
if isinstance(node, ast.Name) and node.id == "dataclass":
return True, None
if isinstance(node, ast.Attribute) and node.attr == "dataclass":
return True, None
if isinstance(node, ast.Call):
inner, _ = _is_dataclass_decorator(node.func)
if inner:
return True, node
return False, None
def find_dataclass_violations(
tree: ast.Module, source_lines: list[str], file: Path,
) -> list[Violation]:
"""Check @dataclass decorators for frozen=True and slots=True."""
violations: list[Violation] = []
for node in ast.walk(tree):
if not isinstance(node, ast.ClassDef):
continue
for dec in node.decorator_list:
is_dc, call_node = _is_dataclass_decorator(dec)
if not is_dc:
continue
# Get the line of the decorator for opt-out check
dec_line = source_lines[dec.lineno - 1] if dec.lineno <= len(source_lines) else ""
if call_node is not None:
has_frozen = _has_keyword(call_node, "frozen")
has_slots = _has_keyword(call_node, "slots")
else:
# bare @dataclass with no arguments
has_frozen = False
has_slots = False
if not has_frozen and not MUTABLE_OK_RE.search(dec_line):
violations.append(Violation(
rule="mutable-dataclass",
file=file,
line=dec.lineno,
col=dec.col_offset + 1,
message=f"class {node.name}: @dataclass without frozen=True",
))
if not has_slots and not SLOTS_OK_RE.search(dec_line):
violations.append(Violation(
rule="missing-slots",
file=file,
line=dec.lineno,
col=dec.col_offset + 1,
message=f"class {node.name}: @dataclass without slots=True",
))
return violations
def find_dict_return_violations(
tree: ast.Module, source_lines: list[str], file: Path,
) -> list[Violation]:
"""Check for functions returning bare `dict` type."""
violations: list[Violation] = []
for node in ast.walk(tree):
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
ret = node.returns
if ret is None:
continue
# Check for bare `dict` return annotation
is_bare_dict = (
(isinstance(ret, ast.Name) and ret.id == "dict")
or (isinstance(ret, ast.Attribute) and ret.attr == "dict")
)
if not is_bare_dict:
continue
func_line = source_lines[node.lineno - 1] if node.lineno <= len(source_lines) else ""
if DICT_OK_RE.search(func_line):
continue
violations.append(Violation(
rule="raw-dict-return",
file=file,
line=node.lineno,
col=node.col_offset + 1,
message=f"`{node.name}` returns bare dict - use TypedDict/dataclass/Pydantic model",
))
return violations
def find_match_violations(
tree: ast.Module, source_lines: list[str], file: Path,
) -> list[Violation]:
"""Check match statements for assert_never in default case."""
violations: list[Violation] = []
for node in ast.walk(tree):
if not isinstance(node, ast.Match):
continue
match_line = source_lines[node.lineno - 1] if node.lineno <= len(source_lines) else ""
if MATCH_OK_RE.search(match_line):
continue
has_assert_never = False
for case in node.cases:
# Wildcard: `case _:` -> MatchAs(pattern=None, name=None)
# `case _ as x:` -> MatchAs(pattern=MatchAs(pattern=None, name=None), name="x")
pattern = case.pattern
is_wildcard = (
isinstance(pattern, ast.MatchAs)
and (
pattern.pattern is None
or (
isinstance(pattern.pattern, ast.MatchAs)
and pattern.pattern.pattern is None
and pattern.pattern.name is None
)
)
)
if not is_wildcard:
continue
# Check if body contains assert_never call
for stmt in case.body:
if isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Call):
func = stmt.value.func
if (
(isinstance(func, ast.Name) and func.id == "assert_never")
or (isinstance(func, ast.Attribute) and func.attr == "assert_never")
):
has_assert_never = True
break
if not has_assert_never:
violations.append(Violation(
rule="missing-assert-never",
file=file,
line=node.lineno,
col=node.col_offset + 1,
message="match without `case _: assert_never(x)` default",
))
return violations
def find_generic_exception_violations(
tree: ast.Module, source_lines: list[str], file: Path,
) -> list[Violation]:
"""Check for raise ValueError/TypeError/RuntimeError with bare string or f-string."""
GENERIC_EXCEPTIONS = {"ValueError", "TypeError", "RuntimeError", "KeyError"}
violations: list[Violation] = []
for node in ast.walk(tree):
if not isinstance(node, ast.Raise) or node.exc is None:
continue
exc = node.exc
# Match: raise SomeError("string literal")
if not isinstance(exc, ast.Call):
continue
func = exc.func
exc_name: str | None = None
if isinstance(func, ast.Name) and func.id in GENERIC_EXCEPTIONS:
exc_name = func.id
elif isinstance(func, ast.Attribute) and func.attr in GENERIC_EXCEPTIONS:
exc_name = func.attr
if exc_name is None:
continue
# Check if all arguments are string literals or f-strings
if not exc.args:
continue
all_str = all(
(isinstance(arg, ast.Constant) and isinstance(arg.value, str))
or isinstance(arg, ast.JoinedStr)
for arg in exc.args
)
if not all_str:
continue
raise_line = source_lines[node.lineno - 1] if node.lineno <= len(source_lines) else ""
if GENERIC_ERR_OK_RE.search(raise_line):
continue
violations.append(Violation(
rule="generic-exception",
file=file,
line=node.lineno,
col=node.col_offset + 1,
message=f"`raise {exc_name}(\"...\")` - define a typed error class instead",
))
return violations
def _is_isinstance_test(node: ast.expr) -> bool:
"""Check if node is an isinstance() call."""
return (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Name)
and node.func.id == "isinstance"
)
def _is_enum_comparison(node: ast.expr) -> bool:
"""Check if node is `x == Enum.VALUE` or `x is Enum.VALUE`."""
if isinstance(node, ast.Compare) and len(node.ops) == 1:
op = node.ops[0]
if isinstance(op, (ast.Eq, ast.Is)):
comparator = node.comparators[0]
# x == Enum.VALUE (attribute access on the right)
if isinstance(comparator, ast.Attribute):
return True
# Enum.VALUE == x (attribute access on the left)
if isinstance(node.left, ast.Attribute):
return True
return False
def find_object_annotation_violations(
tree: ast.Module, source_lines: list[str], file: Path,
) -> list[Violation]:
"""Check for `object` used as a type annotation."""
violations: list[Violation] = []
def _check_annotation(ann: ast.expr | None) -> None:
if ann is None:
return
for child in ast.walk(ann):
if isinstance(child, ast.Name) and child.id == "object":
line = source_lines[child.lineno - 1] if child.lineno <= len(source_lines) else ""
if OBJECT_OK_RE.search(line):
return
violations.append(Violation(
rule="no-object",
file=file,
line=child.lineno,
col=child.col_offset + 1,
message="`object` as type annotation \u2014 use Protocol, TypeVar, or union",
))
for node in ast.walk(tree):
match node: # noqa: MATCH_OK — filtering walk, not discriminating a closed union
case ast.FunctionDef() | ast.AsyncFunctionDef():
all_args = (
node.args.args
+ node.args.posonlyargs
+ node.args.kwonlyargs
)
for arg in all_args:
_check_annotation(arg.annotation)
if node.args.vararg:
_check_annotation(node.args.vararg.annotation)
if node.args.kwarg:
_check_annotation(node.args.kwarg.annotation)
_check_annotation(node.returns)
case ast.AnnAssign():
_check_annotation(node.annotation)
return violations
def find_if_elif_variant_violations(
tree: ast.Module, source_lines: list[str], file: Path,
) -> list[Violation]:
"""Check for if/elif chains on isinstance or enum comparison."""
violations: list[Violation] = []
for node in ast.walk(tree):
if not isinstance(node, ast.If):
continue
line = source_lines[node.lineno - 1] if node.lineno <= len(source_lines) else ""
if IF_VARIANT_OK_RE.search(line):
continue
is_variant_test = _is_isinstance_test(node.test) or _is_enum_comparison(node.test)
if not is_variant_test:
continue
# Must have at least one elif that is also a variant test
orelse = node.orelse
while orelse and len(orelse) == 1 and isinstance(orelse[0], ast.If):
elif_node = orelse[0]
if _is_isinstance_test(elif_node.test) or _is_enum_comparison(elif_node.test):
violations.append(Violation(
rule="if-elif-on-variant",
file=file,
line=node.lineno,
col=node.col_offset + 1,
message="isinstance/enum if/elif chain \u2014 use match/case + assert_never",
))
break
orelse = elif_node.orelse
return violations
def find_broad_except_violations(
tree: ast.Module, source_lines: list[str], file: Path,
) -> list[Violation]:
"""Check for except Exception / except BaseException (too broad)."""
BROAD_EXCEPTIONS = {"Exception", "BaseException"}
violations: list[Violation] = []
for node in ast.walk(tree):
if not isinstance(node, ast.ExceptHandler):
continue
if node.type is None:
continue # already caught by bare-except
exc_name: str | None = None
if isinstance(node.type, ast.Name) and node.type.id in BROAD_EXCEPTIONS:
exc_name = node.type.id
elif isinstance(node.type, ast.Attribute) and node.type.attr in BROAD_EXCEPTIONS:
exc_name = node.type.attr
if exc_name is None:
continue
line = source_lines[node.lineno - 1] if node.lineno <= len(source_lines) else ""
if BROAD_EXCEPT_OK_RE.search(line):
continue
violations.append(Violation(
rule="broad-except",
file=file,
line=node.lineno,
col=node.col_offset + 1,
message=f"`except {exc_name}` is too broad \u2014 catch the specific exception you expect",
))
return violations
def find_oversized_module_violations(
source_lines: list[str], file: Path,
) -> list[Violation]:
"""Check if file exceeds 250 pure LOC (non-blank, non-comment)."""
# File-level opt-out in first 10 lines (shebang + script metadata can push it down)
for line in source_lines[:10]:
if SIZE_OK_RE.search(line):
return []
pure_loc = sum(
1 for line in source_lines
if line.strip() and not line.strip().startswith("#")
)
if pure_loc > PURE_LOC_LIMIT:
return [Violation(
rule="oversized-module",
file=file,
line=1,
col=1,
message=f"{pure_loc} pure LOC (limit: {PURE_LOC_LIMIT}) \u2014 split by responsibility",
)]
return []
def check_file(file: Path) -> list[Violation]:
source = file.read_text(encoding="utf-8")
try:
tree = ast.parse(source, filename=str(file))
except SyntaxError as exc:
return [Violation(
rule="syntax-error",
file=file,
line=exc.lineno or 1,
col=exc.offset or 1,
message=f"SyntaxError: {exc.msg}",
)]
source_lines = source.splitlines()
return [
*find_node_violations(tree, file),
*find_import_violations(tree, source_lines, file),
*find_comment_violations(source, file),
*find_dataclass_violations(tree, source_lines, file),
*find_dict_return_violations(tree, source_lines, file),
*find_match_violations(tree, source_lines, file),
*find_generic_exception_violations(tree, source_lines, file),
*find_object_annotation_violations(tree, source_lines, file),
*find_if_elif_variant_violations(tree, source_lines, file),
*find_oversized_module_violations(source_lines, file),
*find_broad_except_violations(tree, source_lines, file),
]
def main() -> int:
if len(sys.argv) < 2:
print("usage: check-no-excuse-rules.py <file-or-dir>...", file=sys.stderr)
return 2
files = discover_files(Path(arg) for arg in sys.argv[1:])
if not files:
print("check-no-excuse-rules: no .py files found", file=sys.stderr)
return 0
violations: list[Violation] = []
for file in files:
violations.extend(check_file(file))
if not violations:
print(f"no violations in {len(files)} file(s)")
return 0
for violation in violations:
print(violation.render(), file=sys.stderr)
print(
f"\n{len(violations)} violation(s) in {len(files)} file(s)",
file=sys.stderr,
)
return 1
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,172 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "typer",
# "rich",
# ]
# ///
# ─── How to run ───
# 1. Install uv (if not installed):
# curl -LsSf https://astral.sh/uv/install.sh | sh
# 2. Run:
# uv run new-project.py myproject
# uv run new-project.py myproject --path ./workspace
# uv run new-project.py myproject --lib # library (publishable)
# ──────────────────
"""Scaffold a new Python project with ultra-strict config from pyproject-strict.md.
Creates via `uv init`, then injects basedpyright + ruff ALL + pytest config.
"""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
import typer
from rich import print as rprint
# ── Strict tool config (from pyproject-strict.md) ──
TOOL_CONFIG = '''
[dependency-groups]
dev = [
"basedpyright>=1.21",
"ruff>=0.8",
"pytest>=8",
"pytest-cov>=5",
]
[tool.basedpyright]
typeCheckingMode = "all"
pythonVersion = "3.13"
reportMissingTypeStubs = false
reportUnknownMemberType = false
reportUnknownArgumentType = false
reportUnknownVariableType = false
reportUnknownLambdaType = false
reportUnknownParameterType = false
reportMissingParameterType = false
reportUnnecessaryIsInstance = false
reportUnusedCallResult = false
reportImplicitOverride = false
[tool.ruff]
target-version = "py313"
line-length = 120
[tool.ruff.lint]
select = ["ALL"]
ignore = [
"COM812", # trailing comma (conflicts with formatter)
"ISC001", # single-line string concat (conflicts with formatter)
"D1", # undocumented-public-* (too noisy early on)
"ANN101", # deprecated: self annotation
"ANN102", # deprecated: cls annotation
"S101", # assert used (pytest needs it)
"PLR2004", # magic-value-comparison (test data)
"FBT", # boolean-trap (too strict for CLIs)
"TD", # flake8-todos (noisy)
"FIX", # fixme (noisy)
]
[tool.ruff.lint.per-file-ignores]
"tests/**/*.py" = ["S101", "PLR2004", "SLF001", "D", "ARG", "ANN"]
[tool.ruff.lint.pydocstyle]
convention = "google"
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra --strict-markers --strict-config"
'''
GITIGNORE = """\
__pycache__/
*.py[cod]
*.so
.venv/
dist/
*.egg-info/
.coverage
htmlcov/
.basedpyright/
.ruff_cache/
"""
def main(
name: str = typer.Argument(help="Project name"),
path: Path = typer.Option(Path("."), "--path", "-p", help="Parent directory"),
lib: bool = typer.Option(False, "--lib", help="Create as publishable library (uv init --lib)"),
) -> None:
"""Create a new Python project with ultra-strict config."""
project_dir = path / name
if project_dir.exists():
rprint(f"[red]Error:[/red] {project_dir} already exists")
raise SystemExit(1)
# Run uv init
cmd = ["uv", "init", "--lib" if lib else "--app", str(project_dir)]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
rprint(f"[red]uv init failed:[/red] {result.stderr}")
raise SystemExit(1)
# Read existing pyproject.toml
pyproject_path = project_dir / "pyproject.toml"
content = pyproject_path.read_text()
# Remove the default [dependency-groups] if uv init created one
# (we'll replace it with our strict version)
lines = content.splitlines(keepends=True)
filtered: list[str] = []
skip = False
for line in lines:
if line.strip().startswith("[dependency-groups]"):
skip = True
continue
if skip and line.strip().startswith("["):
skip = False
if not skip:
filtered.append(line)
content = "".join(filtered).rstrip("\n") + "\n"
# Append strict tool config
content += TOOL_CONFIG
pyproject_path.write_text(content)
# Add dev dependencies
subprocess.run(
["uv", "add", "--dev", "basedpyright", "ruff", "pytest", "pytest-cov"],
cwd=project_dir,
capture_output=True,
)
# Create tests directory
tests_dir = project_dir / "tests"
tests_dir.mkdir(exist_ok=True)
(tests_dir / "__init__.py").touch()
# Overwrite .gitignore
(project_dir / ".gitignore").write_text(GITIGNORE)
# Create py.typed marker for libraries
if lib:
src_dir = project_dir / "src" / name.replace("-", "_")
if src_dir.exists():
(src_dir / "py.typed").touch()
rprint(f"[green]✓[/green] Created: [bold]{project_dir}[/bold]")
rprint(f" cd {name} && uv sync && uv run basedpyright . && uv run ruff check .")
if __name__ == "__main__":
typer.run(main)
@@ -0,0 +1,116 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "typer",
# "rich",
# ]
# ///
# ─── How to run ───
# 1. Install uv (if not installed):
# curl -LsSf https://astral.sh/uv/install.sh | sh
# 2. Run:
# uv run new-script.py my_tool
# uv run new-script.py my_tool --output ./scripts/my_tool.py
# uv run new-script.py my_tool --deps 'httpx2[http2,brotli,zstd]' --deps rich --deps polars
# uv run new-script.py my_tool --py 3.13
# ──────────────────
"""Generate a PEP 723 Python script with all boilerplate pre-filled.
Creates a new .py file with:
- uv shebang
- PEP 723 inline metadata (requires-python + dependencies)
- Mandatory "How to run" comment block
- from __future__ import annotations
- main() + if __name__ guard
By default writes to a temp directory and prints the path.
"""
from __future__ import annotations
import os
import stat
import sys
import tempfile
from pathlib import Path
import typer
from rich import print as rprint
TEMPLATE = '''\
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">={python_version}"
# dependencies = [
{deps_block}# ]
# ///
# ─── How to run ───
# 1. Install uv (if not installed):
# curl -LsSf https://astral.sh/uv/install.sh | sh
# 2. Run directly (no venv, no pip install needed):
# uv run {filename} {args_hint}
# 3. Or make executable and run:
# chmod +x {filename} && ./{filename}
# ──────────────────
from __future__ import annotations
def main() -> None:
"""TODO: implement."""
if __name__ == "__main__":
main()
'''
def main(
name: str = typer.Argument(help="Script name (without .py extension)"),
output: Path | None = typer.Option(None, "--output", "-o", help="Output path. Default: OS temp directory."),
deps: list[str] = typer.Option([], "--deps", "-d", help="Dependencies to include (repeat --deps for each)."),
py: str = typer.Option("3.13", "--py", help="Minimum Python version."),
) -> None:
"""Generate a new PEP 723 script with all boilerplate pre-filled."""
filename = f"{name}.py" if not name.endswith(".py") else name
stem = filename.removesuffix(".py")
if output is not None:
dest = Path(output)
else:
tmp_dir = Path(tempfile.gettempdir()) / "uv-scripts"
tmp_dir.mkdir(exist_ok=True)
dest = tmp_dir / filename
dep_list = deps or []
if dep_list:
deps_block = "".join(f'# "{d}",\n' for d in dep_list)
else:
deps_block = '# # add deps here, e.g.: "httpx2[http2,brotli,zstd]"\n'
content = TEMPLATE.format(
python_version=py,
deps_block=deps_block,
filename=filename,
args_hint="",
)
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(content)
# Make executable on Unix
if sys.platform != "win32":
st = dest.stat()
dest.chmod(st.st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
rprint(f"[green]✓[/green] Created: [bold]{dest}[/bold]")
rprint(f" Run: [cyan]uv run {dest}[/cyan]")
if __name__ == "__main__":
typer.run(main)
@@ -0,0 +1,296 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
#
# How to run:
# uv run --script check-no-excuse-rules.py src/lib.rs src/main.rs
# uv run --script check-no-excuse-rules.py src/ # recursively finds .rs files
# uv run --script check-no-excuse-rules.py . # entire tree
#
# No-excuse rule checker for Rust files — Python rewrite of check-no-excuse-rules.sh.
# Only rules enforceable via pure text matching live here.
# Everything semantic is on clippy + miri + nextest.
#
# Rules:
# unwrap .unwrap() outside tests without // SAFE-UNWRAP:
# expect .expect() outside tests without // SAFE-EXPECT:
# placeholder-macro todo!/unimplemented!/unreachable!/unreachable_unchecked! in committed code
# box-dyn-error Box<dyn Error> in non-test code
# lib-panic panic!() in library code
# unsafe-no-safety unsafe { without // SAFETY: in preceding 5 lines
# unjustified-clippy-allow #[allow(clippy::...)] without // CLIPPY-ALLOW:
# narrowing-as-cast possible narrowing 'as' cast
#
# Opt-out: place the appropriate comment on the previous line:
# // SAFE-UNWRAP: <reason>
# // SAFE-EXPECT: <reason>
# // SAFETY: <reason> (for unsafe blocks, within 5 lines above)
# // CLIPPY-ALLOW: <reason>
#
# Test paths (exempt from unwrap/expect/placeholder/box-dyn-error/lib-panic):
# tests/, benches/, examples/, build.rs, *_test.rs, #[cfg(test)] regions
from __future__ import annotations
import re
import sys
from pathlib import Path
# ---------------------------------------------------------------------------
# Patterns (compiled once)
# ---------------------------------------------------------------------------
RE_UNWRAP = re.compile(r"\.unwrap\(\)")
RE_EXPECT = re.compile(r"\.expect\(")
RE_PLACEHOLDER = re.compile(r"\b(todo!|unimplemented!|unreachable!|unreachable_unchecked!)")
RE_BOX_DYN_ERROR = re.compile(r"Box<dyn\s+Error")
RE_PANIC = re.compile(r"\bpanic!\(")
RE_UNSAFE_BLOCK = re.compile(r"\bunsafe\s*\{")
RE_CLIPPY_ALLOW = re.compile(r"#\[allow\(clippy::")
RE_CFG_TEST = re.compile(r"#\[cfg\(test\)\]")
RE_SAFE_UNWRAP = re.compile(r"//\s*SAFE-UNWRAP:")
RE_SAFE_EXPECT = re.compile(r"//\s*SAFE-EXPECT:")
RE_SAFETY = re.compile(r"//\s*SAFETY:")
RE_CLIPPY_ALLOW_JUST = re.compile(r"//\s*CLIPPY-ALLOW:")
# Narrowing cast: (wider) as (narrower)
# Wider types that lose bits when cast to narrower targets.
# No leading \b — must match e.g. `999u64 as u32` where a digit precedes the type.
_WIDER = r"(?:u16|u32|u64|u128|usize|i16|i32|i64|i128|isize)"
_NARROWER = r"(?:u8|u16|u32|i8|i16|i32)"
RE_NARROWING_CAST = re.compile(
rf"{_WIDER}\s+as\s+{_NARROWER}"
)
# Test-path fragments.
_TEST_PATH_PARTS = {"tests", "benches", "examples"}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
violations = 0
def report(file: str, line: int, rule: str, detail: str) -> None:
"""Emit a GitHub-Actions-compatible error annotation to stderr."""
global violations
print(f"::error file={file},line={line}::[{rule}] {detail}", file=sys.stderr)
violations += 1
def is_test_path(path: Path) -> bool:
"""Return True if *path* is in a test/bench/example directory or is a test file."""
parts = path.parts
for part in parts:
if part in _TEST_PATH_PARTS:
return True
if path.name == "build.rs":
return True
if path.name.endswith("_test.rs"):
return True
return False
def is_lib_path(file: Path) -> bool:
"""Heuristic: is this file library code (not main.rs, not src/bin/*)."""
parts = path_parts_str(file)
# Must live under src/
if "src" not in parts:
return False
if file.name == "main.rs":
return False
# src/bin/* is binary code
try:
src_idx = parts.index("src")
if src_idx + 1 < len(parts) and parts[src_idx + 1] == "bin":
return False
except ValueError:
return False
return True
def path_parts_str(p: Path) -> list[str]:
return list(p.parts)
def strip_line_comment(line: str) -> str:
"""Return the portion of *line* before any ``//`` line comment.
This is a crude heuristic — it does not handle ``//`` inside string
literals, but matches the behaviour of the bash version.
"""
idx = line.find("//")
if idx == -1:
return line
return line[:idx]
def collect_rs_files(args: list[str]) -> list[Path]:
"""Expand CLI arguments: files are kept as-is, directories are walked."""
result: list[Path] = []
for arg in args:
p = Path(arg)
if p.is_file():
if p.suffix == ".rs":
result.append(p)
elif p.is_dir():
result.extend(sorted(p.rglob("*.rs")))
# Ignore non-existent / non-.rs
return result
# ---------------------------------------------------------------------------
# Main checker
# ---------------------------------------------------------------------------
def check_file(file: Path) -> None:
in_test_file = is_test_path(file)
in_cfg_test = False
cfg_test_brace_depth = 0
try:
lines = file.read_text(encoding="utf-8", errors="replace").splitlines()
except OSError as exc:
print(f"warning: cannot read {file}: {exc}", file=sys.stderr)
return
for line_no_0, raw_line in enumerate(lines):
line_no = line_no_0 + 1 # 1-indexed
# --- #[cfg(test)] region tracker ---
if RE_CFG_TEST.search(raw_line):
in_cfg_test = True
cfg_test_brace_depth = 0
if in_cfg_test:
opens = raw_line.count("{")
closes = raw_line.count("}")
cfg_test_brace_depth += opens - closes
if cfg_test_brace_depth <= 0 and not RE_CFG_TEST.search(raw_line):
in_cfg_test = False
exempt = in_test_file or in_cfg_test
code_only = strip_line_comment(raw_line)
if not exempt:
# .unwrap()
if RE_UNWRAP.search(code_only):
prev = lines[line_no_0 - 1] if line_no_0 > 0 else ""
if not RE_SAFE_UNWRAP.search(prev):
report(
str(file), line_no, "unwrap",
".unwrap() outside tests - use ? / ok_or / pattern match "
"or annotate previous line with // SAFE-UNWRAP: <reason>",
)
# .expect(...)
if RE_EXPECT.search(code_only):
prev = lines[line_no_0 - 1] if line_no_0 > 0 else ""
if not RE_SAFE_EXPECT.search(prev):
report(
str(file), line_no, "expect",
".expect() outside tests - use ? or annotate previous "
"line with // SAFE-EXPECT: <reason>",
)
# todo!/unimplemented!/unreachable!/unreachable_unchecked!
if RE_PLACEHOLDER.search(code_only):
report(
str(file), line_no, "placeholder-macro",
"todo!/unimplemented!/unreachable! in committed code",
)
# Box<dyn Error>
if RE_BOX_DYN_ERROR.search(code_only):
report(
str(file), line_no, "box-dyn-error",
"Box<dyn Error> in non-test code - use anyhow::Error (apps) "
"or thiserror enum (libs)",
)
# panic!() in library code
if is_lib_path(file) and RE_PANIC.search(code_only):
report(
str(file), line_no, "lib-panic",
"panic!() in library code - return Result",
)
# unsafe { without // SAFETY: — always enforced, even in tests
if RE_UNSAFE_BLOCK.search(code_only):
start = max(0, line_no_0 - 5)
window = "\n".join(lines[start : line_no_0 + 1])
if not RE_SAFETY.search(window):
report(
str(file), line_no, "unsafe-no-safety-comment",
"unsafe block without // SAFETY: comment in preceding 5 lines",
)
# #[allow(clippy::...)] without // CLIPPY-ALLOW: — always enforced
if RE_CLIPPY_ALLOW.search(code_only):
prev = lines[line_no_0 - 1] if line_no_0 > 0 else ""
if not RE_CLIPPY_ALLOW_JUST.search(prev):
report(
str(file), line_no, "unjustified-clippy-allow",
"#[allow(clippy::...)] without // CLIPPY-ALLOW: <reason> on "
"previous line",
)
# Narrowing numeric `as` casts
if RE_NARROWING_CAST.search(code_only):
report(
str(file), line_no, "narrowing-as-cast",
"possible narrowing 'as' cast - use TryFrom / try_into() for "
"fallible conversion",
)
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main() -> None:
global violations
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} <file.rs|dir> [file.rs|dir ...]", file=sys.stderr)
sys.exit(2)
files = collect_rs_files(sys.argv[1:])
if not files:
print("warning: no .rs files found in the given arguments", file=sys.stderr)
sys.exit(0)
for f in files:
check_file(f)
if violations > 0:
print("", file=sys.stderr)
print(
f"rust-programmer: {violations} violation(s). Fix before declaring work done.",
file=sys.stderr,
)
print("", file=sys.stderr)
print("Then run the full toolchain gate:", file=sys.stderr)
print(" cargo +stable fmt --all -- --check", file=sys.stderr)
print(
" cargo +stable clippy --all-targets --all-features -- -D warnings",
file=sys.stderr,
)
print(" cargo nextest run --all-targets --all-features", file=sys.stderr)
print(
" cargo +nightly miri nextest run --all-features # if unsafe touched",
file=sys.stderr,
)
print(" cargo machete", file=sys.stderr)
print(" cargo deny check", file=sys.stderr)
sys.exit(1)
print(f"rust-programmer: no-excuse rules passed for {len(files)} file(s).")
if __name__ == "__main__":
main()
@@ -0,0 +1,158 @@
#!/usr/bin/env bash
# No-excuse rule checker for Rust files.
# Mirrors the philosophy of python-programmer / typescript-programmer scripts:
# only rules that can be enforced via pure text matching live here.
# Everything semantic is on clippy + miri + nextest.
set -euo pipefail
if [ $# -eq 0 ]; then
echo "Usage: $0 <file.rs> [file.rs ...]" >&2
exit 2
fi
violations=0
report() {
local file="$1"
local line="$2"
local rule="$3"
local detail="$4"
echo "::error file=${file},line=${line}::[${rule}] ${detail}" >&2
violations=$((violations + 1))
}
is_test_path() {
local path="$1"
case "$path" in
*/tests/*|*/benches/*|*/examples/*|*/build.rs|*_test.rs|tests/*|benches/*|examples/*) return 0 ;;
esac
# In-file #[cfg(test)] modules are handled per-line below.
return 1
}
for file in "$@"; do
[ -f "$file" ] || continue
case "$file" in
*.rs) ;;
*) continue ;;
esac
if is_test_path "$file"; then
# Test files are exempt from unwrap/expect/todo rules.
# Still enforce unsafe-comment, allow-comment, panic-in-lib rules below
# by setting a marker - keeping the loop unified.
in_test_file=1
else
in_test_file=0
fi
# Track #[cfg(test)] regions for per-line exemptions.
in_cfg_test=0
cfg_test_brace_depth=0
line_no=0
while IFS= read -r raw_line || [ -n "$raw_line" ]; do
line_no=$((line_no + 1))
line="$raw_line"
# Crude #[cfg(test)] region tracker: when we see #[cfg(test)] on a
# line followed by a mod with `{`, count braces until depth returns
# to zero. This is approximate but matches typical formatting.
if [[ "$line" =~ \#\[cfg\(test\)\] ]]; then
in_cfg_test=1
cfg_test_brace_depth=0
fi
if [ "$in_cfg_test" -eq 1 ]; then
opens=$(printf '%s' "$line" | tr -cd '{' | wc -c)
closes=$(printf '%s' "$line" | tr -cd '}' | wc -c)
cfg_test_brace_depth=$((cfg_test_brace_depth + opens - closes))
if [ "$cfg_test_brace_depth" -le 0 ] && [[ ! "$line" =~ \#\[cfg\(test\)\] ]]; then
in_cfg_test=0
fi
fi
exempt=0
[ "$in_test_file" -eq 1 ] && exempt=1
[ "$in_cfg_test" -eq 1 ] && exempt=1
# Strip line comments before pattern checks - so doc comments and
# explanatory prose do not trip the regexes.
code_only="${line%%//*}"
if [ "$exempt" -eq 0 ]; then
# .unwrap()
if [[ "$code_only" =~ \.unwrap\(\) ]]; then
# Allow if previous line had // SAFE-UNWRAP: comment
prev_line=$(sed -n "$((line_no - 1))p" "$file" 2>/dev/null || true)
if [[ ! "$prev_line" =~ //[[:space:]]*SAFE-UNWRAP: ]]; then
report "$file" "$line_no" "unwrap" ".unwrap() outside tests - use ? / ok_or / pattern match or annotate previous line with // SAFE-UNWRAP: <reason>"
fi
fi
# .expect("...")
if [[ "$code_only" =~ \.expect\( ]]; then
prev_line=$(sed -n "$((line_no - 1))p" "$file" 2>/dev/null || true)
if [[ ! "$prev_line" =~ //[[:space:]]*SAFE-EXPECT: ]]; then
report "$file" "$line_no" "expect" ".expect() outside tests - use ? or annotate previous line with // SAFE-EXPECT: <reason>"
fi
fi
# todo!() / unimplemented!() / unreachable!()
if [[ "$code_only" =~ (todo!|unimplemented!|unreachable!|unreachable_unchecked!) ]]; then
report "$file" "$line_no" "placeholder-macro" "todo!/unimplemented!/unreachable! in committed code"
fi
# Box<dyn Error
if [[ "$code_only" =~ Box\<dyn[[:space:]]+Error ]]; then
report "$file" "$line_no" "box-dyn-error" "Box<dyn Error> in non-test code - use anyhow::Error (apps) or thiserror enum (libs)"
fi
# panic!( in lib
if [[ "$file" == */src/lib.rs || "$file" == */src/*/mod.rs || ( "$file" == */src/*.rs && "$file" != */src/main.rs && "$file" != */src/bin/* ) ]]; then
if [[ "$code_only" =~ panic!\( ]]; then
report "$file" "$line_no" "lib-panic" "panic!() in library code - return Result"
fi
fi
fi
# unsafe { without preceding // SAFETY: in the last 5 lines (always enforced)
if [[ "$code_only" =~ unsafe[[:space:]]*\{ ]]; then
start=$((line_no > 5 ? line_no - 5 : 1))
window=$(sed -n "${start},${line_no}p" "$file" 2>/dev/null || true)
if [[ ! "$window" =~ //[[:space:]]*SAFETY: ]]; then
report "$file" "$line_no" "unsafe-no-safety-comment" "unsafe block without // SAFETY: comment in preceding 5 lines"
fi
fi
# #[allow(clippy::...)] without preceding // CLIPPY-ALLOW: justification
if [[ "$code_only" =~ \#\[allow\(clippy:: ]]; then
prev_line=$(sed -n "$((line_no - 1))p" "$file" 2>/dev/null || true)
if [[ ! "$prev_line" =~ //[[:space:]]*CLIPPY-ALLOW: ]]; then
report "$file" "$line_no" "unjustified-clippy-allow" "#[allow(clippy::...)] without // CLIPPY-ALLOW: <reason> on previous line"
fi
fi
# Narrowing numeric `as` casts - heuristic flag for human review.
# Catches the common shapes; precise type analysis belongs to clippy::cast_possible_truncation.
if [[ "$code_only" =~ as[[:space:]]+(u8|u16|u32|i8|i16|i32) ]] && \
[[ "$code_only" =~ (u16|u32|u64|u128|usize|i16|i32|i64|i128|isize)[[:space:]]+as[[:space:]]+(u8|u16|u32|i8|i16|i32) ]]; then
report "$file" "$line_no" "narrowing-as-cast" "possible narrowing 'as' cast - use TryFrom / try_into() for fallible conversion"
fi
done < "$file"
done
if [ "$violations" -gt 0 ]; then
echo "" >&2
echo "rust-programmer: ${violations} violation(s). Fix before declaring work done." >&2
echo "" >&2
echo "Then run the full toolchain gate:" >&2
echo " cargo +stable fmt --all -- --check" >&2
echo " cargo +stable clippy --all-targets --all-features -- -D warnings" >&2
echo " cargo nextest run --all-targets --all-features" >&2
echo " cargo +nightly miri nextest run --all-features # if unsafe touched" >&2
echo " cargo machete" >&2
echo " cargo deny check" >&2
exit 1
fi
echo "rust-programmer: no-excuse rules passed for $# file(s)."
@@ -0,0 +1,175 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "typer",
# "rich",
# ]
# ///
# ─── How to run ───
# 1. Install uv (if not installed):
# curl -LsSf https://astral.sh/uv/install.sh | sh
# 2. Run:
# uv run new-project.py myproject
# uv run new-project.py myproject --path ./workspace
# ──────────────────
#
# Creates a new Rust project with strict lints, deny.toml, rustfmt.toml,
# rust-toolchain.toml, and .cargo/config.toml pre-configured.
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
import typer
from rich.console import Console
console = Console(stderr=True)
# ── Embedded config contents ─────────────────────────────────────────────
RUST_TOOLCHAIN_TOML = """\
[toolchain]
channel = "stable"
components = ["rustfmt", "clippy", "rust-src"]
profile = "default"
"""
CARGO_TOML_LINTS = """
[lints.rust]
unsafe_op_in_unsafe_fn = "deny"
missing_docs = "warn"
missing_debug_implementations = "warn"
unreachable_pub = "warn"
unused_must_use = "deny"
elided_lifetimes_in_paths = "warn"
non_ascii_idents = "deny"
trivial_numeric_casts = "warn"
unused_lifetimes = "warn"
single_use_lifetimes = "warn"
[lints.clippy]
all = { level = "deny", priority = -1 }
pedantic = { level = "warn", priority = -1 }
nursery = { level = "warn", priority = -1 }
cargo = { level = "warn", priority = -1 }
undocumented_unsafe_blocks = "deny"
multiple_unsafe_ops_per_block = "deny"
unwrap_used = "deny"
expect_used = "deny"
panic = "deny"
todo = "deny"
unimplemented = "deny"
dbg_macro = "deny"
print_stdout = "warn"
print_stderr = "warn"
module_name_repetitions = { level = "allow" }
must_use_candidate = { level = "allow" }
missing_errors_doc = { level = "allow" }
missing_panics_doc = { level = "allow" }
"""
CARGO_CONFIG_TOML = """\
[build]
rustflags = ["-C", "link-arg=-fuse-ld=lld"]
[target.x86_64-unknown-linux-gnu]
linker = "clang"
rustflags = ["-C", "link-arg=-fuse-ld=lld"]
[target.aarch64-apple-darwin]
rustflags = []
"""
DENY_TOML = """\
[advisories]
vulnerability = "deny"
unmaintained = "warn"
yanked = "deny"
[licenses]
unlicensed = "deny"
allow = ["MIT", "Apache-2.0", "BSD-2-Clause", "BSD-3-Clause", "ISC", "Unicode-3.0", "Zlib"]
[bans]
multiple-versions = "warn"
wildcards = "deny"
[sources]
unknown-registry = "deny"
unknown-git = "deny"
"""
RUSTFMT_TOML = """\
edition = "2024"
max_width = 100
use_field_init_shorthand = true
use_try_shorthand = true
"""
# ── Main ─────────────────────────────────────────────────────────────────
app = typer.Typer(add_completion=False)
@app.command()
def main(
name: str = typer.Argument(help="Name of the new Rust project"),
path: Path = typer.Option(
Path.cwd(),
"--path",
"-p",
help="Parent directory where the project folder is created",
),
) -> None:
"""Scaffold a new Rust project with strict lints and tooling configs."""
project_dir = path / name
# ── cargo init ───────────────────────────────────────────────────
console.print(f"[bold green]Creating[/] project [cyan]{name}[/] at [dim]{project_dir}[/]")
try:
subprocess.run(
["cargo", "init", str(project_dir), "--name", name],
check=True,
capture_output=True,
text=True,
)
except FileNotFoundError:
console.print("[bold red]Error:[/] cargo not found. Install Rust via https://rustup.rs")
sys.exit(1)
except subprocess.CalledProcessError as exc:
console.print(f"[bold red]cargo init failed:[/]\n{exc.stderr}")
sys.exit(1)
# ── rust-toolchain.toml ──────────────────────────────────────────
(project_dir / "rust-toolchain.toml").write_text(RUST_TOOLCHAIN_TOML)
console.print(" [dim]wrote[/] rust-toolchain.toml")
# ── Append [lints] to Cargo.toml ─────────────────────────────────
cargo_toml = project_dir / "Cargo.toml"
with cargo_toml.open("a") as f:
f.write(CARGO_TOML_LINTS)
console.print(" [dim]appended[/] [lints] to Cargo.toml")
# ── .cargo/config.toml ───────────────────────────────────────────
cargo_config_dir = project_dir / ".cargo"
cargo_config_dir.mkdir(parents=True, exist_ok=True)
(cargo_config_dir / "config.toml").write_text(CARGO_CONFIG_TOML)
console.print(" [dim]wrote[/] .cargo/config.toml")
# ── deny.toml ────────────────────────────────────────────────────
(project_dir / "deny.toml").write_text(DENY_TOML)
console.print(" [dim]wrote[/] deny.toml")
# ── rustfmt.toml ─────────────────────────────────────────────────
(project_dir / "rustfmt.toml").write_text(RUSTFMT_TOML)
console.print(" [dim]wrote[/] rustfmt.toml")
console.print(f"\n[bold green]Done![/] cd {project_dir} && cargo check")
if __name__ == "__main__":
app()
@@ -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 <file-or-dir>...
*
* 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 <file-or-dir>...")
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()
@@ -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:
* <name>/
* 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 <name> [--path <dir>]
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`);