ci(server): strict type errors per file may only shrink

tsconfig.strict.json is the build config with strictNullChecks and
noImplicitAny on. lint:strict runs tsc over it and holds the errors per file
to scripts/strict-baseline.json (204 in 109 files today): a file may only
lose errors, a new file starts at none, and an entry above its count fails
until --update lowers it. An error outside any file or an unreadable tsc run
fails closed. It runs in the server job; the build itself stays on
strict: false.
This commit is contained in:
Maurice
2026-10-10 12:19:43 +02:00
parent 1ea435d730
commit b1da50466d
10 changed files with 413 additions and 1 deletions
+29
View File
@@ -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 };
}
+111
View File
@@ -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
}
+110
View File
@@ -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=<path> points it at another server root and --tsc-output=<file> 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;
}
}