feat(shared-skills): add user skill sources
This commit is contained in:
+173
@@ -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)
|
||||
+296
@@ -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()
|
||||
+158
@@ -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()
|
||||
+282
@@ -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`);
|
||||
Reference in New Issue
Block a user