Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,29 @@ jobs:
- name: Lint
run: pnpm lint

- name: Format check
run: pnpm format:check

- name: Typecheck
run: pnpm typecheck

- name: Dynamic-SQL guard
run: pnpm check:sql

# The email worker is not a workspace package: it deploys with wrangler from its own lockfile, so
# it installs in isolation. knip below analyzes it too, so this install comes first.
- name: Email worker install
run: pnpm --dir infra/email-worker install --frozen-lockfile --ignore-workspace

- name: Email worker test
run: pnpm --dir infra/email-worker test

- name: Unused files, exports and dependencies (knip)
run: pnpm knip

- name: Duplication (jscpd)
run: pnpm dup:check

- name: Build
run: pnpm build

Expand Down
14 changes: 14 additions & 0 deletions .jscpd.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"path": ["services/api/src", "services/media-worker/src", "packages"],
"format": ["typescript"],
"ignore": [
"**/*.test.ts",
"**/*.generated.ts",
"**/drizzle/**",
"**/node_modules/**",
"**/dist/**"
],
"minTokens": 70,
"reporters": ["console"],
"threshold": 0.5
}
2 changes: 2 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ coverage
shared
drizzle
pnpm-lock.yaml
# Prose and tables are hand-laid-out and not machine-formatted; the formatting pass covered code only.
*.md
7 changes: 5 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,5 +49,8 @@ a pull request that adds one.
- Validate every input at the boundary, check authorization on every path,
keep secrets out of code and logs, minimize personal data.
- No N+1 queries, index new query paths, bound every result set.
- Add or update tests with the change. Typecheck, lint and tests pass in the
repository before the PR opens.
- Add or update tests with the change. Before the PR opens, these pass from
the repository root: `pnpm lint`, `pnpm typecheck`, `pnpm format:check`,
`pnpm check:sql`, `pnpm knip`, `pnpm dup:check` and `pnpm test`, plus the
email worker's own tests (`pnpm --dir infra/email-worker test`) when it
changes. CI runs the same checks.
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ civfix-backend/
caddy/ reverse-proxy Caddyfile
secrets/ SOPS + age docs and .sops.yaml
tiles/ note: map uses OpenStreetMap (CARTO Voyager) raster; no self-hosted tiles
.github/workflows/ci.yml lint / typecheck / build / test + integration services
.github/workflows/ci.yml lint / format / typecheck / static checks / build / test
tsconfig.base.json strict base TS config
turbo.json turborepo task graph
pnpm-workspace.yaml
Expand Down Expand Up @@ -84,10 +84,23 @@ pnpm build # build @civfix/shared, then api + media-worker
pnpm typecheck # tsc --noEmit across all packages
pnpm lint # eslint across all packages
pnpm test # vitest unit tests
pnpm format:check # prettier --check (Markdown is excluded, see .prettierignore)
pnpm check:sql # dynamic-SQL guard: unsafe/raw allowlist, Drizzle $client, parameter IS NULL tests
pnpm knip # unused files, exports and dependencies (knip.jsonc)
pnpm dup:check # copy-paste duplication over the service sources (.jscpd.json threshold)
pnpm dev # run services in watch mode (persistent)
pnpm clean # remove build artifacts
```

CI runs every command above except `dev` and `clean`. The Cloudflare email worker in
`infra/email-worker` is not a workspace package and keeps its own lockfile, so it installs and tests
in isolation:

```
pnpm --dir infra/email-worker install --frozen-lockfile --ignore-workspace
pnpm --dir infra/email-worker test
```

## Running the API in dev (offline, no credentials)

Outside production the env loader supplies insecure dev defaults for the signing keys and turns on
Expand Down
52 changes: 52 additions & 0 deletions knip.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
{
"$schema": "https://unpkg.com/knip@5/schema-jsonc.json",
// A type that appears in an exported signature stays exported so callers can name it.
"ignoreExportsUsedInFile": { "interface": true, "type": true },
"workspaces": {
".": {
// check-dynamic-sql.d.mts types the .mjs guard for the api tests that import its matchers.
"entry": ["scripts/*.mjs", "scripts/check-dynamic-sql.d.mts"],
"project": ["scripts/**"],
},
// The package.json scripts, the tsup entries, the vitest globalSetup and drizzle.config.ts are entries
// through knip's plugins; listed here are only the hand-run CLIs nothing else names.
"services/api": {
"entry": [
// The font-change procedure in assets/fonts/PROVENANCE.md runs it with tsx.
"scripts/render-sample-certificate.ts",
// The only writer of jurisdictions.handle; whether to wire or retire it is an open decision.
"src/db/backfill-jurisdiction-handles.ts",
],
"project": ["src/**/*.ts", "scripts/**/*.ts", "test/**/*.ts"],
"ignore": [
// Hand-run SQL transcript tooling: the generator is a tsx CLI, and the generated cases reach
// every exported SQL helper through a namespace import that static analysis cannot follow.
"test/sql-transcripts/**",
// Unused since it landed; deleting it waits on the roster-export vs host-contact consent
// decision, whose fix may reuse hostContactOptInFor.
"src/services/host/event-consents-repository.drizzle.ts",
],
},
"services/media-worker": {
"project": ["src/**/*.ts", "test/**/*.ts"],
// tsup inlines @civfix/api (noExternal) but keeps these external, so the bundled api code
// resolves them from the worker's own node_modules at runtime.
"ignoreDependencies": [
// @civfix/api/adapters/storage (R2 client)
"@aws-sdk/client-s3",
// @civfix/api/adapters/storage (presigned URLs)
"@aws-sdk/s3-request-presigner",
// @civfix/api/errors (GlitchTip reporting)
"@sentry/node",
// @civfix/api/adapters/storage (R2 request timeouts and egress proxy)
"@smithy/node-http-handler",
// @civfix/api/adapters/storage (egress proxy agent)
"https-proxy-agent",
],
},
"packages/config": {},
// Outside pnpm-workspace.yaml (it deploys with wrangler from its own lockfile); CI installs it before
// knip runs so its dependencies resolve.
"infra/email-worker": {},
},
}
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,15 @@
"lint": "turbo run lint",
"test": "turbo run test --continue",
"check:sql": "node scripts/check-dynamic-sql.mjs",
"format:check": "prettier --check .",
"knip": "knip",
"dup:check": "jscpd",
"dev": "turbo run dev",
"clean": "turbo run clean"
},
"devDependencies": {
"jscpd": "^5.3.2",
"knip": "^5.88.1",
"prettier": "^3.4.2",
"turbo": "^2.3.3",
"typescript": "^5.6.3"
Expand Down
31 changes: 29 additions & 2 deletions packages/config/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,35 @@ import eslint from "@eslint/js"
import tseslint from "typescript-eslint"
import globals from "globals"

// Type-aware rules are enabled per service via `parserOptions.projectService` in the service's
// eslint.config.js, so this preset stays project-agnostic.
// Type-aware rules need a TypeScript program, which only the consuming service can locate, so each
// service passes its own parserOptions (projectService + tsconfigRootDir) through `typed()`.
export function typed(parserOptions) {
return {
files: ["**/*.ts"],
languageOptions: { parserOptions },
rules: {
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-misused-promises": "error",
"@typescript-eslint/await-thenable": "error",
"@typescript-eslint/no-for-in-array": "error",
"@typescript-eslint/no-implied-eval": "error",
"@typescript-eslint/only-throw-error": "error",
"@typescript-eslint/prefer-promise-reject-errors": "error",
"@typescript-eslint/restrict-template-expressions": [
"error",
{ allowNumber: true, allowBoolean: true },
],
"@typescript-eslint/no-base-to-string": "error",
"@typescript-eslint/no-redundant-type-constituents": "error",
"@typescript-eslint/switch-exhaustiveness-check": [
"error",
{ considerDefaultExhaustiveForUnions: true },
],
"@typescript-eslint/no-unnecessary-type-assertion": "error",
},
}
}

export function config(...extra) {
return tseslint.config(
{
Expand Down
Loading