diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1cd65315b..1986edb43 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -228,6 +228,12 @@ jobs: - name: Typecheck run: cd server && npm run typecheck + - name: Strict type errors may only shrink + # tsconfig.strict.json is the build config plus strictNullChecks and + # noImplicitAny. Its errors per file are held to + # scripts/strict-baseline.json; a new file starts at none. + run: cd server && npm run lint:strict + - name: Typecheck tests # tests/ is outside the build tsconfig, so a service that grows a # constructor parameter does not break its hand-wired call sites at diff --git a/CLAUDE.md b/CLAUDE.md index e9cead8d8..7e66c3989 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,6 +41,7 @@ cd client && npm run lint:size # no source file past 1000 li cd client && npm run lint:warnings # eslint errors fail, warnings per rule may only shrink (CI gate) cd server && npm run lint:warnings # the same for the server, against server/scripts/eslint-baseline.json (CI gate) cd server && npm run lint:format # Prettier outside the baseline list (CI gate; shared has the same in lint-prettier.yml) +cd server && npm run lint:strict # strict type errors per file may only shrink, a new file has none (CI gate) cd client && npm run lint:format # Prettier outside the baseline list (CI gate) cd client && npm run lint:layers # components never import pages/ or mobile/ (CI gate) cd client && npm run lint:offline # views may not import src/api/ beyond the baseline (CI gate) @@ -125,7 +126,7 @@ These principles come out of a full-repo audit and shape all new code: ## Quality gates in CI -`.github/workflows/test.yml` runs, per package: typecheck (server also `typecheck:tests`), lint (client and server through `lint:warnings`, which also holds the warning count per rule), `lint:pages`, `lint:rtl`, `lint:size` (client and server), the server's `lint:boundaries`, `lint:tx`, `lint:dialect` and `lint:test-sql`, the client's and server's `lint:format`, `lint:layers`, `lint:offline`, `lint:dup`, `lint:pairs`, `lint:skips` and `lint:i18n-keys`, shared's `contracts:open`, `check:plugin-facts`, `i18n:parity:strict` (with the untranslated-strings ratchet), the S3 contract suite, the Postgres probe (`postgres-probe`: every dialect helper on SQLite and Postgres, and the statements every repository method reaches with sample arguments against a Postgres service; the SQL errors per method and the list of methods it cannot measure are held to a baseline that only shrinks and fails while it is still unmeasured; see `server/CLAUDE.md`), the browser end-to-end flows (Chromium and WebKit), a client build with `check:gl-split`, coverage for all four packages, then a SonarCloud scan. It also builds the Docker image and boots it until `/api/health/ready` answers (`docker-image`), and lints and renders the Helm chart against the Kubernetes schemas and runs `docker compose config` on every compose file (`deploy-manifests`). `lint-prettier.yml` additionally runs `lint` + `lint:format` on `shared/`, both check-only. `plugin-sdk` has no lint script and is outside the eslint gates. **Not in CI**: `theme:lint` and the screenshot and help-media Playwright projects; run them locally. There are no git hooks; nothing runs before a commit except you. Other workflows build Docker images, scan them, and publish `trek-plugin-sdk` on `plugin-sdk-v*` tags. +`.github/workflows/test.yml` runs, per package: typecheck (server also `typecheck:tests`), lint (client and server through `lint:warnings`, which also holds the warning count per rule), `lint:pages`, `lint:rtl`, `lint:size` (client and server), the server's `lint:strict`, `lint:boundaries`, `lint:tx`, `lint:dialect` and `lint:test-sql`, the client's and server's `lint:format`, `lint:layers`, `lint:offline`, `lint:dup`, `lint:pairs`, `lint:skips` and `lint:i18n-keys`, shared's `contracts:open`, `check:plugin-facts`, `i18n:parity:strict` (with the untranslated-strings ratchet), the S3 contract suite, the Postgres probe (`postgres-probe`: every dialect helper on SQLite and Postgres, and the statements every repository method reaches with sample arguments against a Postgres service; the SQL errors per method and the list of methods it cannot measure are held to a baseline that only shrinks and fails while it is still unmeasured; see `server/CLAUDE.md`), the browser end-to-end flows (Chromium and WebKit), a client build with `check:gl-split`, coverage for all four packages, then a SonarCloud scan. It also builds the Docker image and boots it until `/api/health/ready` answers (`docker-image`), and lints and renders the Helm chart against the Kubernetes schemas and runs `docker compose config` on every compose file (`deploy-manifests`). `lint-prettier.yml` additionally runs `lint` + `lint:format` on `shared/`, both check-only. `plugin-sdk` has no lint script and is outside the eslint gates. **Not in CI**: `theme:lint` and the screenshot and help-media Playwright projects; run them locally. There are no git hooks; nothing runs before a commit except you. Other workflows build Docker images, scan them, and publish `trek-plugin-sdk` on `plugin-sdk-v*` tags. - **`ci-ok` is the required check.** The branch ruleset requires `ci-ok` (plus `lint` and `check-target` from their own workflows), and `ci-ok` needs every job above except the scan. It fails if any of them failed or was cancelled and passes if they passed or were skipped. A new gate job counts only once it is listed in `ci-ok`'s `needs:`. - **Guides name paths that exist**: the `changes` job runs `scripts/ci/doc-paths.mjs`, which resolves every backtick path in the CLAUDE.md files, `README.md`, `server/src/nest/README.md` and the wiki against the tracked files; the missing ones are listed in `scripts/ci/doc-paths-baseline.json`, which only shrinks (`--update` after a fix). diff --git a/server/CLAUDE.md b/server/CLAUDE.md index 21440b867..c866bd851 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -13,6 +13,7 @@ npm run lint # eslint, no fix npm run lint:fix # eslint --fix npm run lint:warnings # eslint errors fail, warnings per rule may only shrink (CI gate, scripts/eslint-baseline.json) npm run lint:format # Prettier over src/ and tests/ outside scripts/format-baseline.json, which only shrinks (CI gate) +npm run lint:strict # strict type errors (strictNullChecks + noImplicitAny) per file may only shrink; a new file has none (CI gate) npm run test # vitest run; also test:unit / test:integration / test:ws / test:e2e npm run test:coverage # istanbul coverage; per-domain ratchet over src/nest/** npm run gen:plugin-facts # regenerate the plugin-protocol tables into plugin-sdk/ + shared/ @@ -38,6 +39,8 @@ npm run contracts:dto-open # open shapes in the createZodDto schemas may only sh **`lint:size`** (`scripts/size-lint.mjs`) counts a line longer than 120 columns once per 120 columns it spans, so joining lines does not make room. Files that were already longer are held at their length in `scripts/size-baseline.json`; a file that needs to grow past its entry is split by concern instead. An entry above what its file holds now, or for a file that is gone, fails as well, so after a split `npm run lint:size -- --update` lowers the baseline in the same change (it never raises or adds an entry). +**`lint:strict`** (`scripts/strict-lint.mjs`) runs tsc over `tsconfig.strict.json`, the build config with `strictNullChecks` and `noImplicitAny` on, and counts the errors per file against `scripts/strict-baseline.json`. A file may hold at most its entry and a file without one, a new file in particular, none; an entry above what its file holds now, or for a file that is gone, fails as well, so the change that fixes errors runs `npm run lint:strict -- --update` (it never raises or adds an entry). It is a ratchet only: the build stays on `strict: false` until the baseline is empty. + **`lint:test-sql`** (`scripts/test-sql-ratchet.mjs`) counts every `.prepare(` under `tests/` per file against `scripts/test-sql-baseline.json`: a file may hold at most its entry, a file without one none. Growth fails, and so does an entry above what its file holds now, so a conversion lands with `npm run lint:test-sql -- --update` (it only lowers and drops entries). Files whose subject is raw SQL (the dialect functions, PRAGMA introspection, the legacy upgrade fixtures) sit in the baseline's `exempt` list with a reason; a single statement that must stay raw carries a `test-sql-allow: ` comment on its line or the line above. `tests/unit/scripts/test-sql-ratchet.test.ts` also runs it on the tree. Single test: `npx vitest run tests/unit/nest/weather.controller.test.ts`, or `npx vitest run -t "returns 401 without cookie"`. diff --git a/server/package.json b/server/package.json index 516cfbc2b..2bf627fd8 100644 --- a/server/package.json +++ b/server/package.json @@ -18,6 +18,7 @@ "lint:size": "node scripts/size-lint.mjs", "lint:warnings": "node scripts/eslint-ratchet.mjs", "lint:format": "node scripts/format-lint.mjs", + "lint:strict": "node scripts/strict-lint.mjs", "lint:boundaries": "node scripts/import-boundaries.mjs", "lint:dialect": "node scripts/dialect-lint.mjs", "lint:tx": "node scripts/tx-writes.mjs", diff --git a/server/scripts/lib/tsc-errors.mjs b/server/scripts/lib/tsc-errors.mjs new file mode 100644 index 000000000..2aab06bad --- /dev/null +++ b/server/scripts/lib/tsc-errors.mjs @@ -0,0 +1,29 @@ +/* + * Reading tsc's --pretty false output for lint:strict (scripts/strict-lint.mjs). + * Kept free of imports so the unit tests can load it on their own. + */ + +/** `src/a.ts(3,7): error TS2322: ...` as tsc --pretty false prints it. */ +const FILE_ERROR = /^(.+?)\((\d+),(\d+)\): error (TS\d+): /; +/** An error tied to no file, such as a config problem. */ +const GLOBAL_ERROR = /^error (TS\d+): /; + +/** + * The errors per file in tsc's output, keyed by the path from the server root + * with forward slashes, and the lines of every error that names no file. + * Continuation lines (indented) belong to the error above and do not count. + */ +export function countErrors(output) { + const counts = {}; + const global = []; + for (const line of output.replace(/\r\n?/g, '\n').split('\n')) { + const match = FILE_ERROR.exec(line); + if (match) { + const key = match[1].split('\\').join('/').replace(/^\.\//, ''); + counts[key] = (counts[key] ?? 0) + 1; + } else if (GLOBAL_ERROR.test(line)) { + global.push(line); + } + } + return { counts, global }; +} diff --git a/server/scripts/strict-baseline.json b/server/scripts/strict-baseline.json new file mode 100644 index 000000000..9dee01343 --- /dev/null +++ b/server/scripts/strict-baseline.json @@ -0,0 +1,111 @@ +{ + "src/db/repositories/BudgetItems.repository.ts": 1, + "src/db/repositories/CollabLinks.repository.ts": 1, + "src/db/repositories/CollabMessageReactions.repository.ts": 1, + "src/db/repositories/CollabMessages.repository.ts": 1, + "src/db/repositories/CollabNotes.repository.ts": 1, + "src/db/repositories/CollabPollVotes.repository.ts": 1, + "src/db/repositories/CollabPolls.repository.ts": 1, + "src/db/repositories/CollectionMembers.repository.ts": 1, + "src/db/repositories/CollectionPlaces.repository.ts": 1, + "src/db/repositories/Collections.repository.ts": 1, + "src/db/repositories/DawarichVisitSuggestions.repository.ts": 1, + "src/db/repositories/DayAccommodations.repository.ts": 2, + "src/db/repositories/DayAssignments.repository.ts": 2, + "src/db/repositories/DayNotes.repository.ts": 2, + "src/db/repositories/Days.repository.ts": 3, + "src/db/repositories/DocumentConnections.repository.ts": 1, + "src/db/repositories/InviteTokens.repository.ts": 1, + "src/db/repositories/JourneyBooks.repository.ts": 1, + "src/db/repositories/JourneyContributors.repository.ts": 1, + "src/db/repositories/JourneyEntries.repository.ts": 2, + "src/db/repositories/JourneyShareTokens.repository.ts": 1, + "src/db/repositories/Journeys.repository.ts": 1, + "src/db/repositories/McpTokens.repository.ts": 1, + "src/db/repositories/Notifications.repository.ts": 1, + "src/db/repositories/OauthTokens.repository.ts": 4, + "src/db/repositories/PackingBags.repository.ts": 1, + "src/db/repositories/PackingItems.repository.ts": 3, + "src/db/repositories/PackingTemplates.repository.ts": 2, + "src/db/repositories/PasswordResetTokens.repository.ts": 1, + "src/db/repositories/PhotoProviderFields.repository.ts": 1, + "src/db/repositories/Places.repository.ts": 3, + "src/db/repositories/ReservationEndpoints.repository.ts": 1, + "src/db/repositories/Reservations.repository.ts": 3, + "src/db/repositories/RoadtripVias.repository.ts": 1, + "src/db/repositories/SchoolHolidayPeriods.repository.ts": 1, + "src/db/repositories/SchoolHolidayRegions.repository.ts": 1, + "src/db/repositories/ShareTokens.repository.ts": 1, + "src/db/repositories/Tags.repository.ts": 1, + "src/db/repositories/TourWaypoints.repository.ts": 1, + "src/db/repositories/Tours.repository.ts": 1, + "src/db/repositories/TripDocumentLinks.repository.ts": 1, + "src/db/repositories/TripFiles.repository.ts": 1, + "src/db/repositories/TripInviteTokens.repository.ts": 1, + "src/db/repositories/TripMembers.repository.ts": 1, + "src/db/repositories/Trips.repository.ts": 2, + "src/db/repositories/UserSessions.repository.ts": 1, + "src/db/repositories/VacayCompanyHolidays.repository.ts": 1, + "src/db/repositories/VacayEntries.repository.ts": 1, + "src/db/repositories/VacayHolidayCalendars.repository.ts": 1, + "src/db/repositories/VacayPlanMembers.repository.ts": 1, + "src/db/repositories/VacayPlans.repository.ts": 1, + "src/db/repositories/VacayShares.repository.ts": 1, + "src/db/repositories/VacayYears.repository.ts": 1, + "src/db/repositories/WebauthnCredentials.repository.ts": 1, + "src/db/repositories/_shared/trek-repository.ts": 8, + "src/mcp/sessionManager.ts": 2, + "src/mcp/tools.ts": 1, + "src/nest-mcp/registry.ts": 2, + "src/nest/accommodations/accommodations.service.ts": 2, + "src/nest/admin/admin.controller.ts": 1, + "src/nest/atlas/atlas-geo.ts": 2, + "src/nest/auth/auth.service.ts": 1, + "src/nest/budget/budget.service.ts": 2, + "src/nest/calendar/calendar.service.ts": 5, + "src/nest/collections/collections.service.ts": 1, + "src/nest/day-notes/day-notes.mcp.ts": 1, + "src/nest/day-notes/day-notes.service.ts": 1, + "src/nest/doc-sync/doc-sync.helpers.ts": 6, + "src/nest/files/files.controller.ts": 1, + "src/nest/integrations/airtrail.mapper.ts": 4, + "src/nest/integrations/airtrail.service.ts": 1, + "src/nest/integrations/dawarich-suggestions.service.ts": 1, + "src/nest/journey/journey-domain.service.ts": 1, + "src/nest/journey/journey.controller.ts": 5, + "src/nest/journey/journey.dto.ts": 2, + "src/nest/journey/journey.mcp.ts": 1, + "src/nest/llm-parse/mime-email.ts": 1, + "src/nest/maps/maps.service.ts": 1, + "src/nest/maps/providers/amap.provider.ts": 1, + "src/nest/mcp-transport/mcp-transport.service.ts": 4, + "src/nest/memories/immich.controller.ts": 1, + "src/nest/memories/immich.service.ts": 1, + "src/nest/memories/synology.service.ts": 5, + "src/nest/notifications/notification-preferences.service.ts": 1, + "src/nest/oauth/oauth-api.controller.ts": 1, + "src/nest/oauth/oauth-sdk.provider.ts": 6, + "src/nest/oauth/oauth.module.ts": 2, + "src/nest/packing/admin-packing-templates.controller.ts": 3, + "src/nest/packing/packing.controller.ts": 1, + "src/nest/packing/packing.dto.ts": 1, + "src/nest/packing/packing.mcp.ts": 1, + "src/nest/places/places.controller.ts": 2, + "src/nest/places/places.mcp.ts": 5, + "src/nest/places/places.service.ts": 4, + "src/nest/platform/discovery-metadata.service.ts": 3, + "src/nest/plugins/host/plugin-jobs.ts": 1, + "src/nest/plugins/host/rpc/host-surface.rpc.ts": 8, + "src/nest/plugins/registry/registry.service.ts": 1, + "src/nest/plugins/runtime/plugin-host-entry.ts": 3, + "src/nest/public-api/public-api.service.ts": 1, + "src/nest/query-helpers/query-helpers.service.ts": 1, + "src/nest/reservations/reservations-read.service.ts": 1, + "src/nest/reservations/reservations.service.ts": 4, + "src/nest/roadtrip/roadtrip-router.service.ts": 12, + "src/nest/settings/settings.service.ts": 1, + "src/nest/storage/drivers/s3.driver.ts": 1, + "src/nest/trips/trips.controller.ts": 2, + "src/nest/trips/trips.mcp.ts": 1, + "src/nest/trips/trips.service.ts": 1 +} diff --git a/server/scripts/strict-lint.mjs b/server/scripts/strict-lint.mjs new file mode 100644 index 000000000..ec9b13abc --- /dev/null +++ b/server/scripts/strict-lint.mjs @@ -0,0 +1,110 @@ +#!/usr/bin/env node +/* + * lint:strict: the strict type errors per file may only go down. + * + * The server compiles with strict: false, so a value that can be null or + * undefined reads as always present, and an untyped parameter is a silent + * `any` that no lint rule counts. Switching strict on at once is not a change + * anyone can review, so this runs tsc over tsconfig.strict.json (the build + * config plus strictNullChecks and noImplicitAny) and counts its errors per + * file. A file may hold at most its entry in scripts/strict-baseline.json and + * a file without an entry, a new one in particular, none. An entry above what + * its file holds now, or for a file that is gone, fails as well until + * --update lowers it, so a fixed error cannot come back unseen. An error tsc + * reports outside any file (a broken config) fails the run, and so does a + * tsc that exits non-zero without one error it can name: the check never + * passes on output it could not read. + * + * npm run lint:strict check against the baseline (CI) + * npm run lint:strict -- --update lower the baseline to today's counts; + * it never raises or adds an entry + * + * --dir= points it at another server root and --tsc-output= reads + * a saved `tsc --pretty false` output instead of running tsc (the unit tests + * use both). The counting lives in scripts/lib/tsc-errors.mjs, the baseline + * handling in scripts/lib/ratchet.mjs at the repository root. + */ +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + countMap, + lowerCounts, + RatchetError, + readBaseline, + reportStale, + staleCounts, + writeBaseline, +} from '../../scripts/lib/ratchet.mjs'; +import { countErrors } from './lib/tsc-errors.mjs'; + +/** tsc over tsconfig.strict.json, as its output and exit status. */ +function runTsc(serverDir) { + const tsc = createRequire(join(serverDir, 'package.json')).resolve('typescript/bin/tsc'); + try { + const output = execFileSync(process.execPath, [tsc, '-p', 'tsconfig.strict.json', '--noEmit', '--pretty', 'false'], { + cwd: serverDir, + encoding: 'utf8', + maxBuffer: 256 * 1024 * 1024, + }); + return { output, status: 0 }; + } catch (err) { + if (typeof err.status !== 'number') throw err; + return { output: `${err.stdout ?? ''}${err.stderr ?? ''}`, status: err.status }; + } +} + +const sum = (map) => Object.values(map).reduce((a, b) => a + b, 0); + +function check(argv) { + const dirArg = argv.find((a) => a.startsWith('--dir=')); + const outputArg = argv.find((a) => a.startsWith('--tsc-output=')); + const serverDir = dirArg ? resolve(dirArg.slice('--dir='.length)) : fileURLToPath(new URL('..', import.meta.url)); + const baselinePath = join(serverDir, 'scripts', 'strict-baseline.json'); + + let baseline = readBaseline(baselinePath, countMap); + const { output, status } = outputArg + ? { output: readFileSync(resolve(outputArg.slice('--tsc-output='.length)), 'utf8'), status: null } + : runTsc(serverDir); + const { counts, global } = countErrors(output); + if (global.length) { + throw new RatchetError(`tsc reported errors outside any file:\n ${global.join('\n ')}`); + } + if (status !== null && status !== 0 && !Object.keys(counts).length) { + throw new RatchetError(`tsc exited with ${status} without an error it could name:\n${output.slice(0, 2000)}`); + } + + if (argv.includes('--update')) { + baseline = lowerCounts(baseline, counts); + writeBaseline(baselinePath, baseline); + } + + const grown = Object.entries(counts).filter(([file, n]) => n > (baseline[file] ?? 0)); + for (const [file, n] of grown) { + const entry = baseline[file]; + console.error( + `FAIL ${file}: ${n} strict error(s), ` + + (entry ? `its baseline is ${entry}. ` : 'a file without a baseline entry may have none. ') + + 'Run npx tsc -p tsconfig.strict.json --noEmit to see them; narrow the null case or type the parameter.', + ); + } + const stale = staleCounts(baseline, counts); + reportStale(stale, { file: 'strict-baseline.json', command: 'lint:strict', root: serverDir, error: console.error }); + console.log( + `strict: ${sum(counts)} error(s) in ${Object.keys(counts).length} file(s), baseline ${sum(baseline)} in ${Object.keys(baseline).length}`, + ); + return grown.length || stale.length ? 1 : 0; +} + +const isCli = Boolean(process.argv[1]) && resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (isCli) { + try { + process.exitCode = check(process.argv.slice(2)); + } catch (err) { + if (!(err instanceof RatchetError)) throw err; + console.error(`FAIL lint:strict: ${err.message}`); + process.exitCode = 1; + } +} diff --git a/server/tests/unit/scripts/strict-lint.test.ts b/server/tests/unit/scripts/strict-lint.test.ts new file mode 100644 index 000000000..dc4bc7cac --- /dev/null +++ b/server/tests/unit/scripts/strict-lint.test.ts @@ -0,0 +1,137 @@ +/** + * scripts/strict-lint.mjs holds the strict type errors (strictNullChecks + + * noImplicitAny) per file to scripts/strict-baseline.json. The counting + * (scripts/lib/tsc-errors.mjs) is tested on tsc output directly; the + * comparison runs the real script as a child process against throwaway + * server roots (--dir) fed a saved tsc output (--tsc-output), so no test pays + * for a type check. + */ +import { countErrors } from '../../../scripts/lib/tsc-errors.mjs'; + +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; + +const SCRIPT = path.join(__dirname, '../../../scripts/strict-lint.mjs'); + +interface ExecError { + status: number | null; + stdout: string; + stderr: string; +} + +const roots: string[] = []; + +const err = (file: string, line = 1, code = 'TS2322') => + `${file}(${line},5): error ${code}: Type 'x' is not assignable.`; + +function serverRoot(files: Record, baseline: unknown, tscOutput: string): string { + const dir = mkdtempSync(path.join(tmpdir(), 'trek-strict-lint-')); + roots.push(dir); + mkdirSync(path.join(dir, 'scripts'), { recursive: true }); + for (const [file, text] of Object.entries(files)) { + mkdirSync(path.dirname(path.join(dir, file)), { recursive: true }); + writeFileSync(path.join(dir, file), text); + } + if (baseline !== null) { + writeFileSync( + path.join(dir, 'scripts/strict-baseline.json'), + typeof baseline === 'string' ? baseline : JSON.stringify(baseline), + ); + } + writeFileSync(path.join(dir, 'tsc.out'), tscOutput); + return dir; +} + +function run(dir: string, ...args: string[]): { status: number | null; out: string } { + try { + const stdout = execFileSync( + process.execPath, + [SCRIPT, `--dir=${dir}`, `--tsc-output=${path.join(dir, 'tsc.out')}`, ...args], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }, + ); + return { status: 0, out: stdout }; + } catch (e) { + const { status, stdout, stderr } = e as ExecError; + return { status, out: `${stdout}${stderr}` }; + } +} + +afterEach(() => { + for (const dir of roots.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe('strict-lint countErrors', () => { + it('STRICT-001: counts one error per error line and file, not its continuation lines', () => { + const output = [ + err('src/a.ts', 1), + " Type 'string | undefined' is not assignable to type 'string'.", + " Type 'undefined' is not assignable to type 'string'.", + err('src/a.ts', 9, 'TS7006'), + err('src/nest/b/b.service.ts', 3), + '', + ].join('\n'); + expect(countErrors(output)).toEqual({ counts: { 'src/a.ts': 2, 'src/nest/b/b.service.ts': 1 }, global: [] }); + }); + + it('STRICT-002: reads CRLF output and Windows paths the same as LF and POSIX ones', () => { + const output = `${err('src\\nest\\a.ts')}\r\n${err('./src/b.ts')}\r\n`; + expect(countErrors(output).counts).toEqual({ 'src/nest/a.ts': 1, 'src/b.ts': 1 }); + }); + + it('STRICT-003: keeps an error that names no file apart, and ignores anything else', () => { + const output = [ + "error TS5023: Unknown compiler option 'x'.", + 'Found 3 errors in 2 files.', + 'src/a.ts is fine', + ].join('\n'); + expect(countErrors(output)).toEqual({ counts: {}, global: ["error TS5023: Unknown compiler option 'x'."] }); + }); +}); + +describe('strict-lint.mjs', () => { + it('STRICT-010: passes when every file sits at or under its entry', () => { + const dir = serverRoot({ 'src/a.ts': '' }, { 'src/a.ts': 2 }, `${err('src/a.ts')}\n${err('src/a.ts', 2)}\n`); + const { status, out } = run(dir); + expect(status).toBe(0); + expect(out).toContain('strict: 2 error(s) in 1 file(s), baseline 2 in 1'); + }); + + it('STRICT-011: fails a new file with any strict error, and a baselined file past its entry', () => { + const fresh = run(serverRoot({}, {}, `${err('src/new.ts')}\n`)); + expect(fresh.status).toBe(1); + expect(fresh.out).toContain('FAIL src/new.ts: 1 strict error(s), a file without a baseline entry may have none.'); + const grown = run(serverRoot({ 'src/a.ts': '' }, { 'src/a.ts': 1 }, `${err('src/a.ts')}\n${err('src/a.ts', 2)}\n`)); + expect(grown.status).toBe(1); + expect(grown.out).toContain('FAIL src/a.ts: 2 strict error(s), its baseline is 1.'); + }); + + it('STRICT-012: fails an entry above its count or for a gone file until --update lowers it, and never adds one', () => { + const dir = serverRoot( + { 'src/a.ts': '', 'src/b.ts': '' }, + { 'src/a.ts': 3, 'src/b.ts': 1, 'src/gone.ts': 2 }, + `${err('src/a.ts')}\n${err('src/new.ts')}\n`, + ); + const before = run(dir); + expect(before.status).toBe(1); + expect(before.out).toContain('FAIL src/a.ts is held at 3 in scripts/strict-baseline.json, but there are 1 now.'); + expect(before.out).toContain( + 'FAIL src/gone.ts is held at 2 in scripts/strict-baseline.json, but the file is gone.', + ); + expect(before.out).toContain('npm run lint:strict -- --update'); + // The new file still fails after the update; the baseline only lost entries. + expect(run(dir, '--update').status).toBe(1); + expect(JSON.parse(readFileSync(path.join(dir, 'scripts/strict-baseline.json'), 'utf8'))).toEqual({ 'src/a.ts': 1 }); + }); + + it('STRICT-013: fails closed on a missing or broken baseline and on an error outside any file', () => { + expect(run(serverRoot({}, null, '')).out).toContain('strict-baseline.json cannot be read'); + expect(run(serverRoot({}, '{ broken', '')).status).toBe(1); + expect(run(serverRoot({}, { 'src/a.ts': 0 }, '')).status).toBe(1); + const config = run(serverRoot({}, {}, "error TS5023: Unknown compiler option 'x'.\n")); + expect(config.status).toBe(1); + expect(config.out).toContain('tsc reported errors outside any file'); + }); +}); diff --git a/server/tsconfig.strict.json b/server/tsconfig.strict.json new file mode 100644 index 000000000..49060b7d6 --- /dev/null +++ b/server/tsconfig.strict.json @@ -0,0 +1,13 @@ +{ + // The strict ratchet (npm run lint:strict, scripts/strict-lint.mjs): the + // build config with strictNullChecks and noImplicitAny switched on. Nothing + // compiles with it; the script counts its errors per file against + // scripts/strict-baseline.json, so a file may only lose them and a new file + // starts at none. + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "strictNullChecks": true, + "noImplicitAny": true + } +} diff --git a/wiki/Development-environment.md b/wiki/Development-environment.md index 13901d88b..e91a0b769 100644 --- a/wiki/Development-environment.md +++ b/wiki/Development-environment.md @@ -187,6 +187,7 @@ The `@trek/shared` package is the single source of truth for code shared between | `npm run check:db-types` | Verify the generated Kysely table types match the migrations (CI gate) | | `npm run probe:pg` | Run the dialect helpers and repository statements against Postgres (CI job, needs `TREK_PG_PROBE_URL`) | | `npm run lint:format` | Every file outside the shrinking baseline is Prettier-formatted (CI gate) | +| `npm run lint:strict` | Strict type errors per file may not grow past their baseline; a new file has none (CI gate) | | `npm run format` | Format source | ### Client (`/client`)