mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-10-28 07:41:27 +00:00
feat: phase 0 foundation — monorepo scaffold, backend, frontend, CI, docker
Establishes the skeleton every later phase builds on: Go+Gin API with zap logging, /api/healthz, SQLite+goose runner; Vite+React18+TS strict+ Tailwind v4 frontend with bento-mono theme tokens; openapi.yaml driving a generated TS client; oxlint with vendored anti-slop rules; GitHub CI; multi-stage Dockerfile -> 33.6MB distroless image; docker compose up serves the blank board. Generated with [Devin](https://devin.ai) Authored By: TDvorak <[email protected]> Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
e7f32949e9
commit
b60821350a
46 files changed
+6415
-10
No files matched your search
@@ -0,0 +1,11 @@
|
|||||||
|
.git
|
||||||
|
node_modules
|
||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
data
|
||||||
|
docs
|
||||||
|
mockups
|
||||||
|
tools
|
||||||
|
.github
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
name: ci
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
backend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: apps/backend
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: apps/backend/go.mod
|
||||||
|
cache-dependency-path: apps/backend/go.sum
|
||||||
|
- run: go build ./...
|
||||||
|
- run: go vet ./...
|
||||||
|
- run: go test ./...
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: npm
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run typecheck
|
||||||
|
- run: npm run lint
|
||||||
|
- run: npm run build
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
# deps / builds
|
||||||
|
node_modules/
|
||||||
|
apps/frontend/dist/
|
||||||
|
packages/api-client/dist/
|
||||||
|
packages/api-client/src/schema.d.ts
|
||||||
|
apps/backend/internal/web/dist/*
|
||||||
|
!apps/backend/internal/web/dist/.keep
|
||||||
|
!apps/backend/internal/web/dist/index.html
|
||||||
|
|
||||||
|
# data / runtime
|
||||||
|
data/*
|
||||||
|
!data/.keep
|
||||||
|
*.db
|
||||||
|
*.db-*
|
||||||
|
|
||||||
|
# env / secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
coverage/
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Dash
|
||||||
|
|
||||||
|
Self-hosted homelab dashboard. Single Go binary serving an embedded React UI.
|
||||||
|
No auth, no YAML. See `ROADMAP.md` (canonical) and `docs/specs/` for design.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
- `apps/frontend` — React 18 + Vite + TS strict + Tailwind v4 + shadcn/ui
|
||||||
|
- `apps/backend` — Go + Gin + zap, CGO-free, SQLite via `modernc.org/sqlite`
|
||||||
|
- `packages/api-client` — generated TS client; `openapi.yaml` at repo root is the contract
|
||||||
|
- `infra/` — Dockerfile (multi-stage, one image)
|
||||||
|
- `tools/oxlint/anti-slop` — vendored lint plugin (do not extend casually)
|
||||||
|
- `data/` — runtime SQLite + uploaded icons (gitignored)
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
npm run generate # regenerate TS client from openapi.yaml
|
||||||
|
npm run dev # vite dev server (proxies /api -> :8080)
|
||||||
|
npm run typecheck # tsc --noEmit
|
||||||
|
npm run lint # oxlint + anti-slop rules
|
||||||
|
npm run build # api-client types + frontend bundle
|
||||||
|
|
||||||
|
cd apps/backend
|
||||||
|
go build ./... # build backend
|
||||||
|
go test ./... # tests
|
||||||
|
DASH_DEV=1 go run ./cmd/dash # dev server on :8080, no embedded UI needed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- OpenAPI is the API contract. Never hand-write API types in the frontend.
|
||||||
|
- All schema changes go through goose migrations in `apps/backend/internal/db/migrations`.
|
||||||
|
- Board renderers share state/dnd/popover primitives; never reimplement them per renderer.
|
||||||
|
- No emojis in code or UI.
|
||||||
|
- Before pushing: `npm run typecheck`, `npm run lint`, `npm run build`, `go build/vet/test ./...` all green.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 TDvorak
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Dash
|
||||||
|
|
||||||
|
A self-hosted homelab dashboard. CasaOS-grade ease of use, Vercel-grade design.
|
||||||
|
Single container, UI-managed, no YAML, no auth.
|
||||||
|
|
||||||
|
**Status:** pre-release. See [ROADMAP.md](ROADMAP.md).
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker run -v ./data:/data -p 3000:3000 dash
|
||||||
|
```
|
||||||
|
|
||||||
|
or
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open http://localhost:3000.
|
||||||
|
|
||||||
|
## Develop
|
||||||
|
|
||||||
|
Requires Go 1.24+ and Node 20+.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install # workspaces: frontend + api-client
|
||||||
|
npm run generate # regenerate TS client from openapi.yaml
|
||||||
|
npm run dev # vite dev on :5173, proxies /api to :8080
|
||||||
|
```
|
||||||
|
|
||||||
|
In another shell:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd apps/backend
|
||||||
|
DASH_DEV=1 go run ./cmd/dash # API on :8080 (DASH_DEV skips embedded UI)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
Go + Gin + SQLite (single static binary, embeds the frontend) · React 18 +
|
||||||
|
Vite + TS strict + Tailwind + shadcn/ui + dnd-kit · OpenAPI-generated client.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[MIT](LICENSE)
|
||||||
+10
-10
@@ -39,20 +39,20 @@ or popover logic.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 0 — Foundation → part of v0.1.0
|
## Phase 0 — Foundation → part of v0.1.0 ✅ done 2026-09-17
|
||||||
|
|
||||||
Repo hygiene and pipelines before features.
|
Repo hygiene and pipelines before features.
|
||||||
|
|
||||||
- [ ] Monorepo scaffold (`apps/frontend`, `apps/backend`, `packages/api-client`, `infra/`, `data/`)
|
- [x] Monorepo scaffold (`apps/frontend`, `apps/backend`, `packages/api-client`, `infra/`, `data/`)
|
||||||
- [ ] MIT `LICENSE`, `README.md`, `.gitignore`, `AGENTS.md`
|
- [x] MIT `LICENSE`, `README.md`, `.gitignore`, `AGENTS.md`
|
||||||
- [ ] `openapi.yaml` stub → generated TS client (`packages/api-client`)
|
- [x] `openapi.yaml` stub → generated TS client (`packages/api-client`)
|
||||||
- [ ] Backend skeleton: Gin router, `zap` logging, `/api/healthz`, SQLite open + goose migrations runner
|
- [x] Backend skeleton: Gin router, `zap` logging, `/api/healthz`, SQLite open + goose migrations runner
|
||||||
- [ ] Frontend skeleton: Vite + TS strict + Tailwind + shadcn/ui, theme tokens matching `d-bento-mono.html` CSS vars
|
- [x] Frontend skeleton: Vite + TS strict + Tailwind + shadcn/ui, theme tokens matching `d-bento-mono.html` CSS vars
|
||||||
- [ ] CI (GitHub Actions): `go build ./...`, `go vet ./...`, `go test ./...`, `tsc --noEmit`, `oxlint` + vendored anti-slop rules, `npm run build`
|
- [x] CI (GitHub Actions): `go build ./...`, `go vet ./...`, `go test ./...`, `tsc --noEmit`, `oxlint` + vendored anti-slop rules, `npm run build`
|
||||||
- [ ] Multi-stage `Dockerfile` (frontend build → Go build → `FROM scratch`/distroless, `embed.FS` serves UI)
|
- [x] Multi-stage `Dockerfile` (frontend build → Go build → distroless, `embed.FS` serves UI)
|
||||||
- [ ] `docker-compose.yml` — one service, `./data:/data` volume
|
- [x] `docker-compose.yml` — one service, `./data:/data` volume
|
||||||
|
|
||||||
**Exit:** `docker compose up` serves a blank board. CI green on `main`.
|
**Exit:** `docker compose up` serves a blank board — verified locally (33.6 MB image). CI green on `main` — pending first push.
|
||||||
|
|
||||||
## Phase 1 — MVP → v0.1.0
|
## Phase 1 — MVP → v0.1.0
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// Command dash is the single-binary entrypoint: API + embedded UI.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/tdvorak/dash/internal/api"
|
||||||
|
"github.com/tdvorak/dash/internal/db"
|
||||||
|
"github.com/tdvorak/dash/internal/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
dev := os.Getenv("DASH_DEV") == "1"
|
||||||
|
|
||||||
|
var logger *zap.Logger
|
||||||
|
if dev {
|
||||||
|
logger = zap.Must(zap.NewDevelopment())
|
||||||
|
} else {
|
||||||
|
gin.SetMode(gin.ReleaseMode)
|
||||||
|
logger = zap.Must(zap.NewProduction())
|
||||||
|
}
|
||||||
|
defer func() { _ = logger.Sync() }()
|
||||||
|
|
||||||
|
dataDir := envOr("DASH_DATA_DIR", "./data")
|
||||||
|
if err := os.MkdirAll(filepath.Join(dataDir, "icons"), 0o755); err != nil {
|
||||||
|
logger.Fatal("create data dir", zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlDB, err := db.Open(filepath.Join(dataDir, "dash.db"))
|
||||||
|
if err != nil {
|
||||||
|
logger.Fatal("open database", zap.Error(err))
|
||||||
|
}
|
||||||
|
defer func() { _ = sqlDB.Close() }()
|
||||||
|
|
||||||
|
router := api.NewRouter(logger, sqlDB)
|
||||||
|
if err := web.Register(router); err != nil {
|
||||||
|
logger.Fatal("register embedded UI", zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
addr := ":" + envOr("DASH_PORT", "8080")
|
||||||
|
srv := &http.Server{Addr: addr, Handler: router, ReadHeaderTimeout: 10 * time.Second}
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
logger.Info("listening", zap.String("addr", addr), zap.String("data", dataDir))
|
||||||
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
logger.Fatal("serve", zap.Error(err))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
<-ctx.Done()
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||||
|
logger.Error("shutdown", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOr(key, fallback string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
module github.com/tdvorak/dash
|
||||||
|
|
||||||
|
go 1.27
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gin-gonic/gin v1.12.0
|
||||||
|
github.com/pressly/goose/v3 v3.28.0
|
||||||
|
go.uber.org/zap v1.28.0
|
||||||
|
modernc.org/sqlite v1.59.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||||
|
github.com/bytedance/sonic v1.15.0 // indirect
|
||||||
|
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||||
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||||
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
|
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||||
|
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
|
github.com/quic-go/qpack v0.6.0 // indirect
|
||||||
|
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/sethvargo/go-retry v0.4.0 // indirect
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
|
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||||
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
|
golang.org/x/arch v0.22.0 // indirect
|
||||||
|
golang.org/x/crypto v0.55.0 // indirect
|
||||||
|
golang.org/x/net v0.58.0 // indirect
|
||||||
|
golang.org/x/sync v0.22.0 // indirect
|
||||||
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
|
golang.org/x/text v0.41.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.12 // indirect
|
||||||
|
modernc.org/libc v1.75.7 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.12.1 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||||
|
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||||
|
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||||
|
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||||
|
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||||
|
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||||
|
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
|
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
|
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||||
|
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||||
|
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||||
|
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
|
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||||
|
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||||
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
|
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||||
|
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||||
|
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
|
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||||
|
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||||
|
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||||
|
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/pressly/goose/v3 v3.28.0 h1:D2M+iL31GmpZxSHOhX8mqyqAT3CXnokUmm0eKoSP+Vc=
|
||||||
|
github.com/pressly/goose/v3 v3.28.0/go.mod h1:v26MOuB8bL3kzzrt3Vqhb3R0PRVsl8hFQKdrht/L6Rk=
|
||||||
|
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||||
|
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||||
|
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||||
|
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/sethvargo/go-retry v0.4.0 h1:9qy1OoIAxBL+gBYnkTnTnWle5wlfsXQlwRzIbbpdqPw=
|
||||||
|
github.com/sethvargo/go-retry v0.4.0/go.mod h1:tvsjdKG6xfiCx4LSiUZ06kcv38xvdVQwv8R6/VnnVWg=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||||
|
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
|
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||||
|
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||||
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
|
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||||
|
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
|
go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
|
||||||
|
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||||
|
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||||
|
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||||
|
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||||
|
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||||
|
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||||
|
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||||
|
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||||
|
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||||
|
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
|
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||||
|
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||||
|
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||||
|
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||||
|
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
||||||
|
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
|
||||||
|
modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||||
|
modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
|
||||||
|
modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I=
|
||||||
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
|
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
|
||||||
|
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.75.7 h1:o3DTP9/0p9pKmY2WCKQaySW6wIiZhNM7wc2lUoyhfew=
|
||||||
|
modernc.org/libc v1.75.7/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
|
||||||
|
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||||
|
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.59.0 h1:X1es1GpqBlS/5T+vbM4HLUdaa8OtQx468DF2vrx+38A=
|
||||||
|
modernc.org/sqlite v1.59.0/go.mod h1:+paeT2A3iPRHkQDwG7oA6Tk0zQd5woMEI8q7orfry8k=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// Package api wires the Gin router and /api handlers.
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewRouter builds the HTTP handler: zap access log, recovery, /api routes.
|
||||||
|
func NewRouter(logger *zap.Logger, db *sql.DB) *gin.Engine {
|
||||||
|
r := gin.New()
|
||||||
|
r.Use(accessLog(logger), gin.Recovery())
|
||||||
|
|
||||||
|
v1 := r.Group("/api")
|
||||||
|
v1.GET("/healthz", func(c *gin.Context) {
|
||||||
|
if err := db.PingContext(c.Request.Context()); err != nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"status": "db unreachable"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
|
})
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// accessLog is gin.Logger backed by zap: one structured line per request.
|
||||||
|
func accessLog(logger *zap.Logger) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
start := time.Now()
|
||||||
|
c.Next()
|
||||||
|
logger.Info("request",
|
||||||
|
zap.String("method", c.Request.Method),
|
||||||
|
zap.String("path", c.Request.URL.Path),
|
||||||
|
zap.Int("status", c.Writer.Status()),
|
||||||
|
zap.Duration("latency", time.Since(start)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// Package db opens the SQLite database and applies goose migrations.
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/pressly/goose/v3"
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed migrations/*.sql
|
||||||
|
var migrations embed.FS
|
||||||
|
|
||||||
|
// Open opens (creating if needed) the SQLite file at path and migrates it
|
||||||
|
// to the latest schema. WAL + busy_timeout keep reads snappy under writes.
|
||||||
|
func Open(path string) (*sql.DB, error) {
|
||||||
|
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)", path)
|
||||||
|
sqlDB, err := sql.Open("sqlite", dsn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
goose.SetBaseFS(migrations)
|
||||||
|
if err := goose.SetDialect("sqlite3"); err != nil {
|
||||||
|
return nil, fmt.Errorf("goose dialect: %w", err)
|
||||||
|
}
|
||||||
|
if err := goose.Up(sqlDB, "migrations"); err != nil {
|
||||||
|
return nil, fmt.Errorf("migrate: %w", err)
|
||||||
|
}
|
||||||
|
return sqlDB, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- Key/value store for user prefs (theme, renderer, ...). Values are JSON.
|
||||||
|
CREATE TABLE settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value JSON NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP TABLE settings;
|
||||||
Whitespace-only changes.
+1
@@ -0,0 +1 @@
|
|||||||
|
<!doctype html><meta charset="utf-8"><title>Dash</title><p>Dash API is running. Frontend build is not embedded in this binary.</p>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// Package web serves the embedded frontend build (SPA with index.html
|
||||||
|
// fallback). dist/ is populated by the frontend build in Docker/CI; the
|
||||||
|
// committed .keep keeps //go:embed satisfied on a bare clone.
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed all:dist
|
||||||
|
var dist embed.FS
|
||||||
|
|
||||||
|
// Register mounts the SPA on the router. Non-/api paths serve static files;
|
||||||
|
// unknown paths fall back to index.html for client-side routing.
|
||||||
|
func Register(r *gin.Engine) error {
|
||||||
|
sub, err := fs.Sub(dist, "dist")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fileServer := http.FileServer(http.FS(sub))
|
||||||
|
|
||||||
|
r.NoRoute(func(c *gin.Context) {
|
||||||
|
path := c.Request.URL.Path
|
||||||
|
if len(path) >= 4 && path[:4] == "/api" {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if f, err := sub.Open(path[1:]); err == nil {
|
||||||
|
_ = f.Close()
|
||||||
|
fileServer.ServeHTTP(c.Writer, c.Request)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Request.URL.Path = "/"
|
||||||
|
fileServer.ServeHTTP(c.Writer, c.Request)
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/index.css",
|
||||||
|
"baseColor": "zinc",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide"
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Dash</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "@dash/frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@dash/api-client": "*",
|
||||||
|
"@fontsource-variable/geist": "^5.2.5",
|
||||||
|
"@fontsource-variable/geist-mono": "^5.2.5",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.545.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"tailwind-merge": "^3.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.1.13",
|
||||||
|
"@types/node": "^24.5.2",
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
|
"tailwindcss": "^4.1.13",
|
||||||
|
"typescript": "^5.9.2",
|
||||||
|
"vite": "^7.1.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Moon, Plus, Sun } from "lucide-react";
|
||||||
|
|
||||||
|
type Theme = "dark" | "light";
|
||||||
|
|
||||||
|
function initialTheme(): Theme {
|
||||||
|
const saved = localStorage.getItem("dash.theme");
|
||||||
|
return saved === "light" ? "light" : "dark";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [theme, setTheme] = useState<Theme>(initialTheme);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.dataset.theme = theme;
|
||||||
|
localStorage.setItem("dash.theme", theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="font-sans">
|
||||||
|
<header className="sticky top-0 z-10 flex items-center gap-3.5 border-b border-border bg-bg/80 px-7 py-3.5 backdrop-blur-md">
|
||||||
|
<div className="flex items-center gap-2.5 text-[15px] font-semibold tracking-tight">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden>
|
||||||
|
<rect x="1" y="1" width="7" height="7" rx="1.5" fill="currentColor" />
|
||||||
|
<rect x="10" y="1" width="7" height="7" rx="1.5" fill="currentColor" opacity=".45" />
|
||||||
|
<rect x="1" y="10" width="7" height="7" rx="1.5" fill="currentColor" opacity=".45" />
|
||||||
|
<rect x="10" y="10" width="7" height="7" rx="1.5" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
Dash
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||||
|
aria-label="Toggle theme"
|
||||||
|
className="ml-auto flex size-[34px] items-center justify-center rounded-lg border border-border bg-surface text-text-dim transition-colors hover:border-border-strong hover:text-text active:scale-95"
|
||||||
|
>
|
||||||
|
{theme === "dark" ? <Moon size={15} strokeWidth={1.8} /> : <Sun size={15} strokeWidth={1.8} />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-[34px] items-center gap-1.5 rounded-lg bg-text px-3.5 text-[13px] font-medium text-bg transition-transform active:scale-95"
|
||||||
|
>
|
||||||
|
<Plus size={13} strokeWidth={2.2} />
|
||||||
|
Add service
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<main className="mx-auto max-w-[1140px] px-7 pb-24 pt-9">
|
||||||
|
<p className="text-center text-[13px] text-text-faint">
|
||||||
|
No sections yet. Add a service to get started.
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* Design tokens — ported from mockups/d-bento-mono.html.
|
||||||
|
Semantic vars flip on [data-theme]; Tailwind utilities map via @theme. */
|
||||||
|
:root {
|
||||||
|
--ease: cubic-bezier(0.2, 0, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--bg: #0a0a0a;
|
||||||
|
--surface: #111111;
|
||||||
|
--surface-hover: #161616;
|
||||||
|
--border: rgba(255, 255, 255, 0.08);
|
||||||
|
--border-strong: rgba(255, 255, 255, 0.14);
|
||||||
|
--text: #fafafa;
|
||||||
|
--text-dim: #a1a1aa;
|
||||||
|
--text-faint: #52525b;
|
||||||
|
--accent: #3b82f6;
|
||||||
|
--up: #22c55e;
|
||||||
|
--down: #f59e0b;
|
||||||
|
--icon-bg: #1c1c1e;
|
||||||
|
--popover: #141414;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] {
|
||||||
|
--bg: #fafafa;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-hover: #f4f4f5;
|
||||||
|
--border: rgba(0, 0, 0, 0.08);
|
||||||
|
--border-strong: rgba(0, 0, 0, 0.16);
|
||||||
|
--text: #09090b;
|
||||||
|
--text-dim: #52525b;
|
||||||
|
--text-faint: #a1a1aa;
|
||||||
|
--accent: #2563eb;
|
||||||
|
--up: #16a34a;
|
||||||
|
--down: #d97706;
|
||||||
|
--icon-bg: #f4f4f5;
|
||||||
|
--popover: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-bg: var(--bg);
|
||||||
|
--color-surface: var(--surface);
|
||||||
|
--color-surface-hover: var(--surface-hover);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-border-strong: var(--border-strong);
|
||||||
|
--color-text: var(--text);
|
||||||
|
--color-text-dim: var(--text-dim);
|
||||||
|
--color-text-faint: var(--text-faint);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-up: var(--up);
|
||||||
|
--color-down: var(--down);
|
||||||
|
--color-icon-bg: var(--icon-bg);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--font-sans: "Geist Variable", ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||||
|
--font-mono: "Geist Mono Variable", ui-monospace, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
min-height: 100dvh;
|
||||||
|
transition:
|
||||||
|
background 200ms var(--ease),
|
||||||
|
color 200ms var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
* {
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import "@fontsource-variable/geist";
|
||||||
|
import "@fontsource-variable/geist-mono";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import App from "./App.tsx";
|
||||||
|
|
||||||
|
const root = document.getElementById("root");
|
||||||
|
if (!root) throw new Error("#root missing");
|
||||||
|
|
||||||
|
createRoot(root).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"exactOptionalPropertyTypes": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": { "@/*": ["src/*"] }
|
||||||
|
},
|
||||||
|
"include": ["src", "vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: { "@": path.resolve(__dirname, "src") },
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
// Backend API during development (DASH_DEV=1 go run ./cmd/dash).
|
||||||
|
proxy: { "/api": "http://localhost:8080" },
|
||||||
|
},
|
||||||
|
});
|
||||||
Whitespace-only changes.
@@ -0,0 +1,10 @@
|
|||||||
|
services:
|
||||||
|
dash:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: infra/Dockerfile
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
- ./data:/data
|
||||||
|
restart: unless-stopped
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
# ---- frontend ----
|
||||||
|
FROM node:24-alpine AS frontend
|
||||||
|
WORKDIR /src
|
||||||
|
COPY package.json package-lock.json openapi.yaml ./
|
||||||
|
COPY apps/frontend/package.json apps/frontend/package.json
|
||||||
|
COPY packages/api-client/package.json packages/api-client/package.json
|
||||||
|
RUN npm ci
|
||||||
|
COPY apps/frontend apps/frontend
|
||||||
|
COPY packages/api-client packages/api-client
|
||||||
|
RUN npm run build -w @dash/frontend
|
||||||
|
|
||||||
|
# ---- backend ----
|
||||||
|
FROM golang:1.27-alpine AS backend
|
||||||
|
WORKDIR /src
|
||||||
|
COPY apps/backend/go.mod apps/backend/go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY apps/backend ./
|
||||||
|
COPY --from=frontend /src/apps/frontend/dist ./internal/web/dist
|
||||||
|
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/dash ./cmd/dash
|
||||||
|
|
||||||
|
# ---- final: static binary on distroless ----
|
||||||
|
# Runs as root so the ./data bind mount stays writable without PUID plumbing.
|
||||||
|
# Attack surface is a single static binary; no shell, no package manager.
|
||||||
|
FROM gcr.io/distroless/static-debian12
|
||||||
|
COPY --from=backend /out/dash /dash
|
||||||
|
ENV DASH_PORT=3000 DASH_DATA_DIR=/data
|
||||||
|
EXPOSE 3000
|
||||||
|
VOLUME ["/data"]
|
||||||
|
ENTRYPOINT ["/dash"]
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
openapi: 3.1.0
|
||||||
|
info:
|
||||||
|
title: Dash API
|
||||||
|
version: 0.1.0
|
||||||
|
description: >
|
||||||
|
Single source of truth for the Dash API. The TypeScript client in
|
||||||
|
packages/api-client is generated from this file — do not hand-write
|
||||||
|
API types in the frontend. Phase 1 extends this contract with
|
||||||
|
sections/items/urls CRUD (see docs/specs).
|
||||||
|
servers:
|
||||||
|
- url: /
|
||||||
|
paths:
|
||||||
|
/api/healthz:
|
||||||
|
get:
|
||||||
|
operationId: healthz
|
||||||
|
summary: Liveness + database reachability probe
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Service healthy
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Health"
|
||||||
|
"503":
|
||||||
|
description: Database unreachable
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Health"
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
Health:
|
||||||
|
type: object
|
||||||
|
required: [status]
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
example: ok
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { defineConfig } from "oxlint";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
ignorePatterns: [
|
||||||
|
".agents/**",
|
||||||
|
".claude/**",
|
||||||
|
"tools/oxlint/anti-slop/**",
|
||||||
|
"**/dist/**",
|
||||||
|
"**/node_modules/**",
|
||||||
|
],
|
||||||
|
jsPlugins: [
|
||||||
|
{ name: "anti-slop", specifier: "./tools/oxlint/anti-slop/index.ts" },
|
||||||
|
],
|
||||||
|
rules: {
|
||||||
|
"anti-slop/no-chained-type-assertions": "error",
|
||||||
|
"anti-slop/no-widen-then-assert": "error",
|
||||||
|
"anti-slop/no-unsafe-dictionary-type": "error",
|
||||||
|
"anti-slop/require-safety-comment-for-type-assertion": "error",
|
||||||
|
"anti-slop/no-known-value-widening": "error",
|
||||||
|
},
|
||||||
|
});
|
||||||
Generated
+3363
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "dash",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"workspaces": [
|
||||||
|
"apps/frontend",
|
||||||
|
"packages/api-client"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"generate": "npm run generate -w @dash/api-client",
|
||||||
|
"dev": "npm run dev -w @dash/frontend",
|
||||||
|
"build": "npm run build -w @dash/api-client --if-present && npm run build -w @dash/frontend",
|
||||||
|
"typecheck": "npm run typecheck -w @dash/frontend",
|
||||||
|
"lint": "oxlint",
|
||||||
|
"postinstall": "npm run generate"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@oxlint/plugins": "1.78.0",
|
||||||
|
"oxlint": "^1.78.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "@dash/api-client",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts",
|
||||||
|
"./types": "./src/schema.d.ts"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"generate": "openapi-typescript ../../openapi.yaml -o src/schema.d.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"openapi-fetch": "^0.15.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"openapi-typescript": "^7.10.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// Generated-client entrypoint. Types come from src/schema.d.ts, produced by
|
||||||
|
// `npm run generate` from ../../openapi.yaml — the API contract.
|
||||||
|
import createFetchClient from "openapi-fetch";
|
||||||
|
import type { paths } from "./schema.d.ts";
|
||||||
|
|
||||||
|
export type { paths, components } from "./schema.d.ts";
|
||||||
|
|
||||||
|
/** API client bound to the OpenAPI contract. Same-origin by default. */
|
||||||
|
export function createClient(baseUrl = "") {
|
||||||
|
return createFetchClient<paths>({ baseUrl });
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// Vendored from github.com/dmmulroy/anti-slop (MIT), trimmed to the adopted
|
||||||
|
// rule set in references/architecture.md. Do not add rules here without
|
||||||
|
// updating the adopted list.
|
||||||
|
import { eslintCompatPlugin } from "@oxlint/plugins";
|
||||||
|
|
||||||
|
import { noChainedTypeAssertionsRule } from "./rules/no-chained-type-assertions.ts";
|
||||||
|
import { noKnownValueWideningRule } from "./rules/no-known-value-widening.ts";
|
||||||
|
import { noUnsafeDictionaryTypeRule } from "./rules/no-unsafe-dictionary-type.ts";
|
||||||
|
import { noWidenThenAssertRule } from "./rules/no-widen-then-assert.ts";
|
||||||
|
import { requireSafetyCommentForTypeAssertionRule } from "./rules/require-safety-comment-for-type-assertion.ts";
|
||||||
|
|
||||||
|
const antiSlopPlugin = eslintCompatPlugin({
|
||||||
|
meta: { name: "anti-slop" },
|
||||||
|
rules: {
|
||||||
|
"no-chained-type-assertions": noChainedTypeAssertionsRule,
|
||||||
|
"no-known-value-widening": noKnownValueWideningRule,
|
||||||
|
"no-unsafe-dictionary-type": noUnsafeDictionaryTypeRule,
|
||||||
|
"no-widen-then-assert": noWidenThenAssertRule,
|
||||||
|
"require-safety-comment-for-type-assertion": requireSafetyCommentForTypeAssertionRule,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default antiSlopPlugin;
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { defineRule } from "@oxlint/plugins";
|
||||||
|
import type { ESTree } from "@oxlint/plugins";
|
||||||
|
|
||||||
|
type TypeAssertionExpression = ESTree.TSAsExpression | ESTree.TSTypeAssertion;
|
||||||
|
|
||||||
|
function isTypeAssertionExpression(node: ESTree.Node): node is TypeAssertionExpression {
|
||||||
|
return node.type === "TSAsExpression" || node.type === "TSTypeAssertion";
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrapParenthesizedExpression(expression: ESTree.Expression): ESTree.Expression {
|
||||||
|
let current = expression;
|
||||||
|
while (current.type === "ParenthesizedExpression") {
|
||||||
|
current = current.expression;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isConstAssertion(node: TypeAssertionExpression): boolean {
|
||||||
|
const { typeAnnotation } = node;
|
||||||
|
return (
|
||||||
|
typeAnnotation.type === "TSTypeReference" &&
|
||||||
|
typeAnnotation.typeName.type === "Identifier" &&
|
||||||
|
typeAnnotation.typeName.name === "const"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOutermostAssertionInChain(node: TypeAssertionExpression): boolean {
|
||||||
|
let current: ESTree.Expression = node;
|
||||||
|
let parent = node.parent;
|
||||||
|
|
||||||
|
while (parent.type === "ParenthesizedExpression" && parent.expression === current) {
|
||||||
|
current = parent;
|
||||||
|
parent = parent.parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !isTypeAssertionExpression(parent) || parent.expression !== current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isForbiddenAssertionChain(node: TypeAssertionExpression): boolean {
|
||||||
|
let assertionCount = 0;
|
||||||
|
let hasNonConstAssertion = false;
|
||||||
|
let current: ESTree.Expression = node;
|
||||||
|
|
||||||
|
while (isTypeAssertionExpression(current)) {
|
||||||
|
assertionCount += 1;
|
||||||
|
hasNonConstAssertion ||= !isConstAssertion(current);
|
||||||
|
current = unwrapParenthesizedExpression(current.expression);
|
||||||
|
}
|
||||||
|
|
||||||
|
return assertionCount > 1 && hasNonConstAssertion;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Disallow nested TypeScript type assertions, while permitting chains made only of const assertions. */
|
||||||
|
export const noChainedTypeAssertionsRule = defineRule({
|
||||||
|
meta: {
|
||||||
|
type: "problem",
|
||||||
|
docs: {
|
||||||
|
description:
|
||||||
|
"Disallow chained TypeScript as and angle-bracket assertions, including parenthesized chains.",
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
chained:
|
||||||
|
"This assertion chain discards type evidence. Keep the original precise type, or parse untrusted input at its boundary before narrowing it.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createOnce(context) {
|
||||||
|
const checkTypeAssertion = (node: TypeAssertionExpression) => {
|
||||||
|
if (!isOutermostAssertionInChain(node) || !isForbiddenAssertionChain(node)) return;
|
||||||
|
context.report({ node, messageId: "chained" });
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
TSAsExpression: checkTypeAssertion,
|
||||||
|
TSTypeAssertion: checkTypeAssertion,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
import { defineRule } from "@oxlint/plugins";
|
||||||
|
|
||||||
|
import {
|
||||||
|
classifyUnsafeDictionaryValue,
|
||||||
|
classifyWideningTarget,
|
||||||
|
createTypeEnvironment,
|
||||||
|
isKnownEvidenceExpression,
|
||||||
|
type TypeEnvironment,
|
||||||
|
type WideningTarget,
|
||||||
|
} from "../shared/dictionary-types.ts";
|
||||||
|
import {
|
||||||
|
containsUnknownType,
|
||||||
|
functionParameterBindingName,
|
||||||
|
functionParameterTypeAnnotation,
|
||||||
|
} from "../shared/function-parameters.ts";
|
||||||
|
import { resolveVariable } from "../shared/scope.ts";
|
||||||
|
|
||||||
|
import type { ESTree, SourceCode, Variable } from "@oxlint/plugins";
|
||||||
|
|
||||||
|
type FunctionExpression = ESTree.ArrowFunctionExpression | ESTree.Function;
|
||||||
|
|
||||||
|
function unwrapExpression(expression: ESTree.Expression): ESTree.Expression {
|
||||||
|
let current = expression;
|
||||||
|
while (
|
||||||
|
current.type === "ParenthesizedExpression" ||
|
||||||
|
current.type === "TSAsExpression" ||
|
||||||
|
current.type === "TSSatisfiesExpression" ||
|
||||||
|
current.type === "TSTypeAssertion" ||
|
||||||
|
current.type === "TSNonNullExpression"
|
||||||
|
) {
|
||||||
|
current = current.expression;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function variableDeclarator(variable: Variable): ESTree.VariableDeclarator | null {
|
||||||
|
if (variable.defs.length !== 1) return null;
|
||||||
|
const [definition] = variable.defs;
|
||||||
|
return definition?.type === "Variable" && definition.node.type === "VariableDeclarator"
|
||||||
|
? definition.node
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStableConstVariable(variable: Variable, declarator: ESTree.VariableDeclarator): boolean {
|
||||||
|
return (
|
||||||
|
declarator.parent.type === "VariableDeclaration" &&
|
||||||
|
declarator.parent.kind === "const" &&
|
||||||
|
variable.references.every((reference) => reference.init || !reference.isWrite())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasKnownEvidence(
|
||||||
|
sourceCode: SourceCode,
|
||||||
|
expression: ESTree.Expression,
|
||||||
|
visitedVariables = new Set<Variable>(),
|
||||||
|
): boolean {
|
||||||
|
if (isKnownEvidenceExpression(expression)) return true;
|
||||||
|
const unwrapped = unwrapExpression(expression);
|
||||||
|
if (unwrapped.type !== "Identifier") return false;
|
||||||
|
const variable = resolveVariable(sourceCode, unwrapped);
|
||||||
|
if (variable === null || visitedVariables.has(variable)) return false;
|
||||||
|
const declarator = variableDeclarator(variable);
|
||||||
|
if (
|
||||||
|
declarator === null ||
|
||||||
|
declarator.init === null ||
|
||||||
|
!isStableConstVariable(variable, declarator)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
visitedVariables.add(variable);
|
||||||
|
return hasKnownEvidence(sourceCode, declarator.init, visitedVariables);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFunctionExpression(node: ESTree.Node): node is FunctionExpression {
|
||||||
|
return (
|
||||||
|
node.type === "ArrowFunctionExpression" ||
|
||||||
|
node.type === "FunctionDeclaration" ||
|
||||||
|
node.type === "FunctionExpression" ||
|
||||||
|
node.type === "TSDeclareFunction" ||
|
||||||
|
node.type === "TSEmptyBodyFunctionExpression"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function localFunctionForCall(
|
||||||
|
sourceCode: SourceCode,
|
||||||
|
callee: ESTree.Expression,
|
||||||
|
): FunctionExpression | null {
|
||||||
|
const unwrapped = unwrapExpression(callee);
|
||||||
|
if (isFunctionExpression(unwrapped)) return unwrapped;
|
||||||
|
if (unwrapped.type !== "Identifier") return null;
|
||||||
|
const variable = resolveVariable(sourceCode, unwrapped);
|
||||||
|
if (variable === null || variable.defs.length !== 1) return null;
|
||||||
|
const [definition] = variable.defs;
|
||||||
|
if (definition === undefined) return null;
|
||||||
|
if (definition.type === "FunctionName" && isFunctionExpression(definition.node)) {
|
||||||
|
return definition.node;
|
||||||
|
}
|
||||||
|
if (definition.type !== "Variable" || definition.node.type !== "VariableDeclarator") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const initializer = definition.node.init;
|
||||||
|
if (initializer === null) return null;
|
||||||
|
const unwrappedInitializer = unwrapExpression(initializer);
|
||||||
|
return isFunctionExpression(unwrappedInitializer) ? unwrappedInitializer : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function variableTypeAnnotation(
|
||||||
|
sourceCode: SourceCode,
|
||||||
|
variable: Variable,
|
||||||
|
): ESTree.TSTypeAnnotation | null {
|
||||||
|
if (variable.defs.length !== 1) return null;
|
||||||
|
const [definition] = variable.defs;
|
||||||
|
if (definition === undefined) return null;
|
||||||
|
if (
|
||||||
|
definition.type === "Variable" &&
|
||||||
|
definition.node.type === "VariableDeclarator" &&
|
||||||
|
definition.node.id.type === "Identifier"
|
||||||
|
) {
|
||||||
|
return definition.node.id.typeAnnotation ?? null;
|
||||||
|
}
|
||||||
|
if (definition.type !== "Parameter" || !isFunctionExpression(definition.node)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parameter = definition.node.params.find(
|
||||||
|
(candidate) =>
|
||||||
|
functionParameterBindingName(candidate, sourceCode) === variable.name,
|
||||||
|
);
|
||||||
|
return parameter === undefined ? null : (functionParameterTypeAnnotation(parameter) ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasInformativeType(
|
||||||
|
type: ESTree.TSType,
|
||||||
|
environment: TypeEnvironment,
|
||||||
|
): boolean {
|
||||||
|
return classifyUnsafeDictionaryValue(type, environment) === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasKnownCallArgumentEvidence(
|
||||||
|
sourceCode: SourceCode,
|
||||||
|
expression: ESTree.Expression,
|
||||||
|
environment: TypeEnvironment,
|
||||||
|
visitedVariables = new Set<Variable>(),
|
||||||
|
): boolean {
|
||||||
|
if (expression.type === "ParenthesizedExpression" || expression.type === "TSNonNullExpression") {
|
||||||
|
return hasKnownCallArgumentEvidence(
|
||||||
|
sourceCode,
|
||||||
|
expression.expression,
|
||||||
|
environment,
|
||||||
|
visitedVariables,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (expression.type === "TSAsExpression" || expression.type === "TSTypeAssertion") {
|
||||||
|
return hasInformativeType(expression.typeAnnotation, environment);
|
||||||
|
}
|
||||||
|
if (expression.type === "TSSatisfiesExpression") {
|
||||||
|
return hasKnownCallArgumentEvidence(
|
||||||
|
sourceCode,
|
||||||
|
expression.expression,
|
||||||
|
environment,
|
||||||
|
visitedVariables,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (expression.type === "CallExpression") {
|
||||||
|
const owner = localFunctionForCall(sourceCode, expression.callee);
|
||||||
|
const returnType = owner?.returnType?.typeAnnotation;
|
||||||
|
return returnType !== undefined && hasInformativeType(returnType, environment);
|
||||||
|
}
|
||||||
|
if (expression.type !== "Identifier") return isKnownEvidenceExpression(expression);
|
||||||
|
const variable = resolveVariable(sourceCode, expression);
|
||||||
|
if (variable === null || visitedVariables.has(variable)) return false;
|
||||||
|
const annotation = variableTypeAnnotation(sourceCode, variable);
|
||||||
|
if (annotation !== null) {
|
||||||
|
return hasInformativeType(annotation.typeAnnotation, environment);
|
||||||
|
}
|
||||||
|
const declarator = variableDeclarator(variable);
|
||||||
|
if (
|
||||||
|
declarator === null ||
|
||||||
|
declarator.init === null ||
|
||||||
|
!isStableConstVariable(variable, declarator)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
visitedVariables.add(variable);
|
||||||
|
return hasKnownCallArgumentEvidence(
|
||||||
|
sourceCode,
|
||||||
|
declarator.init,
|
||||||
|
environment,
|
||||||
|
visitedVariables,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function typePredicateSubjectIndex(
|
||||||
|
sourceCode: SourceCode,
|
||||||
|
owner: FunctionExpression,
|
||||||
|
): number | null {
|
||||||
|
const predicate = owner.returnType?.typeAnnotation;
|
||||||
|
if (predicate?.type !== "TSTypePredicate" || predicate.parameterName.type !== "Identifier") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const predicateParameterName = predicate.parameterName.name;
|
||||||
|
const index = owner.params.findIndex(
|
||||||
|
(parameter) =>
|
||||||
|
functionParameterBindingName(parameter, sourceCode) === predicateParameterName,
|
||||||
|
);
|
||||||
|
return index === -1 ? null : index;
|
||||||
|
}
|
||||||
|
|
||||||
|
function annotationTarget(
|
||||||
|
annotation: ESTree.TSTypeAnnotation | null | undefined,
|
||||||
|
environment: TypeEnvironment,
|
||||||
|
): WideningTarget | null {
|
||||||
|
return annotation === null || annotation === undefined
|
||||||
|
? null
|
||||||
|
: classifyWideningTarget(annotation.typeAnnotation, environment);
|
||||||
|
}
|
||||||
|
|
||||||
|
function enclosingFunction(node: ESTree.Node): FunctionExpression | null {
|
||||||
|
let current: ESTree.Node | null = node.parent;
|
||||||
|
while (current !== null && current.type !== "Program") {
|
||||||
|
if (
|
||||||
|
current.type === "ArrowFunctionExpression" ||
|
||||||
|
current.type === "FunctionDeclaration" ||
|
||||||
|
current.type === "FunctionExpression"
|
||||||
|
) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
current = current.parent;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceKeyName(sourceCode: SourceCode, key: ESTree.PropertyKey): string {
|
||||||
|
if (key.type === "Identifier" || key.type === "PrivateIdentifier") return key.name;
|
||||||
|
if (key.type === "Literal") return String(key.value);
|
||||||
|
return sourceCode.getText(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function functionName(sourceCode: SourceCode, owner: FunctionExpression | null): string {
|
||||||
|
if (owner === null) return "anonymous function";
|
||||||
|
if (owner.id !== null) return owner.id.name;
|
||||||
|
const parent = owner.parent;
|
||||||
|
if (parent.type === "VariableDeclarator" && parent.id.type === "Identifier")
|
||||||
|
return parent.id.name;
|
||||||
|
if (parent.type === "MethodDefinition") return sourceKeyName(sourceCode, parent.key);
|
||||||
|
return "anonymous function";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEmptyObjectExpression(expression: ESTree.Expression): boolean {
|
||||||
|
const unwrapped = unwrapExpression(expression);
|
||||||
|
return unwrapped.type === "ObjectExpression" && unwrapped.properties.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDictionaryAccumulatorTarget(destination: WideningTarget): boolean {
|
||||||
|
return destination.kind === "open dictionary" || destination.kind === "generic container";
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasParentAssertion(node: ESTree.Node): boolean {
|
||||||
|
return node.parent?.type === "TSAsExpression" || node.parent?.type === "TSTypeAssertion";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Detect sound syntactic cases where a known value is explicitly widened and loses evidence. */
|
||||||
|
export const noKnownValueWideningRule = defineRule({
|
||||||
|
meta: {
|
||||||
|
type: "problem",
|
||||||
|
docs: {
|
||||||
|
description:
|
||||||
|
"Disallow syntactically established values from flowing into explicitly broad or anonymous target types that discard useful evidence.",
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
widening:
|
||||||
|
"The explicit {{target}} type on {{subject}} discards known type evidence. Keep inference, validate with `satisfies`, or use a named owner contract.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createOnce(context) {
|
||||||
|
let environment: TypeEnvironment | null = null;
|
||||||
|
|
||||||
|
const reportFlow = (
|
||||||
|
expression: ESTree.Expression,
|
||||||
|
destination: WideningTarget | null,
|
||||||
|
subject: string,
|
||||||
|
) => {
|
||||||
|
if (destination === null) return;
|
||||||
|
if (
|
||||||
|
isDictionaryAccumulatorTarget(destination) &&
|
||||||
|
isEmptyObjectExpression(expression)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!hasKnownEvidence(context.sourceCode, expression)) return;
|
||||||
|
context.report({
|
||||||
|
node: expression,
|
||||||
|
messageId: "widening",
|
||||||
|
data: { subject, target: destination.kind },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const targetFromAnnotation = (annotation: ESTree.TSTypeAnnotation | null | undefined) =>
|
||||||
|
environment === null ? null : annotationTarget(annotation, environment);
|
||||||
|
|
||||||
|
return {
|
||||||
|
Program(node) {
|
||||||
|
environment = createTypeEnvironment(
|
||||||
|
node,
|
||||||
|
context.sourceCode.visitorKeys,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
VariableDeclarator(node) {
|
||||||
|
if (node.init === null || node.id.type !== "Identifier") return;
|
||||||
|
reportFlow(
|
||||||
|
node.init,
|
||||||
|
targetFromAnnotation(node.id.typeAnnotation),
|
||||||
|
`binding \`${node.id.name}\``,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
PropertyDefinition(node) {
|
||||||
|
if (node.value === null) return;
|
||||||
|
reportFlow(
|
||||||
|
node.value,
|
||||||
|
targetFromAnnotation(node.typeAnnotation),
|
||||||
|
`property \`${sourceKeyName(context.sourceCode, node.key)}\``,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
AccessorProperty(node) {
|
||||||
|
if (node.value === null) return;
|
||||||
|
reportFlow(
|
||||||
|
node.value,
|
||||||
|
targetFromAnnotation(node.typeAnnotation),
|
||||||
|
`property \`${sourceKeyName(context.sourceCode, node.key)}\``,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
AssignmentExpression(node) {
|
||||||
|
if (node.operator !== "=" || node.left.type !== "Identifier") return;
|
||||||
|
const variable = resolveVariable(context.sourceCode, node.left);
|
||||||
|
if (variable === null) return;
|
||||||
|
const declarator = variableDeclarator(variable);
|
||||||
|
if (declarator === null || declarator.id.type !== "Identifier") return;
|
||||||
|
reportFlow(
|
||||||
|
node.right,
|
||||||
|
targetFromAnnotation(declarator.id.typeAnnotation),
|
||||||
|
`binding \`${declarator.id.name}\``,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
CallExpression(node) {
|
||||||
|
if (environment === null) return;
|
||||||
|
const owner = localFunctionForCall(context.sourceCode, node.callee);
|
||||||
|
if (owner === null) return;
|
||||||
|
const parameterIndex = typePredicateSubjectIndex(context.sourceCode, owner);
|
||||||
|
if (parameterIndex === null) return;
|
||||||
|
const parameter = owner.params[parameterIndex];
|
||||||
|
const argument = node.arguments[parameterIndex];
|
||||||
|
if (parameter === undefined || argument === undefined || argument.type === "SpreadElement") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parameterAnnotation = functionParameterTypeAnnotation(parameter);
|
||||||
|
if (
|
||||||
|
parameterAnnotation === null ||
|
||||||
|
parameterAnnotation === undefined ||
|
||||||
|
!containsUnknownType(parameterAnnotation.typeAnnotation)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!hasKnownCallArgumentEvidence(
|
||||||
|
context.sourceCode,
|
||||||
|
argument,
|
||||||
|
environment,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.report({
|
||||||
|
node: argument,
|
||||||
|
messageId: "widening",
|
||||||
|
data: {
|
||||||
|
subject: `argument for parameter \`${functionParameterBindingName(parameter, context.sourceCode)}\` of \`${functionName(context.sourceCode, owner)}\``,
|
||||||
|
target: "unknown",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
ReturnStatement(node) {
|
||||||
|
if (node.argument === null) return;
|
||||||
|
const owner = enclosingFunction(node);
|
||||||
|
reportFlow(
|
||||||
|
node.argument,
|
||||||
|
targetFromAnnotation(owner?.returnType),
|
||||||
|
`return value of \`${functionName(context.sourceCode, owner)}\``,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
ArrowFunctionExpression(node) {
|
||||||
|
if (node.body.type === "BlockStatement") return;
|
||||||
|
reportFlow(
|
||||||
|
node.body,
|
||||||
|
targetFromAnnotation(node.returnType),
|
||||||
|
`return value of \`${functionName(context.sourceCode, node)}\``,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
TSAsExpression(node) {
|
||||||
|
if (environment === null || hasParentAssertion(node)) return;
|
||||||
|
reportFlow(
|
||||||
|
node.expression,
|
||||||
|
classifyWideningTarget(node.typeAnnotation, environment),
|
||||||
|
"assertion",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
TSTypeAssertion(node) {
|
||||||
|
if (environment === null || hasParentAssertion(node)) return;
|
||||||
|
reportFlow(
|
||||||
|
node.expression,
|
||||||
|
classifyWideningTarget(node.typeAnnotation, environment),
|
||||||
|
"assertion",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { defineRule } from "@oxlint/plugins";
|
||||||
|
|
||||||
|
import {
|
||||||
|
classifyUnsafeDictionary,
|
||||||
|
classifyUnsafeDictionaryValue,
|
||||||
|
createTypeEnvironment,
|
||||||
|
type TypeEnvironment,
|
||||||
|
} from "../shared/dictionary-types.ts";
|
||||||
|
import { visibleTypeAlias } from "../shared/type-alias-resolution.ts";
|
||||||
|
|
||||||
|
import type { ESTree } from "@oxlint/plugins";
|
||||||
|
|
||||||
|
const typeNodeKinds: ReadonlySet<string> = new Set([
|
||||||
|
"JSDocNonNullableType",
|
||||||
|
"JSDocNullableType",
|
||||||
|
"JSDocUnknownType",
|
||||||
|
"TSAnyKeyword",
|
||||||
|
"TSArrayType",
|
||||||
|
"TSBigIntKeyword",
|
||||||
|
"TSBooleanKeyword",
|
||||||
|
"TSConditionalType",
|
||||||
|
"TSConstructorType",
|
||||||
|
"TSFunctionType",
|
||||||
|
"TSImportType",
|
||||||
|
"TSIndexedAccessType",
|
||||||
|
"TSInferType",
|
||||||
|
"TSIntersectionType",
|
||||||
|
"TSIntrinsicKeyword",
|
||||||
|
"TSLiteralType",
|
||||||
|
"TSMappedType",
|
||||||
|
"TSNamedTupleMember",
|
||||||
|
"TSNeverKeyword",
|
||||||
|
"TSNullKeyword",
|
||||||
|
"TSNumberKeyword",
|
||||||
|
"TSObjectKeyword",
|
||||||
|
"TSParenthesizedType",
|
||||||
|
"TSStringKeyword",
|
||||||
|
"TSSymbolKeyword",
|
||||||
|
"TSTemplateLiteralType",
|
||||||
|
"TSThisType",
|
||||||
|
"TSTupleType",
|
||||||
|
"TSTypeLiteral",
|
||||||
|
"TSTypeOperator",
|
||||||
|
"TSTypePredicate",
|
||||||
|
"TSTypeQuery",
|
||||||
|
"TSTypeReference",
|
||||||
|
"TSUndefinedKeyword",
|
||||||
|
"TSUnionType",
|
||||||
|
"TSUnknownKeyword",
|
||||||
|
"TSVoidKeyword",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isTypeNode(node: ESTree.Node): node is ESTree.TSType {
|
||||||
|
return typeNodeKinds.has(node.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeReferenceName(type: ESTree.TSTypeReference): string | null {
|
||||||
|
return type.typeName.type === "Identifier" ? type.typeName.name : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInsideTypeAliasDeclaration(node: ESTree.Node): boolean {
|
||||||
|
let current: ESTree.Node | null = node.parent;
|
||||||
|
while (current !== null && current.type !== "Program") {
|
||||||
|
if (current.type === "TSTypeAliasDeclaration") return true;
|
||||||
|
current = current.parent;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainAliasConsumerUse(node: ESTree.TSType, environment: TypeEnvironment): boolean {
|
||||||
|
if (node.type !== "TSTypeReference" || node.typeArguments?.params.length) return false;
|
||||||
|
const name = typeReferenceName(node);
|
||||||
|
return (
|
||||||
|
name !== null &&
|
||||||
|
visibleTypeAlias(name, node, environment.typeAliases) !== null &&
|
||||||
|
!isInsideTypeAliasDeclaration(node)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInsideTypeParameterConstraint(node: ESTree.TSType): boolean {
|
||||||
|
let child: ESTree.Node = node;
|
||||||
|
let parent: ESTree.Node | null = child.parent;
|
||||||
|
while (parent !== null && parent.type !== "Program") {
|
||||||
|
if (parent.type === "TSTypeParameter" && parent.constraint === child) return true;
|
||||||
|
child = parent;
|
||||||
|
parent = child.parent;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldReportType(node: ESTree.TSType, environment: TypeEnvironment): boolean {
|
||||||
|
if (isInsideTypeParameterConstraint(node)) return false;
|
||||||
|
if (isPlainAliasConsumerUse(node, environment)) return false;
|
||||||
|
if (classifyUnsafeDictionary(node, environment) === null) return false;
|
||||||
|
let current: ESTree.Node | null = node.parent;
|
||||||
|
while (current !== null && current.type !== "Program") {
|
||||||
|
if (isTypeNode(current) && classifyUnsafeDictionary(current, environment) !== null)
|
||||||
|
return false;
|
||||||
|
current = current.parent;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Disallow object-dictionary contracts whose direct value type is an unsafe escape hatch. */
|
||||||
|
export const noUnsafeDictionaryTypeRule = defineRule({
|
||||||
|
meta: {
|
||||||
|
type: "problem",
|
||||||
|
docs: {
|
||||||
|
description:
|
||||||
|
"Disallow object-dictionary contracts whose direct value type is unknown, any, object, {}, or a union/alias containing one of those escape hatches.",
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
unsafeDictionary:
|
||||||
|
"This dictionary's {{value}} value type gives callers no concrete value contract. Use an owner/schema-derived value type; parse external payloads before insertion.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createOnce(context) {
|
||||||
|
let environment: TypeEnvironment | null = null;
|
||||||
|
const report = (node: ESTree.Node, value: string) => {
|
||||||
|
context.report({ node, messageId: "unsafeDictionary", data: { value } });
|
||||||
|
};
|
||||||
|
const reportIfUnsafe = (node: ESTree.TSType) => {
|
||||||
|
if (environment === null || !shouldReportType(node, environment)) return;
|
||||||
|
const unsafe = classifyUnsafeDictionary(node, environment);
|
||||||
|
if (unsafe === null) return;
|
||||||
|
report(node, unsafe.unsafeValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
Program(node) {
|
||||||
|
environment = createTypeEnvironment(
|
||||||
|
node,
|
||||||
|
context.sourceCode.visitorKeys,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
TSTypeReference: reportIfUnsafe,
|
||||||
|
TSTypeLiteral: reportIfUnsafe,
|
||||||
|
TSMappedType: reportIfUnsafe,
|
||||||
|
TSIndexSignature(node) {
|
||||||
|
if (
|
||||||
|
environment === null ||
|
||||||
|
node.typeAnnotation === null ||
|
||||||
|
node.parent.type === "TSTypeLiteral"
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
const unsafe = classifyUnsafeDictionaryValue(
|
||||||
|
node.typeAnnotation.typeAnnotation,
|
||||||
|
environment,
|
||||||
|
);
|
||||||
|
if (unsafe !== null) report(node, unsafe.unsafeValue);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
import { defineRule } from "@oxlint/plugins";
|
||||||
|
import type { ESTree, Variable } from "@oxlint/plugins";
|
||||||
|
|
||||||
|
type BroadTypeKind = "top" | "object" | "record";
|
||||||
|
|
||||||
|
type KnownValueEvidence = {
|
||||||
|
readonly type: ESTree.TSType | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const functionBoundaryTypes = new Set([
|
||||||
|
"ArrowFunctionExpression",
|
||||||
|
"FunctionDeclaration",
|
||||||
|
"FunctionExpression",
|
||||||
|
"TSDeclareFunction",
|
||||||
|
"TSEmptyBodyFunctionExpression",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function unwrapExpressionParentheses(expression: ESTree.Expression): ESTree.Expression {
|
||||||
|
let current = expression;
|
||||||
|
while (current.type === "ParenthesizedExpression") current = current.expression;
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrapTypeParentheses(type: ESTree.TSType): ESTree.TSType {
|
||||||
|
let current = type;
|
||||||
|
while (current.type === "TSParenthesizedType") current = current.typeAnnotation;
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeReferenceName(type: ESTree.TSTypeReference): string | null {
|
||||||
|
return type.typeName.type === "Identifier" ? type.typeName.name : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUnknownOrAnyType(type: ESTree.TSType): boolean {
|
||||||
|
const unwrapped = unwrapTypeParentheses(type);
|
||||||
|
return unwrapped.type === "TSUnknownKeyword" || unwrapped.type === "TSAnyKeyword";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBroadRecordKeyType(type: ESTree.TSType): boolean {
|
||||||
|
const unwrapped = unwrapTypeParentheses(type);
|
||||||
|
if (
|
||||||
|
unwrapped.type === "TSStringKeyword" ||
|
||||||
|
unwrapped.type === "TSNumberKeyword" ||
|
||||||
|
unwrapped.type === "TSSymbolKeyword"
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (unwrapped.type === "TSUnionType") return unwrapped.types.every(isBroadRecordKeyType);
|
||||||
|
return unwrapped.type === "TSTypeReference" && typeReferenceName(unwrapped) === "PropertyKey";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBroadRecordType(type: ESTree.TSType): boolean {
|
||||||
|
const unwrapped = unwrapTypeParentheses(type);
|
||||||
|
|
||||||
|
if (unwrapped.type === "TSTypeReference") {
|
||||||
|
if (typeReferenceName(unwrapped) === "Readonly") {
|
||||||
|
const [inner] = unwrapped.typeArguments?.params ?? [];
|
||||||
|
return inner !== undefined && isBroadRecordType(inner);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeReferenceName(unwrapped) !== "Record") return false;
|
||||||
|
const parameters = unwrapped.typeArguments?.params ?? [];
|
||||||
|
return (
|
||||||
|
parameters.length === 2 &&
|
||||||
|
parameters[0] !== undefined &&
|
||||||
|
parameters[1] !== undefined &&
|
||||||
|
isBroadRecordKeyType(parameters[0]) &&
|
||||||
|
isUnknownOrAnyType(parameters[1])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unwrapped.type !== "TSTypeLiteral" || unwrapped.members.length !== 1) return false;
|
||||||
|
const [member] = unwrapped.members;
|
||||||
|
const [parameter] = member?.type === "TSIndexSignature" ? member.parameters : [];
|
||||||
|
return (
|
||||||
|
member?.type === "TSIndexSignature" &&
|
||||||
|
member.parameters.length === 1 &&
|
||||||
|
parameter !== undefined &&
|
||||||
|
isBroadRecordKeyType(parameter.typeAnnotation.typeAnnotation) &&
|
||||||
|
isUnknownOrAnyType(member.typeAnnotation.typeAnnotation)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadTypeKind(type: ESTree.TSType): BroadTypeKind | null {
|
||||||
|
const unwrapped = unwrapTypeParentheses(type);
|
||||||
|
if (unwrapped.type === "TSUnknownKeyword" || unwrapped.type === "TSAnyKeyword") return "top";
|
||||||
|
if (unwrapped.type === "TSObjectKeyword") return "object";
|
||||||
|
return isBroadRecordType(unwrapped) ? "record" : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertedExpression(
|
||||||
|
node: ESTree.TSAsExpression | ESTree.TSTypeAssertion,
|
||||||
|
): ESTree.Expression {
|
||||||
|
return unwrapExpressionParentheses(node.expression);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertionFromExpression(
|
||||||
|
expression: ESTree.Expression,
|
||||||
|
): ESTree.TSAsExpression | ESTree.TSTypeAssertion | null {
|
||||||
|
const unwrapped = unwrapExpressionParentheses(expression);
|
||||||
|
return unwrapped.type === "TSAsExpression" || unwrapped.type === "TSTypeAssertion"
|
||||||
|
? unwrapped
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedTypeText(sourceText: string, type: ESTree.TSType): string {
|
||||||
|
return sourceText.slice(type.start, type.end).replaceAll(/\s+/gu, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function typesHaveSameSyntax(
|
||||||
|
sourceText: string,
|
||||||
|
left: ESTree.TSType | null,
|
||||||
|
right: ESTree.TSType,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
left !== null &&
|
||||||
|
normalizedTypeText(sourceText, unwrapTypeParentheses(left)) ===
|
||||||
|
normalizedTypeText(sourceText, unwrapTypeParentheses(right))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDefinitelyObjectType(type: ESTree.TSType): boolean {
|
||||||
|
const unwrapped = unwrapTypeParentheses(type);
|
||||||
|
switch (unwrapped.type) {
|
||||||
|
case "TSArrayType":
|
||||||
|
case "TSConstructorType":
|
||||||
|
case "TSFunctionType":
|
||||||
|
case "TSMappedType":
|
||||||
|
case "TSObjectKeyword":
|
||||||
|
case "TSTupleType":
|
||||||
|
return true;
|
||||||
|
case "TSTypeLiteral":
|
||||||
|
return unwrapped.members.length > 0;
|
||||||
|
case "TSIntersectionType":
|
||||||
|
return unwrapped.types.every(isDefinitelyObjectType);
|
||||||
|
case "TSTypeOperator":
|
||||||
|
return unwrapped.operator === "readonly" && isDefinitelyObjectType(unwrapped.typeAnnotation);
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDefinitelyNarrowerRecordType(type: ESTree.TSType): boolean {
|
||||||
|
const unwrapped = unwrapTypeParentheses(type);
|
||||||
|
if (unwrapped.type === "TSTypeLiteral") {
|
||||||
|
return unwrapped.members.some((member) => member.type !== "TSIndexSignature");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unwrapped.type !== "TSTypeReference") return false;
|
||||||
|
if (typeReferenceName(unwrapped) === "Readonly") {
|
||||||
|
const [inner] = unwrapped.typeArguments?.params ?? [];
|
||||||
|
return inner !== undefined && isDefinitelyNarrowerRecordType(inner);
|
||||||
|
}
|
||||||
|
if (typeReferenceName(unwrapped) !== "Record") return false;
|
||||||
|
|
||||||
|
const parameters = unwrapped.typeArguments?.params ?? [];
|
||||||
|
return (
|
||||||
|
parameters.length === 2 && parameters[1] !== undefined && !isUnknownOrAnyType(parameters[1])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function functionBoundary(node: ESTree.Node): ESTree.Node | null {
|
||||||
|
let current = node.parent;
|
||||||
|
while (current !== null && current.type !== "Program") {
|
||||||
|
if (functionBoundaryTypes.has(current.type)) return current;
|
||||||
|
current = current.parent;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvedVariableForIdentifier(
|
||||||
|
scopes: readonly {
|
||||||
|
readonly references: readonly {
|
||||||
|
readonly identifier: ESTree.Node;
|
||||||
|
readonly resolved: Variable | null;
|
||||||
|
}[];
|
||||||
|
}[],
|
||||||
|
identifier: ESTree.IdentifierReference,
|
||||||
|
): Variable | null {
|
||||||
|
for (const scope of scopes) {
|
||||||
|
const reference = scope.references.find(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.identifier.start === identifier.start &&
|
||||||
|
candidate.identifier.end === identifier.end,
|
||||||
|
);
|
||||||
|
if (reference !== undefined) return reference.resolved;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function variableDeclarator(variable: Variable): ESTree.VariableDeclarator | null {
|
||||||
|
for (const definition of variable.defs) {
|
||||||
|
if (definition.type === "Variable" && definition.node.type === "VariableDeclarator") {
|
||||||
|
return definition.node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function knownValueEvidence(
|
||||||
|
expression: ESTree.Expression,
|
||||||
|
scopes: Parameters<typeof resolvedVariableForIdentifier>[0],
|
||||||
|
boundary: ESTree.Node | null,
|
||||||
|
visitedVariables: ReadonlySet<Variable>,
|
||||||
|
): KnownValueEvidence | null {
|
||||||
|
const unwrapped = unwrapExpressionParentheses(expression);
|
||||||
|
|
||||||
|
if (unwrapped.type === "TSAsExpression" || unwrapped.type === "TSTypeAssertion") {
|
||||||
|
if (broadTypeKind(unwrapped.typeAnnotation) !== null) return null;
|
||||||
|
return { type: unwrapped.typeAnnotation };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unwrapped.type === "Literal" || unwrapped.type === "TemplateLiteral") {
|
||||||
|
return { type: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
unwrapped.type === "ArrayExpression" ||
|
||||||
|
unwrapped.type === "ArrowFunctionExpression" ||
|
||||||
|
unwrapped.type === "ClassExpression" ||
|
||||||
|
unwrapped.type === "FunctionExpression" ||
|
||||||
|
unwrapped.type === "NewExpression" ||
|
||||||
|
unwrapped.type === "ObjectExpression"
|
||||||
|
) {
|
||||||
|
return { type: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unwrapped.type !== "Identifier") return null;
|
||||||
|
const variable = resolvedVariableForIdentifier(scopes, unwrapped);
|
||||||
|
if (variable === null || visitedVariables.has(variable)) return null;
|
||||||
|
|
||||||
|
const annotatedIdentifier = variable.identifiers.find(
|
||||||
|
(identifier) => identifier.typeAnnotation !== null && identifier.typeAnnotation !== undefined,
|
||||||
|
);
|
||||||
|
const annotation = annotatedIdentifier?.typeAnnotation?.typeAnnotation;
|
||||||
|
if (annotation !== undefined && annotatedIdentifier !== undefined) {
|
||||||
|
if (functionBoundary(annotatedIdentifier) !== boundary || broadTypeKind(annotation) !== null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { type: annotation };
|
||||||
|
}
|
||||||
|
|
||||||
|
const declarator = variableDeclarator(variable);
|
||||||
|
if (
|
||||||
|
declarator === null ||
|
||||||
|
declarator.parent.type !== "VariableDeclaration" ||
|
||||||
|
declarator.parent.kind !== "const" ||
|
||||||
|
declarator.init === null ||
|
||||||
|
variable.references.some((reference) => reference.isWrite() && !reference.init) ||
|
||||||
|
functionBoundary(declarator) !== boundary
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return knownValueEvidence(
|
||||||
|
declarator.init,
|
||||||
|
scopes,
|
||||||
|
boundary,
|
||||||
|
new Set([...visitedVariables, variable]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function widenedBinding(
|
||||||
|
variable: Variable,
|
||||||
|
scopes: Parameters<typeof resolvedVariableForIdentifier>[0],
|
||||||
|
): {
|
||||||
|
readonly broadKind: BroadTypeKind;
|
||||||
|
readonly evidence: KnownValueEvidence;
|
||||||
|
readonly declaredAt: number;
|
||||||
|
readonly boundary: ESTree.Node | null;
|
||||||
|
} | null {
|
||||||
|
const declarator = variableDeclarator(variable);
|
||||||
|
if (
|
||||||
|
declarator === null ||
|
||||||
|
declarator.parent.type !== "VariableDeclaration" ||
|
||||||
|
declarator.parent.kind !== "const" ||
|
||||||
|
declarator.id.type !== "Identifier" ||
|
||||||
|
declarator.init === null ||
|
||||||
|
variable.references.some((reference) => reference.isWrite() && !reference.init)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const boundary = functionBoundary(declarator);
|
||||||
|
const declaredType = declarator.id.typeAnnotation?.typeAnnotation;
|
||||||
|
const initializerAssertion = assertionFromExpression(declarator.init);
|
||||||
|
const initializerBroadKind =
|
||||||
|
initializerAssertion === null ? null : broadTypeKind(initializerAssertion.typeAnnotation);
|
||||||
|
const declaredBroadKind = declaredType === undefined ? null : broadTypeKind(declaredType);
|
||||||
|
const broadKind = declaredBroadKind ?? initializerBroadKind;
|
||||||
|
if (broadKind === null) return null;
|
||||||
|
|
||||||
|
const originalExpression =
|
||||||
|
initializerAssertion !== null && initializerBroadKind !== null
|
||||||
|
? assertedExpression(initializerAssertion)
|
||||||
|
: declarator.init;
|
||||||
|
const evidence = knownValueEvidence(originalExpression, scopes, boundary, new Set([variable]));
|
||||||
|
return evidence === null ? null : { broadKind, evidence, declaredAt: declarator.end, boundary };
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertionIsNarrower(
|
||||||
|
sourceText: string,
|
||||||
|
broadKind: BroadTypeKind,
|
||||||
|
evidence: KnownValueEvidence,
|
||||||
|
assertedType: ESTree.TSType,
|
||||||
|
): boolean {
|
||||||
|
if (broadTypeKind(assertedType) !== null) return false;
|
||||||
|
if (broadKind === "top") return true;
|
||||||
|
if (typesHaveSameSyntax(sourceText, evidence.type, assertedType)) return true;
|
||||||
|
if (broadKind === "object") return isDefinitelyObjectType(assertedType);
|
||||||
|
return isDefinitelyNarrowerRecordType(assertedType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Detect immutable local bindings that erase a known type and are later asserted back to a narrower type. */
|
||||||
|
export const noWidenThenAssertRule = defineRule({
|
||||||
|
meta: {
|
||||||
|
type: "problem",
|
||||||
|
docs: {
|
||||||
|
description:
|
||||||
|
"Disallow local const flows that explicitly widen a known value before asserting the widened binding to a narrower type.",
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
widenThenAssert:
|
||||||
|
'Binding "{{name}}" discards type evidence and later recreates it with an assertion. Keep the precise type from initialization through use; parse boundary input once.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createOnce(context) {
|
||||||
|
let scopes: Parameters<typeof resolvedVariableForIdentifier>[0] = [];
|
||||||
|
|
||||||
|
const checkAssertion = (node: ESTree.TSAsExpression | ESTree.TSTypeAssertion) => {
|
||||||
|
const expression = assertedExpression(node);
|
||||||
|
if (expression.type !== "Identifier") return;
|
||||||
|
|
||||||
|
const variable = resolvedVariableForIdentifier(scopes, expression);
|
||||||
|
if (variable === null) return;
|
||||||
|
const widened = widenedBinding(variable, scopes);
|
||||||
|
if (
|
||||||
|
widened === null ||
|
||||||
|
node.start <= widened.declaredAt ||
|
||||||
|
functionBoundary(node) !== widened.boundary ||
|
||||||
|
!assertionIsNarrower(
|
||||||
|
context.sourceCode.text,
|
||||||
|
widened.broadKind,
|
||||||
|
widened.evidence,
|
||||||
|
node.typeAnnotation,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
messageId: "widenThenAssert",
|
||||||
|
data: { name: expression.name },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
Program() {
|
||||||
|
scopes = context.sourceCode.scopeManager.scopes;
|
||||||
|
},
|
||||||
|
TSAsExpression: checkAssertion,
|
||||||
|
TSTypeAssertion: checkAssertion,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { defineRule } from "@oxlint/plugins";
|
||||||
|
|
||||||
|
import type { ESTree, SourceCode } from "@oxlint/plugins";
|
||||||
|
|
||||||
|
type TypeAssertion = ESTree.TSAsExpression | ESTree.TSTypeAssertion;
|
||||||
|
|
||||||
|
const DEFAULT_SAFETY_MARKERS = ["SAFETY"] as const;
|
||||||
|
|
||||||
|
const commentOwnerKinds = new Set([
|
||||||
|
"ExpressionStatement",
|
||||||
|
"PropertyDefinition",
|
||||||
|
"ReturnStatement",
|
||||||
|
"ThrowStatement",
|
||||||
|
"VariableDeclaration",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isConstAssertion(node: TypeAssertion): boolean {
|
||||||
|
return (
|
||||||
|
node.typeAnnotation.type === "TSTypeReference" &&
|
||||||
|
node.typeAnnotation.typeName.type === "Identifier" &&
|
||||||
|
node.typeAnnotation.typeName.name === "const"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function configuredSafetyMarkers(option: unknown): readonly string[] {
|
||||||
|
if (typeof option !== "object" || option === null || !("markers" in option)) {
|
||||||
|
return DEFAULT_SAFETY_MARKERS;
|
||||||
|
}
|
||||||
|
const configured = option.markers;
|
||||||
|
if (!Array.isArray(configured)) return DEFAULT_SAFETY_MARKERS;
|
||||||
|
const markers = configured.flatMap((marker) =>
|
||||||
|
typeof marker === "string" && marker.trim().length > 0 ? [marker.trim()] : [],
|
||||||
|
);
|
||||||
|
return markers.length > 0 ? markers : DEFAULT_SAFETY_MARKERS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function markerPattern(markers: readonly string[]): RegExp {
|
||||||
|
const alternation = markers
|
||||||
|
.map((marker) => marker.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`))
|
||||||
|
.join("|");
|
||||||
|
return new RegExp(
|
||||||
|
String.raw`(?:^|[^\p{L}\p{N}_])(?:${alternation})\s*:\s*\S`,
|
||||||
|
"u",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasSafetyJustificationBefore(
|
||||||
|
sourceCode: SourceCode,
|
||||||
|
owner: ESTree.Node,
|
||||||
|
assertion: TypeAssertion,
|
||||||
|
pattern: RegExp,
|
||||||
|
): boolean {
|
||||||
|
return sourceCode
|
||||||
|
.getCommentsBefore(owner)
|
||||||
|
.some(
|
||||||
|
(comment) => comment.end <= assertion.start && pattern.test(comment.value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasSafetyComment(
|
||||||
|
sourceCode: SourceCode,
|
||||||
|
node: TypeAssertion,
|
||||||
|
pattern: RegExp,
|
||||||
|
): boolean {
|
||||||
|
let current: ESTree.Node = node;
|
||||||
|
while (true) {
|
||||||
|
if (hasSafetyJustificationBefore(sourceCode, current, node, pattern)) return true;
|
||||||
|
if (commentOwnerKinds.has(current.type)) {
|
||||||
|
const exportDeclaration = current.parent;
|
||||||
|
return (
|
||||||
|
exportDeclaration.type === "ExportNamedDeclaration" &&
|
||||||
|
exportDeclaration.declaration === current &&
|
||||||
|
hasSafetyJustificationBefore(sourceCode, exportDeclaration, node, pattern)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (current.parent.type === "Program") return false;
|
||||||
|
current = current.parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Require every non-const type assertion to state the invariant TypeScript cannot express. */
|
||||||
|
export const requireSafetyCommentForTypeAssertionRule = defineRule({
|
||||||
|
meta: {
|
||||||
|
type: "problem",
|
||||||
|
docs: {
|
||||||
|
description:
|
||||||
|
"Require a nearby SAFETY comment for every TypeScript type assertion except const assertions.",
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
missingSafetyComment:
|
||||||
|
"This type assertion has no `{{marker}}:` justification. State the checked invariant immediately before the assertion or its containing statement.",
|
||||||
|
},
|
||||||
|
schema: [
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
markers: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string", minLength: 1 },
|
||||||
|
minItems: 1,
|
||||||
|
uniqueItems: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
defaultOptions: [{ markers: ["SAFETY"] }],
|
||||||
|
},
|
||||||
|
createOnce(context) {
|
||||||
|
const patterns = new Map<string, RegExp>();
|
||||||
|
|
||||||
|
const checkAssertion = (node: TypeAssertion) => {
|
||||||
|
if (isConstAssertion(node)) return;
|
||||||
|
const markers = configuredSafetyMarkers(context.options?.[0]);
|
||||||
|
const patternKey = markers.join("\u0000");
|
||||||
|
const pattern = patterns.get(patternKey) ?? markerPattern(markers);
|
||||||
|
patterns.set(patternKey, pattern);
|
||||||
|
if (hasSafetyComment(context.sourceCode, node, pattern)) return;
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
messageId: "missingSafetyComment",
|
||||||
|
data: { marker: markers[0] ?? DEFAULT_SAFETY_MARKERS[0] },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
TSAsExpression: checkAssertion,
|
||||||
|
TSTypeAssertion: checkAssertion,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,515 @@
|
|||||||
|
import type { ESTree } from "@oxlint/plugins";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createTypeAliasEnvironment,
|
||||||
|
hasVisibleTypeBinding,
|
||||||
|
visibleTypeAlias,
|
||||||
|
type TypeAliasEnvironment as LexicalTypeAliasEnvironment,
|
||||||
|
} from "./type-alias-resolution.ts";
|
||||||
|
|
||||||
|
const BUILT_INS = new Set([
|
||||||
|
"Record",
|
||||||
|
"Readonly",
|
||||||
|
"Partial",
|
||||||
|
"Required",
|
||||||
|
"Pick",
|
||||||
|
"Omit",
|
||||||
|
"PropertyKey",
|
||||||
|
"NonNullable",
|
||||||
|
]);
|
||||||
|
const TRANSPARENT_WRAPPERS = new Set(["Readonly", "Partial", "Required", "NonNullable"]);
|
||||||
|
|
||||||
|
type TypeAliasEnvironment = ReadonlyMap<string, ESTree.TSType>;
|
||||||
|
|
||||||
|
type ResolvedType = {
|
||||||
|
readonly type: ESTree.TSType;
|
||||||
|
readonly substitutions: TypeAliasEnvironment;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UnsafeDictionary = {
|
||||||
|
readonly kind: "unsafe-dictionary";
|
||||||
|
readonly unsafeValue: "any" | "empty-object" | "object" | "union" | "unknown";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WideningTargetKind =
|
||||||
|
| "anonymous object"
|
||||||
|
| "generic container"
|
||||||
|
| "object"
|
||||||
|
| "open dictionary"
|
||||||
|
| "unknown";
|
||||||
|
|
||||||
|
export type WideningTarget = {
|
||||||
|
readonly kind: WideningTargetKind;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TypeEnvironment = {
|
||||||
|
readonly interfaces: ReadonlyMap<string, readonly ESTree.TSInterfaceDeclaration[]>;
|
||||||
|
readonly typeAliases: LexicalTypeAliasEnvironment;
|
||||||
|
};
|
||||||
|
|
||||||
|
function declaredStatement(statement: ESTree.Statement): ESTree.Node | null {
|
||||||
|
return statement.type === "ExportNamedDeclaration" ||
|
||||||
|
statement.type === "ExportDefaultDeclaration"
|
||||||
|
? (statement.declaration ?? null)
|
||||||
|
: statement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTypeEnvironment(
|
||||||
|
program: ESTree.Program,
|
||||||
|
visitorKeys: Readonly<Record<string, readonly string[]>>,
|
||||||
|
): TypeEnvironment {
|
||||||
|
const interfaces = new Map<string, ESTree.TSInterfaceDeclaration[]>();
|
||||||
|
|
||||||
|
for (const statement of program.body) {
|
||||||
|
const declaration = declaredStatement(statement);
|
||||||
|
if (declaration?.type !== "TSInterfaceDeclaration") continue;
|
||||||
|
const declarations = interfaces.get(declaration.id.name) ?? [];
|
||||||
|
declarations.push(declaration);
|
||||||
|
interfaces.set(declaration.id.name, declarations);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
interfaces,
|
||||||
|
typeAliases: createTypeAliasEnvironment(program, visitorKeys),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeReferenceName(type: ESTree.TSTypeReference): string | null {
|
||||||
|
return type.typeName.type === "Identifier" ? type.typeName.name : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBuiltIn(
|
||||||
|
name: string,
|
||||||
|
use: ESTree.Node,
|
||||||
|
environment: TypeEnvironment,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
BUILT_INS.has(name) &&
|
||||||
|
!hasVisibleTypeBinding(name, use, environment.typeAliases)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUnappliedReferenceTo(type: ESTree.TSType, name: string): boolean {
|
||||||
|
const unwrapped = unwrapTransparentType(type);
|
||||||
|
return (
|
||||||
|
unwrapped.type === "TSTypeReference" &&
|
||||||
|
typeReferenceName(unwrapped) === name &&
|
||||||
|
(unwrapped.typeArguments === null ||
|
||||||
|
unwrapped.typeArguments === undefined ||
|
||||||
|
unwrapped.typeArguments.params.length === 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrapTransparentType(type: ESTree.TSType): ESTree.TSType {
|
||||||
|
let current = type;
|
||||||
|
while (
|
||||||
|
current.type === "TSParenthesizedType" ||
|
||||||
|
(current.type === "TSTypeOperator" && current.operator === "readonly")
|
||||||
|
) {
|
||||||
|
current = current.typeAnnotation;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNeverType(type: ESTree.TSType): boolean {
|
||||||
|
return unwrapTransparentType(type).type === "TSNeverKeyword";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEffectivelyEmptyMember(member: ESTree.TSSignature): boolean {
|
||||||
|
return (
|
||||||
|
member.type === "TSPropertySignature" &&
|
||||||
|
member.optional === true &&
|
||||||
|
member.typeAnnotation !== null &&
|
||||||
|
member.typeAnnotation !== undefined &&
|
||||||
|
isNeverType(member.typeAnnotation.typeAnnotation)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEffectivelyEmptyTypeLiteral(type: ESTree.TSTypeLiteral): boolean {
|
||||||
|
return type.members.length === 0 || type.members.every(isEffectivelyEmptyMember);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEffectivelyEmptyInterface(
|
||||||
|
declarations: readonly ESTree.TSInterfaceDeclaration[],
|
||||||
|
): boolean {
|
||||||
|
if (declarations.length !== 1) return false;
|
||||||
|
const [type] = declarations;
|
||||||
|
return (
|
||||||
|
type !== undefined &&
|
||||||
|
type.extends.length === 0 &&
|
||||||
|
(type.body.body.length === 0 || type.body.body.every(isEffectivelyEmptyMember))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvedSubstitutionArgument(
|
||||||
|
type: ESTree.TSType,
|
||||||
|
base: TypeAliasEnvironment,
|
||||||
|
resolving: ReadonlySet<string> = new Set(),
|
||||||
|
): ESTree.TSType {
|
||||||
|
const unwrapped = unwrapTransparentType(type);
|
||||||
|
if (unwrapped.type !== "TSTypeReference") return type;
|
||||||
|
const name = typeReferenceName(unwrapped);
|
||||||
|
if (name === null || resolving.has(name)) return type;
|
||||||
|
const substitution = base.get(name);
|
||||||
|
if (substitution === undefined) return type;
|
||||||
|
const nextResolving = new Set(resolving);
|
||||||
|
nextResolving.add(name);
|
||||||
|
return resolvedSubstitutionArgument(substitution, base, nextResolving);
|
||||||
|
}
|
||||||
|
|
||||||
|
function aliasSubstitution(
|
||||||
|
alias: ESTree.TSTypeAliasDeclaration,
|
||||||
|
type: ESTree.TSTypeReference,
|
||||||
|
base: TypeAliasEnvironment,
|
||||||
|
): TypeAliasEnvironment | null {
|
||||||
|
const parameters = alias.typeParameters?.params ?? [];
|
||||||
|
const arguments_ = type.typeArguments?.params ?? [];
|
||||||
|
const next = new Map(base);
|
||||||
|
for (const [index, parameter] of parameters.entries()) {
|
||||||
|
const argument = arguments_[index] ?? parameter.default;
|
||||||
|
if (argument === null || argument === undefined) return null;
|
||||||
|
next.set(parameter.name.name, resolvedSubstitutionArgument(argument, next));
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unsafeDirectValue(
|
||||||
|
type: ESTree.TSType,
|
||||||
|
environment: TypeEnvironment,
|
||||||
|
substitutions: TypeAliasEnvironment,
|
||||||
|
resolvingAliases: ReadonlySet<string>,
|
||||||
|
): UnsafeDictionary["unsafeValue"] | null {
|
||||||
|
const unwrapped = unwrapTransparentType(type);
|
||||||
|
if (unwrapped.type === "TSUnknownKeyword") return "unknown";
|
||||||
|
if (unwrapped.type === "TSAnyKeyword") return "any";
|
||||||
|
if (unwrapped.type === "TSObjectKeyword") return "object";
|
||||||
|
if (unwrapped.type === "TSTypeLiteral" && isEffectivelyEmptyTypeLiteral(unwrapped))
|
||||||
|
return "empty-object";
|
||||||
|
if (unwrapped.type === "TSUnionType") {
|
||||||
|
return unwrapped.types.some(
|
||||||
|
(member) => unsafeDirectValue(member, environment, substitutions, resolvingAliases) !== null,
|
||||||
|
)
|
||||||
|
? "union"
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
if (unwrapped.type === "TSIntersectionType") {
|
||||||
|
const unsafeMembers = unwrapped.types.map((member) =>
|
||||||
|
unsafeDirectValue(member, environment, substitutions, resolvingAliases),
|
||||||
|
);
|
||||||
|
if (unsafeMembers.includes("any")) return "any";
|
||||||
|
return unsafeMembers.length > 0 && unsafeMembers.every((member) => member !== null)
|
||||||
|
? unsafeMembers[0]
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
if (unwrapped.type !== "TSTypeReference") return null;
|
||||||
|
const name = typeReferenceName(unwrapped);
|
||||||
|
if (name === null) return null;
|
||||||
|
if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, unwrapped, environment)) {
|
||||||
|
const wrapped = unwrapped.typeArguments?.params[0];
|
||||||
|
return wrapped === undefined
|
||||||
|
? null
|
||||||
|
: unsafeDirectValue(wrapped, environment, substitutions, resolvingAliases);
|
||||||
|
}
|
||||||
|
const substitution = substitutions.get(name);
|
||||||
|
if (substitution !== undefined) {
|
||||||
|
return isUnappliedReferenceTo(substitution, name)
|
||||||
|
? null
|
||||||
|
: unsafeDirectValue(substitution, environment, substitutions, resolvingAliases);
|
||||||
|
}
|
||||||
|
const interfaceDeclarations = environment.interfaces.get(name);
|
||||||
|
if (interfaceDeclarations !== undefined) {
|
||||||
|
return isEffectivelyEmptyInterface(interfaceDeclarations) ? "empty-object" : null;
|
||||||
|
}
|
||||||
|
const alias = visibleTypeAlias(name, unwrapped, environment.typeAliases);
|
||||||
|
if (alias === null || resolvingAliases.has(name)) return null;
|
||||||
|
const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions);
|
||||||
|
if (nextSubstitutions === null) return null;
|
||||||
|
const nextResolving = new Set(resolvingAliases);
|
||||||
|
nextResolving.add(name);
|
||||||
|
return unsafeDirectValue(alias.typeAnnotation, environment, nextSubstitutions, nextResolving);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dictionaryValueTypes(
|
||||||
|
type: ESTree.TSType,
|
||||||
|
environment: TypeEnvironment,
|
||||||
|
substitutions: TypeAliasEnvironment,
|
||||||
|
resolvingAliases: ReadonlySet<string>,
|
||||||
|
): readonly ResolvedType[] {
|
||||||
|
const unwrapped = unwrapTransparentType(type);
|
||||||
|
|
||||||
|
if (unwrapped.type === "TSTypeLiteral") {
|
||||||
|
return unwrapped.members.flatMap((member): readonly ResolvedType[] =>
|
||||||
|
member.type === "TSIndexSignature" && member.typeAnnotation !== null
|
||||||
|
? [{ type: member.typeAnnotation.typeAnnotation, substitutions }]
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unwrapped.type === "TSMappedType") {
|
||||||
|
return unwrapped.typeAnnotation === null
|
||||||
|
? []
|
||||||
|
: [{ type: unwrapped.typeAnnotation, substitutions }];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unwrapped.type !== "TSTypeReference") return [];
|
||||||
|
const name = typeReferenceName(unwrapped);
|
||||||
|
if (name === null) return [];
|
||||||
|
|
||||||
|
const substitution = substitutions.get(name);
|
||||||
|
if (substitution !== undefined) {
|
||||||
|
return isUnappliedReferenceTo(substitution, name)
|
||||||
|
? []
|
||||||
|
: dictionaryValueTypes(substitution, environment, substitutions, resolvingAliases);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, unwrapped, environment)) {
|
||||||
|
const wrapped = unwrapped.typeArguments?.params[0];
|
||||||
|
return wrapped === undefined
|
||||||
|
? []
|
||||||
|
: dictionaryValueTypes(wrapped, environment, substitutions, resolvingAliases);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === "Record" && isBuiltIn(name, unwrapped, environment)) {
|
||||||
|
const value = unwrapped.typeArguments?.params[1] ?? null;
|
||||||
|
return value === null ? [] : [{ type: value, substitutions }];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(name === "Pick" || name === "Omit") &&
|
||||||
|
isBuiltIn(name, unwrapped, environment)
|
||||||
|
) {
|
||||||
|
const source = unwrapped.typeArguments?.params[0];
|
||||||
|
return source === undefined
|
||||||
|
? []
|
||||||
|
: dictionaryValueTypes(source, environment, substitutions, resolvingAliases);
|
||||||
|
}
|
||||||
|
|
||||||
|
const alias = visibleTypeAlias(name, unwrapped, environment.typeAliases);
|
||||||
|
if (alias === null || resolvingAliases.has(name)) return [];
|
||||||
|
const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions);
|
||||||
|
if (nextSubstitutions === null) return [];
|
||||||
|
const nextResolving = new Set(resolvingAliases);
|
||||||
|
nextResolving.add(name);
|
||||||
|
return dictionaryValueTypes(alias.typeAnnotation, environment, nextSubstitutions, nextResolving);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function classifyUnsafeDictionaryValue(
|
||||||
|
valueType: ESTree.TSType,
|
||||||
|
environment: TypeEnvironment,
|
||||||
|
): UnsafeDictionary | null {
|
||||||
|
const unsafeValue = unsafeDirectValue(valueType, environment, new Map(), new Set());
|
||||||
|
return unsafeValue === null ? null : { kind: "unsafe-dictionary", unsafeValue };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function classifyUnsafeDictionary(
|
||||||
|
type: ESTree.TSType,
|
||||||
|
environment: TypeEnvironment,
|
||||||
|
): UnsafeDictionary | null {
|
||||||
|
for (const valueType of dictionaryValueTypes(type, environment, new Map(), new Set())) {
|
||||||
|
const unsafeValue = unsafeDirectValue(
|
||||||
|
valueType.type,
|
||||||
|
environment,
|
||||||
|
valueType.substitutions,
|
||||||
|
new Set(),
|
||||||
|
);
|
||||||
|
if (unsafeValue !== null) return { kind: "unsafe-dictionary", unsafeValue };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function classifyWideningTarget(
|
||||||
|
type: ESTree.TSType,
|
||||||
|
environment: TypeEnvironment,
|
||||||
|
): WideningTarget | null {
|
||||||
|
const unwrapped = unwrapTransparentType(type);
|
||||||
|
if (unwrapped.type === "TSUnknownKeyword") return { kind: "unknown" };
|
||||||
|
if (unwrapped.type === "TSObjectKeyword") return { kind: "object" };
|
||||||
|
if (unwrapped.type === "TSTypeLiteral") {
|
||||||
|
return unwrapped.members.some((member) => member.type === "TSIndexSignature")
|
||||||
|
? { kind: "open dictionary" }
|
||||||
|
: unwrapped.members.length > 0
|
||||||
|
? { kind: "anonymous object" }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
if (unwrapped.type === "TSMappedType") return { kind: "open dictionary" };
|
||||||
|
if (unwrapped.type !== "TSTypeReference") return null;
|
||||||
|
const name = typeReferenceName(unwrapped);
|
||||||
|
if (name === null) return null;
|
||||||
|
if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, unwrapped, environment)) {
|
||||||
|
const wrapped = unwrapped.typeArguments?.params[0];
|
||||||
|
return wrapped === undefined ? null : classifyWideningTarget(wrapped, environment);
|
||||||
|
}
|
||||||
|
if (name === "Record" && isBuiltIn(name, unwrapped, environment)) {
|
||||||
|
return hasBroadRecordKey(unwrapped, environment, new Map())
|
||||||
|
? { kind: "open dictionary" }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
const alias = visibleTypeAlias(name, unwrapped, environment.typeAliases);
|
||||||
|
if (alias === null) return null;
|
||||||
|
if ((alias.typeParameters?.params.length ?? 0) > 0) {
|
||||||
|
const substitutions = aliasSubstitution(alias, unwrapped, new Map());
|
||||||
|
const resolved =
|
||||||
|
substitutions === null
|
||||||
|
? null
|
||||||
|
: classifyAliasBroadTarget(
|
||||||
|
alias.typeAnnotation,
|
||||||
|
environment,
|
||||||
|
substitutions,
|
||||||
|
new Set([name]),
|
||||||
|
);
|
||||||
|
return resolved?.kind === "open dictionary" ? { kind: "generic container" } : null;
|
||||||
|
}
|
||||||
|
const substitutions = aliasSubstitution(alias, unwrapped, new Map());
|
||||||
|
if (substitutions === null) return null;
|
||||||
|
const resolved = classifyAliasBroadTarget(
|
||||||
|
alias.typeAnnotation,
|
||||||
|
environment,
|
||||||
|
substitutions,
|
||||||
|
new Set([name]),
|
||||||
|
);
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasBroadRecordKey(
|
||||||
|
type: ESTree.TSTypeReference,
|
||||||
|
environment: TypeEnvironment,
|
||||||
|
substitutions: TypeAliasEnvironment,
|
||||||
|
): boolean {
|
||||||
|
const key = type.typeArguments?.params[0];
|
||||||
|
return key === undefined || isBroadMappedKey(key, environment, substitutions);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBroadMappedKey(
|
||||||
|
type: ESTree.TSType,
|
||||||
|
environment: TypeEnvironment,
|
||||||
|
substitutions: TypeAliasEnvironment,
|
||||||
|
visitedAliases: ReadonlySet<string> = new Set(),
|
||||||
|
): boolean {
|
||||||
|
const unwrapped = unwrapTransparentType(type);
|
||||||
|
if (
|
||||||
|
unwrapped.type === "TSStringKeyword" ||
|
||||||
|
unwrapped.type === "TSNumberKeyword" ||
|
||||||
|
unwrapped.type === "TSSymbolKeyword"
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (unwrapped.type === "TSUnionType") {
|
||||||
|
return unwrapped.types.some((member) =>
|
||||||
|
isBroadMappedKey(member, environment, substitutions, visitedAliases),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (unwrapped.type !== "TSTypeReference") return false;
|
||||||
|
const name = typeReferenceName(unwrapped);
|
||||||
|
if (name === null) return false;
|
||||||
|
const substitution = substitutions.get(name);
|
||||||
|
if (substitution !== undefined && !isUnappliedReferenceTo(substitution, name)) {
|
||||||
|
return isBroadMappedKey(substitution, environment, substitutions, visitedAliases);
|
||||||
|
}
|
||||||
|
if (name === "PropertyKey" && isBuiltIn(name, unwrapped, environment)) return true;
|
||||||
|
const alias = visibleTypeAlias(name, unwrapped, environment.typeAliases);
|
||||||
|
if (
|
||||||
|
alias === null ||
|
||||||
|
(alias.typeParameters?.params.length ?? 0) > 0 ||
|
||||||
|
visitedAliases.has(name)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const nextVisited = new Set(visitedAliases);
|
||||||
|
nextVisited.add(name);
|
||||||
|
return isBroadMappedKey(alias.typeAnnotation, environment, substitutions, nextVisited);
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyAliasBroadTarget(
|
||||||
|
type: ESTree.TSType,
|
||||||
|
environment: TypeEnvironment,
|
||||||
|
substitutions: TypeAliasEnvironment,
|
||||||
|
resolvingAliases: ReadonlySet<string>,
|
||||||
|
): WideningTarget | null {
|
||||||
|
const unwrapped = unwrapTransparentType(type);
|
||||||
|
if (unwrapped.type === "TSUnknownKeyword") return { kind: "unknown" };
|
||||||
|
if (unwrapped.type === "TSObjectKeyword") return { kind: "object" };
|
||||||
|
if (unwrapped.type === "TSTypeLiteral") {
|
||||||
|
return unwrapped.members.some((member) => member.type === "TSIndexSignature")
|
||||||
|
? { kind: "open dictionary" }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
if (unwrapped.type === "TSMappedType") {
|
||||||
|
return isBroadMappedKey(unwrapped.constraint, environment, substitutions)
|
||||||
|
? { kind: "open dictionary" }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
if (unwrapped.type !== "TSTypeReference") return null;
|
||||||
|
const name = typeReferenceName(unwrapped);
|
||||||
|
if (name === null) return null;
|
||||||
|
const substitution = substitutions.get(name);
|
||||||
|
if (substitution !== undefined) {
|
||||||
|
return isUnappliedReferenceTo(substitution, name)
|
||||||
|
? null
|
||||||
|
: classifyAliasBroadTarget(
|
||||||
|
substitution,
|
||||||
|
environment,
|
||||||
|
substitutions,
|
||||||
|
resolvingAliases,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, unwrapped, environment)) {
|
||||||
|
const wrapped = unwrapped.typeArguments?.params[0];
|
||||||
|
return wrapped === undefined
|
||||||
|
? null
|
||||||
|
: classifyAliasBroadTarget(wrapped, environment, substitutions, resolvingAliases);
|
||||||
|
}
|
||||||
|
if (name === "Record" && isBuiltIn(name, unwrapped, environment)) {
|
||||||
|
return hasBroadRecordKey(unwrapped, environment, substitutions)
|
||||||
|
? { kind: "open dictionary" }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
const alias = visibleTypeAlias(name, unwrapped, environment.typeAliases);
|
||||||
|
if (alias === null || resolvingAliases.has(name)) return null;
|
||||||
|
const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions);
|
||||||
|
if (nextSubstitutions === null) return null;
|
||||||
|
const nextResolving = new Set(resolvingAliases);
|
||||||
|
nextResolving.add(name);
|
||||||
|
return classifyAliasBroadTarget(
|
||||||
|
alias.typeAnnotation,
|
||||||
|
environment,
|
||||||
|
nextSubstitutions,
|
||||||
|
nextResolving,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPopulatedObjectExpression(expression: ESTree.Expression): boolean {
|
||||||
|
let current = expression;
|
||||||
|
while (
|
||||||
|
current.type === "ParenthesizedExpression" ||
|
||||||
|
current.type === "TSAsExpression" ||
|
||||||
|
current.type === "TSTypeAssertion" ||
|
||||||
|
current.type === "TSNonNullExpression"
|
||||||
|
) {
|
||||||
|
current = current.expression;
|
||||||
|
}
|
||||||
|
return current.type === "ObjectExpression" && current.properties.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isKnownEvidenceExpression(expression: ESTree.Expression): boolean {
|
||||||
|
let current = expression;
|
||||||
|
while (
|
||||||
|
current.type === "ParenthesizedExpression" ||
|
||||||
|
current.type === "TSAsExpression" ||
|
||||||
|
current.type === "TSTypeAssertion" ||
|
||||||
|
current.type === "TSNonNullExpression" ||
|
||||||
|
current.type === "TSSatisfiesExpression"
|
||||||
|
) {
|
||||||
|
current = current.expression;
|
||||||
|
}
|
||||||
|
if (current.type === "ObjectExpression") return true;
|
||||||
|
return (
|
||||||
|
current.type === "ArrayExpression" ||
|
||||||
|
current.type === "ArrowFunctionExpression" ||
|
||||||
|
current.type === "ClassExpression" ||
|
||||||
|
current.type === "FunctionExpression" ||
|
||||||
|
current.type === "NewExpression" ||
|
||||||
|
current.type === "Literal" ||
|
||||||
|
current.type === "TemplateLiteral" ||
|
||||||
|
current.type === "UnaryExpression"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { ESTree, SourceCode } from "@oxlint/plugins";
|
||||||
|
|
||||||
|
export type FunctionParameter = ESTree.ParamPattern;
|
||||||
|
|
||||||
|
/** Return whether a type is or contains TypeScript's absorbing unknown top type. */
|
||||||
|
export function containsUnknownType(type: ESTree.TSType): boolean {
|
||||||
|
if (type.type === "TSUnknownKeyword") return true;
|
||||||
|
if (type.type === "TSParenthesizedType") return containsUnknownType(type.typeAnnotation);
|
||||||
|
return type.type === "TSUnionType" && type.types.some(containsUnknownType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return the TypeScript annotation attached to a function parameter or its wrapped binding. */
|
||||||
|
export function functionParameterTypeAnnotation(
|
||||||
|
parameter: FunctionParameter,
|
||||||
|
): ESTree.TSTypeAnnotation | null | undefined {
|
||||||
|
if (parameter.type === "TSParameterProperty") {
|
||||||
|
return functionParameterTypeAnnotation(parameter.parameter);
|
||||||
|
}
|
||||||
|
if (parameter.type === "RestElement") {
|
||||||
|
return parameter.typeAnnotation ?? functionParameterTypeAnnotation(parameter.argument);
|
||||||
|
}
|
||||||
|
if (parameter.type === "AssignmentPattern") {
|
||||||
|
return parameter.typeAnnotation ?? functionParameterTypeAnnotation(parameter.left);
|
||||||
|
}
|
||||||
|
return parameter.typeAnnotation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return only a function parameter's local binding, excluding its annotation and default value. */
|
||||||
|
export function functionParameterBindingName(
|
||||||
|
parameter: FunctionParameter,
|
||||||
|
sourceCode: SourceCode,
|
||||||
|
): string {
|
||||||
|
if (parameter.type === "TSParameterProperty") {
|
||||||
|
return functionParameterBindingName(parameter.parameter, sourceCode);
|
||||||
|
}
|
||||||
|
if (parameter.type === "AssignmentPattern") {
|
||||||
|
return functionParameterBindingName(parameter.left, sourceCode);
|
||||||
|
}
|
||||||
|
if (parameter.type === "RestElement") {
|
||||||
|
return functionParameterBindingName(parameter.argument, sourceCode);
|
||||||
|
}
|
||||||
|
if (parameter.type === "Identifier") return parameter.name;
|
||||||
|
|
||||||
|
const sourceText = sourceCode.getText(parameter);
|
||||||
|
const annotationStart = parameter.typeAnnotation?.start;
|
||||||
|
return annotationStart === undefined
|
||||||
|
? sourceText
|
||||||
|
: sourceText.slice(0, annotationStart - parameter.start).trimEnd();
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type { ESTree } from "@oxlint/plugins";
|
||||||
|
|
||||||
|
type VisitorKeys = Readonly<Record<string, readonly string[]>>;
|
||||||
|
|
||||||
|
function isNode(value: unknown): value is ESTree.Node {
|
||||||
|
return (
|
||||||
|
typeof value === "object" &&
|
||||||
|
value !== null &&
|
||||||
|
"type" in value &&
|
||||||
|
typeof value.type === "string"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectInferTypeParameterNames(
|
||||||
|
node: ESTree.Node,
|
||||||
|
visitorKeys: VisitorKeys,
|
||||||
|
names: Set<string>,
|
||||||
|
): void {
|
||||||
|
if (node.type === "TSInferType") names.add(node.typeParameter.name.name);
|
||||||
|
const record = node as unknown as Readonly<Record<string, unknown>>;
|
||||||
|
for (const key of visitorKeys[node.type] ?? []) {
|
||||||
|
const value = record[key];
|
||||||
|
if (isNode(value)) {
|
||||||
|
collectInferTypeParameterNames(value, visitorKeys, names);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(value)) continue;
|
||||||
|
for (const child of value) {
|
||||||
|
if (isNode(child)) collectInferTypeParameterNames(child, visitorKeys, names);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collect type binders that are in scope at a node and can shadow module aliases. */
|
||||||
|
export function lexicalTypeParameterNames(
|
||||||
|
node: ESTree.Node,
|
||||||
|
visitorKeys: VisitorKeys,
|
||||||
|
): ReadonlySet<string> {
|
||||||
|
const names = new Set<string>();
|
||||||
|
let descendant: ESTree.Node = node;
|
||||||
|
let current: ESTree.Node | null = node;
|
||||||
|
while (current !== null && current.type !== "Program") {
|
||||||
|
if ("typeParameters" in current) {
|
||||||
|
for (const parameter of current.typeParameters?.params ?? []) {
|
||||||
|
names.add(parameter.name.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
current.type === "TSMappedType" &&
|
||||||
|
(descendant === current.nameType || descendant === current.typeAnnotation)
|
||||||
|
) {
|
||||||
|
names.add(current.key.name);
|
||||||
|
}
|
||||||
|
if (current.type === "TSConditionalType" && descendant === current.trueType) {
|
||||||
|
collectInferTypeParameterNames(current.extendsType, visitorKeys, names);
|
||||||
|
}
|
||||||
|
descendant = current;
|
||||||
|
current = current.parent;
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { ESTree, Scope, SourceCode, Variable } from "@oxlint/plugins";
|
||||||
|
|
||||||
|
/** Resolve an identifier to its binding by walking lexical scopes upward. */
|
||||||
|
export function resolveVariable(
|
||||||
|
sourceCode: SourceCode,
|
||||||
|
identifier: ESTree.IdentifierReference,
|
||||||
|
): Variable | null {
|
||||||
|
let scope: Scope | null = sourceCode.getScope(identifier);
|
||||||
|
while (scope !== null) {
|
||||||
|
const variable = scope.set.get(identifier.name);
|
||||||
|
if (variable !== undefined) return variable;
|
||||||
|
scope = scope.upper;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import type { ESTree } from "@oxlint/plugins";
|
||||||
|
|
||||||
|
import { lexicalTypeParameterNames } from "./lexical-type-parameters.ts";
|
||||||
|
|
||||||
|
type VisitorKeys = Readonly<Record<string, readonly string[]>>;
|
||||||
|
type TypeScope = ESTree.Node;
|
||||||
|
|
||||||
|
type TypeBinding = {
|
||||||
|
readonly alias: ESTree.TSTypeAliasDeclaration | null;
|
||||||
|
readonly name: string;
|
||||||
|
readonly scope: TypeScope;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Substitution = {
|
||||||
|
readonly substitutions: Substitutions;
|
||||||
|
readonly type: ESTree.TSType;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Substitutions = ReadonlyMap<string, Substitution>;
|
||||||
|
|
||||||
|
export type TypeAliasEnvironment = {
|
||||||
|
readonly aliases: readonly ESTree.TSTypeAliasDeclaration[];
|
||||||
|
readonly bindingsByName: ReadonlyMap<string, readonly TypeBinding[]>;
|
||||||
|
readonly visitorKeys: VisitorKeys;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResolvedTypeMatcher = (
|
||||||
|
type: ESTree.TSType,
|
||||||
|
matches: (child: ESTree.TSType) => boolean,
|
||||||
|
) => boolean;
|
||||||
|
|
||||||
|
const environmentsByProgram = new WeakMap<ESTree.Program, TypeAliasEnvironment>();
|
||||||
|
|
||||||
|
function isNode(value: unknown): value is ESTree.Node {
|
||||||
|
return (
|
||||||
|
typeof value === "object" &&
|
||||||
|
value !== null &&
|
||||||
|
"type" in value &&
|
||||||
|
typeof value.type === "string"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function enclosingTypeScope(node: ESTree.Node): TypeScope {
|
||||||
|
let current: ESTree.Node | null = node.parent;
|
||||||
|
while (current !== null) {
|
||||||
|
if (
|
||||||
|
current.type === "Program" ||
|
||||||
|
current.type === "BlockStatement" ||
|
||||||
|
current.type === "TSModuleBlock" ||
|
||||||
|
current.type === "StaticBlock" ||
|
||||||
|
current.type === "SwitchStatement"
|
||||||
|
) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
current = current.parent;
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function declaredTypeBinding(node: ESTree.Node): {
|
||||||
|
readonly alias: ESTree.TSTypeAliasDeclaration | null;
|
||||||
|
readonly name: string;
|
||||||
|
} | null {
|
||||||
|
if (node.type === "TSTypeAliasDeclaration") {
|
||||||
|
return { alias: node, name: node.id.name };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
node.type === "TSInterfaceDeclaration" ||
|
||||||
|
node.type === "TSEnumDeclaration" ||
|
||||||
|
node.type === "ClassDeclaration" ||
|
||||||
|
node.type === "ClassExpression"
|
||||||
|
) {
|
||||||
|
return node.id === null ? null : { alias: null, name: node.id.name };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
node.type === "ImportSpecifier" ||
|
||||||
|
node.type === "ImportDefaultSpecifier" ||
|
||||||
|
node.type === "ImportNamespaceSpecifier"
|
||||||
|
) {
|
||||||
|
return { alias: null, name: node.local.name };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectTypeBindings(
|
||||||
|
node: ESTree.Node,
|
||||||
|
visitorKeys: VisitorKeys,
|
||||||
|
bindingsByName: Map<string, TypeBinding[]>,
|
||||||
|
aliases: ESTree.TSTypeAliasDeclaration[],
|
||||||
|
): void {
|
||||||
|
const declared = declaredTypeBinding(node);
|
||||||
|
if (declared !== null) {
|
||||||
|
const bindings = bindingsByName.get(declared.name) ?? [];
|
||||||
|
bindings.push({ ...declared, scope: enclosingTypeScope(node) });
|
||||||
|
bindingsByName.set(declared.name, bindings);
|
||||||
|
if (declared.alias !== null) aliases.push(declared.alias);
|
||||||
|
}
|
||||||
|
|
||||||
|
// SAFETY: Oxlint's visitor keys identify only ESTree child-node properties.
|
||||||
|
const fields = node as unknown as Readonly<Record<string, unknown>>;
|
||||||
|
for (const key of visitorKeys[node.type] ?? []) {
|
||||||
|
const value = fields[key];
|
||||||
|
if (isNode(value)) {
|
||||||
|
collectTypeBindings(value, visitorKeys, bindingsByName, aliases);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(value)) continue;
|
||||||
|
for (const child of value) {
|
||||||
|
if (isNode(child)) {
|
||||||
|
collectTypeBindings(child, visitorKeys, bindingsByName, aliases);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collect every lexical type alias and competing type binding in a program. */
|
||||||
|
export function createTypeAliasEnvironment(
|
||||||
|
program: ESTree.Program,
|
||||||
|
visitorKeys: VisitorKeys,
|
||||||
|
): TypeAliasEnvironment {
|
||||||
|
const cached = environmentsByProgram.get(program);
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
|
const bindingsByName = new Map<string, TypeBinding[]>();
|
||||||
|
const aliases: ESTree.TSTypeAliasDeclaration[] = [];
|
||||||
|
collectTypeBindings(program, visitorKeys, bindingsByName, aliases);
|
||||||
|
const environment = { aliases, bindingsByName, visitorKeys };
|
||||||
|
environmentsByProgram.set(program, environment);
|
||||||
|
return environment;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ancestorDistance(ancestor: ESTree.Node, node: ESTree.Node): number | null {
|
||||||
|
let current: ESTree.Node | null = node;
|
||||||
|
let distance = 0;
|
||||||
|
while (current !== null) {
|
||||||
|
if (current === ancestor) return distance;
|
||||||
|
current = current.parent;
|
||||||
|
distance += 1;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nearestTypeBindings(
|
||||||
|
name: string,
|
||||||
|
use: ESTree.Node,
|
||||||
|
environment: TypeAliasEnvironment,
|
||||||
|
): readonly TypeBinding[] {
|
||||||
|
const candidates = environment.bindingsByName.get(name) ?? [];
|
||||||
|
let nearestDistance = Number.POSITIVE_INFINITY;
|
||||||
|
let nearest: TypeBinding[] = [];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const distance = ancestorDistance(candidate.scope, use);
|
||||||
|
if (distance === null || distance > nearestDistance) continue;
|
||||||
|
if (distance === nearestDistance) {
|
||||||
|
nearest.push(candidate);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
nearestDistance = distance;
|
||||||
|
nearest = [candidate];
|
||||||
|
}
|
||||||
|
return nearest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve the nearest visible alias with this name, respecting lexical shadowing. */
|
||||||
|
export function visibleTypeAlias(
|
||||||
|
name: string,
|
||||||
|
use: ESTree.Node,
|
||||||
|
environment: TypeAliasEnvironment,
|
||||||
|
): ESTree.TSTypeAliasDeclaration | null {
|
||||||
|
if (lexicalTypeParameterNames(use, environment.visitorKeys).has(name)) return null;
|
||||||
|
const bindings = nearestTypeBindings(name, use, environment);
|
||||||
|
return bindings.length === 1 ? (bindings[0]?.alias ?? null) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return whether a local declaration shadows a built-in type at this use. */
|
||||||
|
export function hasVisibleTypeBinding(
|
||||||
|
name: string,
|
||||||
|
use: ESTree.Node,
|
||||||
|
environment: TypeAliasEnvironment,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
lexicalTypeParameterNames(use, environment.visitorKeys).has(name) ||
|
||||||
|
nearestTypeBindings(name, use, environment).length > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeReferenceName(type: ESTree.TSTypeReference): string | null {
|
||||||
|
return type.typeName.type === "Identifier" ? type.typeName.name : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function aliasSubstitutions(
|
||||||
|
alias: ESTree.TSTypeAliasDeclaration,
|
||||||
|
reference: ESTree.TSTypeReference,
|
||||||
|
base: Substitutions,
|
||||||
|
): Substitutions | null {
|
||||||
|
const parameters = alias.typeParameters?.params ?? [];
|
||||||
|
const arguments_ = reference.typeArguments?.params ?? [];
|
||||||
|
const next = new Map(base);
|
||||||
|
for (const [index, parameter] of parameters.entries()) {
|
||||||
|
const explicitArgument = arguments_[index];
|
||||||
|
const argument = explicitArgument ?? parameter.default;
|
||||||
|
if (argument === null || argument === undefined) return null;
|
||||||
|
const argumentSubstitutions = explicitArgument === undefined ? next : base;
|
||||||
|
next.set(parameter.name.name, {
|
||||||
|
type: argument,
|
||||||
|
substitutions: new Map(argumentSubstitutions),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Match a type after resolving visible aliases and substituting their type parameters. */
|
||||||
|
export function resolvedTypeMatches(
|
||||||
|
type: ESTree.TSType,
|
||||||
|
environment: TypeAliasEnvironment,
|
||||||
|
matcher: ResolvedTypeMatcher,
|
||||||
|
): boolean {
|
||||||
|
const evaluate = (
|
||||||
|
current: ESTree.TSType,
|
||||||
|
substitutions: Substitutions,
|
||||||
|
resolvingAliases: ReadonlySet<ESTree.TSTypeAliasDeclaration>,
|
||||||
|
): boolean => {
|
||||||
|
if (current.type === "TSTypeReference") {
|
||||||
|
const name = typeReferenceName(current);
|
||||||
|
if (name !== null) {
|
||||||
|
const substitution = substitutions.get(name);
|
||||||
|
if (substitution !== undefined && !current.typeArguments?.params.length) {
|
||||||
|
return evaluate(
|
||||||
|
substitution.type,
|
||||||
|
substitution.substitutions,
|
||||||
|
resolvingAliases,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const alias = visibleTypeAlias(name, current, environment);
|
||||||
|
if (alias !== null && !resolvingAliases.has(alias)) {
|
||||||
|
const nextSubstitutions = aliasSubstitutions(alias, current, substitutions);
|
||||||
|
if (nextSubstitutions !== null) {
|
||||||
|
const nextResolving = new Set(resolvingAliases);
|
||||||
|
nextResolving.add(alias);
|
||||||
|
return evaluate(alias.typeAnnotation, nextSubstitutions, nextResolving);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matcher(current, (child) =>
|
||||||
|
evaluate(child, substitutions, resolvingAliases),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return evaluate(type, new Map(), new Set());
|
||||||
|
}
|
||||||
Reference in new issue
Block a user