mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
feat(server): gate writes outside a transaction with lint:tx
The rule that a multi-statement write runs in uow.transactional was written down but nothing held it. scripts/tx-writes.mjs reads every class under src/ with the TypeScript parser and counts, per method, the writes it issues outside a transactional callback: repository calls that build an INSERT, UPDATE or DELETE, calls to methods that write (on the class itself or on a constructor-injected one), and a transaction holding writes as one. A write in a loop counts twice, of two branches only the larger counts, local helpers count where they are called, and a helper whose every call sits inside a transaction is not reported. --explain lists the line of every call that counted. Today's 102 reported methods sit in scripts/tx-writes-baseline.json and may only shrink; most are syncs with network I/O between their writes, plugin lifecycle steps and adapters calling two service writes. A new method fails CI, and an entry above what its method holds fails until --update lowers it, the same ratchet as lint:size and lint:boundaries. Two JSDoc tags mark the deliberate cases with their reason in the tag: @txStandalone for a write that stands on its own whatever the caller does (the audit row, notification sends, the booking price mirror, the assignment journey refresh, the quota retention purge), @txIndependent for writes complete one by one on purpose (the OAuth token retention sweep, the notification claim and its release). Runs in CI after lint:boundaries; documented in server/CLAUDE.md, the root CLAUDE.md and the wiki's dev setup page.
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"src/db/repositories/DemoRepository.ts#DemoRepository.restoreInstanceApiKeys": 2,
|
||||
"src/nest/admin/admin.controller.ts#AdminController.updateAddon": 2,
|
||||
"src/nest/atlas/atlas.service.ts#AtlasService.cacheRegionsInBackground": 2,
|
||||
"src/nest/atlas/atlas.service.ts#AtlasService.repairStaleRegionCache": 2,
|
||||
"src/nest/atlas/place-regions-repair.job.ts#PlaceRegionsRepairJob.runOnce": 2,
|
||||
"src/nest/auth/passkey.service.ts#PasskeyService.passkeyLoginOptions": 2,
|
||||
"src/nest/auth/passkey.service.ts#PasskeyService.passkeyLoginVerify": 2,
|
||||
"src/nest/auth/passkey.service.ts#PasskeyService.passkeyRegisterOptions": 2,
|
||||
"src/nest/auth/passkey.service.ts#PasskeyService.passkeyRegisterVerify": 2,
|
||||
"src/nest/auth/user-cleanup.service.ts#UserCleanupService.erasePluginUserData": 2,
|
||||
"src/nest/booking-import/booking-import.service.ts#BookingImportService.confirm": 6,
|
||||
"src/nest/doc-sync/doc-sync-config.service.ts#DocSyncConfigService.upsertConnection": 3,
|
||||
"src/nest/doc-sync/doc-sync.controller.ts#DocSyncController.createLink": 4,
|
||||
"src/nest/doc-sync/doc-sync.controller.ts#DocSyncController.deleteConnection": 2,
|
||||
"src/nest/doc-sync/doc-sync.controller.ts#DocSyncController.deleteLink": 2,
|
||||
"src/nest/doc-sync/doc-sync.controller.ts#DocSyncController.listConnections": 2,
|
||||
"src/nest/doc-sync/doc-sync.controller.ts#DocSyncController.syncNow": 2,
|
||||
"src/nest/doc-sync/doc-sync.controller.ts#DocSyncController.testConnection": 2,
|
||||
"src/nest/doc-sync/doc-sync.controller.ts#DocSyncController.upsertConnection": 2,
|
||||
"src/nest/doc-sync/doc-sync.job.ts#DocSyncJob.tick": 3,
|
||||
"src/nest/doc-sync/doc-sync.mcp.ts#DocSyncMcp.syncNow": 4,
|
||||
"src/nest/doc-sync/doc-sync.service.ts#DocSyncService.applyAction": 2,
|
||||
"src/nest/doc-sync/doc-sync.service.ts#DocSyncService.pull": 3,
|
||||
"src/nest/doc-sync/doc-sync.service.ts#DocSyncService.push": 2,
|
||||
"src/nest/doc-sync/doc-sync.service.ts#DocSyncService.renameRemoteTo": 2,
|
||||
"src/nest/doc-sync/doc-sync.service.ts#DocSyncService.resolveConflict": 3,
|
||||
"src/nest/doc-sync/doc-sync.service.ts#DocSyncService.runLink": 4,
|
||||
"src/nest/doc-sync/doc-sync.service.ts#DocSyncService.syncLink": 2,
|
||||
"src/nest/integrations/airtrail-import.service.ts#AirtrailImportService.importAirtrailFlights": 8,
|
||||
"src/nest/integrations/airtrail-link.service.ts#AirtrailLinkService.pushReservationToAirtrail": 2,
|
||||
"src/nest/integrations/airtrail-sync.service.ts#AirtrailSyncService.runAirtrailSync": 2,
|
||||
"src/nest/integrations/airtrail-sync.service.ts#AirtrailSyncService.syncOwner": 8,
|
||||
"src/nest/integrations/airtrail.service.ts#AirtrailService.saveSettings": 2,
|
||||
"src/nest/integrations/dawarich-sync.service.ts#DawarichSyncService.runSync": 2,
|
||||
"src/nest/integrations/dawarich-sync.service.ts#DawarichSyncService.syncTripWindow": 11,
|
||||
"src/nest/integrations/dawarich-sync.service.ts#DawarichSyncService.syncUserOnce": 4,
|
||||
"src/nest/journey/journey.controller.ts#JourneyController.galleryProviderPhotos": 2,
|
||||
"src/nest/journey/journey.controller.ts#JourneyController.providerPhotos": 2,
|
||||
"src/nest/journey/journey.controller.ts#JourneyController.uploadEntryPhotos": 4,
|
||||
"src/nest/journey/journey.mcp.ts#JourneyMcp.addJourneyProviderPhotos": 2,
|
||||
"src/nest/maps/maps.service.ts#MapsService.getPlaceDetails": 2,
|
||||
"src/nest/maps/maps.service.ts#MapsService.getPlaceDetailsExpanded": 2,
|
||||
"src/nest/maps/maps.service.ts#MapsService.getPlacePhoto": 7,
|
||||
"src/nest/memories/immich.controller.ts#ImmichMemoriesController.putSettings": 2,
|
||||
"src/nest/memories/photo-resolver.service.ts#PhotoResolverService.streamCachedThumbnail": 3,
|
||||
"src/nest/memories/photo-resolver.service.ts#PhotoResolverService.streamPhoto": 2,
|
||||
"src/nest/memories/synology.service.ts#SynologyService._fetchAllSynologyAlbums": 2,
|
||||
"src/nest/memories/synology.service.ts#SynologyService._getSynologySession": 2,
|
||||
"src/nest/memories/synology.service.ts#SynologyService._requestSynologyApi": 3,
|
||||
"src/nest/memories/synology.service.ts#SynologyService.collectSynologyAlbumSelection": 2,
|
||||
"src/nest/memories/synology.service.ts#SynologyService.getSynologyAlbumPhotos": 2,
|
||||
"src/nest/memories/synology.service.ts#SynologyService.listSynologyAlbums": 3,
|
||||
"src/nest/memories/synology.service.ts#SynologyService.testSynologyConnection": 2,
|
||||
"src/nest/memories/synology.service.ts#SynologyService.updateSynologySettings": 2,
|
||||
"src/nest/memories/trek-photo-cache.job.ts#TrekPhotoCacheJob.onApplicationBootstrap": 2,
|
||||
"src/nest/memories/trek-photo-cache.service.ts#TrekPhotoCacheService.sweepExpired": 2,
|
||||
"src/nest/memories/unified-memories.service.ts#UnifiedMemoriesService.addTripPhotos": 4,
|
||||
"src/nest/memories/unified-memories.service.ts#UnifiedMemoriesService.syncImmichAlbum": 2,
|
||||
"src/nest/memories/unified-memories.service.ts#UnifiedMemoriesService.syncSynologyAlbum": 3,
|
||||
"src/nest/notifications/push/push.controller.ts#PushController.subscribe": 2,
|
||||
"src/nest/notifications/reminder-jobs.service.ts#ReminderJobsService.onApplicationBootstrap": 2,
|
||||
"src/nest/notifications/reminder-jobs.service.ts#ReminderJobsService.todoTick": 4,
|
||||
"src/nest/notifications/reminder-jobs.service.ts#ReminderJobsService.tripTick": 4,
|
||||
"src/nest/notifications/transports/web-push.service.ts#WebPushService.deliver": 3,
|
||||
"src/nest/notifications/transports/web-push.service.ts#WebPushService.deliverTo": 2,
|
||||
"src/nest/notifications/transports/web-push.service.ts#WebPushService.settle": 2,
|
||||
"src/nest/oidc/oidc.controller.ts#OidcController.callback": 2,
|
||||
"src/nest/place-enrichment/place-enrichment.service.ts#PlaceEnrichmentService.collectPhotos": 5,
|
||||
"src/nest/place-enrichment/place-enrichment.service.ts#PlaceEnrichmentService.enrich": 5,
|
||||
"src/nest/place-enrichment/place-enrichment.service.ts#PlaceEnrichmentService.readCache": 2,
|
||||
"src/nest/place-enrichment/place-enrichment.service.ts#PlaceEnrichmentService.storeCandidate": 2,
|
||||
"src/nest/place-photos/place-photo-cache.job.ts#PlacePhotoCacheJob.onApplicationBootstrap": 2,
|
||||
"src/nest/place-photos/place-photo-cache.service.ts#PlacePhotoCacheService.sweepOrphans": 2,
|
||||
"src/nest/places/places.mcp.ts#PlacesMcp.bulkDeletePlaces": 3,
|
||||
"src/nest/places/places.mcp.ts#PlacesMcp.createAndAssignPlace": 2,
|
||||
"src/nest/places/places.mcp.ts#PlacesMcp.deletePlace": 2,
|
||||
"src/nest/places/places.rpc.ts#PlacesRpc.create": 2,
|
||||
"src/nest/places/places.rpc.ts#PlacesRpc.delete": 2,
|
||||
"src/nest/places/places.rpc.ts#PlacesRpc.update": 2,
|
||||
"src/nest/places/places.service.ts#PlacesService.backfillMissingAddresses": 2,
|
||||
"src/nest/places/places.service.ts#PlacesService.enrichImportedList": 2,
|
||||
"src/nest/places/places.service.ts#PlacesService.enrichOne": 4,
|
||||
"src/nest/places/places.service.ts#PlacesService.importGoogleDirections": 2,
|
||||
"src/nest/places/places.service.ts#PlacesService.importGoogleList": 2,
|
||||
"src/nest/places/places.service.ts#PlacesService.importNaverList": 2,
|
||||
"src/nest/plugins/plugin-runtime.service.ts#PluginRuntimeService.activate": 2,
|
||||
"src/nest/plugins/plugin-runtime.service.ts#PluginRuntimeService.deactivateForDisabledAddon": 2,
|
||||
"src/nest/plugins/plugin-runtime.service.ts#PluginRuntimeService.deactivateWithDependents": 2,
|
||||
"src/nest/plugins/plugin-runtime.service.ts#PluginRuntimeService.link": 3,
|
||||
"src/nest/plugins/plugin-runtime.service.ts#PluginRuntimeService.onApplicationBootstrap": 7,
|
||||
"src/nest/plugins/plugin-runtime.service.ts#PluginRuntimeService.reload": 2,
|
||||
"src/nest/plugins/plugin-runtime.service.ts#PluginRuntimeService.runDrainOnce": 4,
|
||||
"src/nest/plugins/plugin-runtime.service.ts#PluginRuntimeService.setOperatorEgressHosts": 2,
|
||||
"src/nest/plugins/plugin-runtime.service.ts#PluginRuntimeService.sideload": 2,
|
||||
"src/nest/plugins/plugin-runtime.service.ts#PluginRuntimeService.update": 3,
|
||||
"src/nest/plugins/plugins.controller.ts#PluginsController.install": 2,
|
||||
"src/nest/plugins/plugins.controller.ts#PluginsController.update": 2,
|
||||
"src/nest/plugins/plugins.controller.ts#PluginsController.updateConfig": 3,
|
||||
"src/nest/reservations/reservations.controller.ts#ReservationsController.update": 2,
|
||||
"src/nest/trip-members/trip-members.service.ts#TripMembersService.deleteGuest": 2,
|
||||
"src/nest/trips/trips.service.ts#TripsService.update": 2,
|
||||
"src/nest/vacay/vacay.service.ts#VacayService.updatePlan": 2
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* lint:tx: a method that writes more than once must do it inside one transaction.
|
||||
*
|
||||
* A crash or a thrown error between two writes leaves half a change behind (a
|
||||
* journey without its owner row, a reservation whose budget line was not
|
||||
* updated). The rule in server/CLAUDE.md is that a multi-statement write runs
|
||||
* in `await this.uow.transactional(...)`; this script finds the methods that
|
||||
* do not, as far as a static read of the source can.
|
||||
*
|
||||
* HOW IT COUNTS
|
||||
* Every class under src/ is read with the TypeScript parser. A repository
|
||||
* method (src/db/repositories/) is a write when it builds an INSERT, UPDATE
|
||||
* or DELETE (insertInto/updateTable/deleteFrom, nativeInsert/nativeUpdate/
|
||||
* nativeDelete, insert/insertMany/upsert/upsertMany, a query builder's
|
||||
* update/delete, flush, or a raw SQL string that starts with a write verb),
|
||||
* directly or through another method of its own class.
|
||||
*
|
||||
* A method's "write units" are then counted over its body:
|
||||
* - a call to a write, on a constructor-injected field (`this.trips.x()`)
|
||||
* or on its own class (`this.x()`), is one unit;
|
||||
* - a `.transactional(...)` call that holds any write is one unit, however
|
||||
* many writes it holds;
|
||||
* - a call to a method of another class that is itself reported counts as
|
||||
* one unit here (the fault is reported once, where it lives);
|
||||
* - a write inside a loop, or inside a callback handed to forEach/map/
|
||||
* reduce and friends, counts twice (it can run any number of times);
|
||||
* - of two branches (if/else, a ternary, the cases of a switch, an `if`
|
||||
* that returns or throws against the rest of its block) only the larger
|
||||
* one counts, since only one of them runs.
|
||||
* A method tagged `@txStandalone` in its JSDoc (an audit row, a notification,
|
||||
* a failure record, an idempotent lazy provision, a non-fatal derived refresh)
|
||||
* adds no unit to its callers; the tag says why in its text. A method tagged
|
||||
* `@txIndependent` (writes that are each complete on their own on purpose,
|
||||
* such as a retention sweep in passes or a sync with network I/O between its
|
||||
* writes) is not reported itself.
|
||||
* A method holding two or more units is reported, unless every call to it
|
||||
* that the script can resolve sits inside a `.transactional(...)` callback or
|
||||
* in a method that is itself only ever called that way (a helper that runs
|
||||
* inside its callers' transaction).
|
||||
*
|
||||
* WHAT IT CANNOT SEE
|
||||
* A local helper (`const f = async () => ...` inside the method) is counted
|
||||
* where it is called, not where it is declared.
|
||||
* Calls through anything but `this.<field>.<method>()`, `this.<method>()` and
|
||||
* such a local helper (a free function, a value passed in as an argument, a
|
||||
* dynamic dispatch) are not followed, and a write after network I/O that cannot share a
|
||||
* transaction with it is still reported: those entries stay in the baseline.
|
||||
*
|
||||
* THE RATCHET
|
||||
* scripts/tx-writes-baseline.json holds today's reported methods with their
|
||||
* unit count. A method not in it fails, and so does one past its count. An
|
||||
* entry above what its method holds now, or for a method that is no longer
|
||||
* reported, fails too until --update lowers or drops it, so a fixed method
|
||||
* cannot quietly regress.
|
||||
*
|
||||
* npm run lint:tx check against the baseline (CI)
|
||||
* npm run lint:tx -- --update lower the baseline to what the code holds now;
|
||||
* it never raises or adds an entry
|
||||
* npm run lint:tx -- --list print every reported method with its count
|
||||
* npm run lint:tx -- --explain the same, with the line of each call that counted
|
||||
*
|
||||
* --dir=<path> points the check at another server root (the unit tests use it).
|
||||
*/
|
||||
import { existsSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { join, relative, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const require_ = createRequire(import.meta.url);
|
||||
const ts = require_('typescript');
|
||||
|
||||
const SOURCE = /\.ts$/;
|
||||
const SKIP = /\.(?:test|spec|d)\.ts$/;
|
||||
const REPOSITORY_DIR = 'src/db/repositories/';
|
||||
|
||||
/** Calls that issue a write wherever a repository makes them. */
|
||||
const WRITE_CALLS = new Set([
|
||||
'insertInto',
|
||||
'updateTable',
|
||||
'deleteFrom',
|
||||
'replaceInto',
|
||||
'nativeInsert',
|
||||
'nativeUpdate',
|
||||
'nativeDelete',
|
||||
'insert',
|
||||
'insertMany',
|
||||
'upsert',
|
||||
'upsertMany',
|
||||
'update',
|
||||
'delete',
|
||||
'truncate',
|
||||
'flush',
|
||||
'persistAndFlush',
|
||||
'removeAndFlush',
|
||||
]);
|
||||
|
||||
/** The base-class writes a service may call on a repository field directly. */
|
||||
const BASE_WRITES = new Set([
|
||||
'nativeInsert',
|
||||
'nativeUpdate',
|
||||
'nativeDelete',
|
||||
'insert',
|
||||
'insertMany',
|
||||
'upsert',
|
||||
'upsertMany',
|
||||
]);
|
||||
|
||||
/**
|
||||
* A method whose JSDoc carries this tag writes something that stands on its own,
|
||||
* whatever its caller does around it: an audit row, a notification, a failure
|
||||
* record, an idempotent lazy provision, a non-fatal derived refresh. Its calls
|
||||
* add no unit to the caller. The method's own body is still counted.
|
||||
*/
|
||||
export const STANDALONE_TAG = 'txStandalone';
|
||||
|
||||
/**
|
||||
* A method whose JSDoc carries this tag writes in steps that are each complete
|
||||
* on their own on purpose: a retention sweep that deletes in passes, a sync
|
||||
* whose network round trips sit between its writes, one transaction per item
|
||||
* of a batch. It is not reported; the tag's text says why.
|
||||
*/
|
||||
export const INDEPENDENT_TAG = 'txIndependent';
|
||||
|
||||
const RAW_WRITE = /^\s*(?:INSERT|UPDATE|DELETE|REPLACE)\b/i;
|
||||
|
||||
/** Array methods that run their callback once per element. */
|
||||
const LOOP_CALLBACKS = new Set(['forEach', 'map', 'flatMap', 'filter', 'reduce', 'some', 'every', 'find', 'findIndex']);
|
||||
|
||||
function walk(dir, files = []) {
|
||||
for (const name of readdirSync(dir)) {
|
||||
if (name === 'node_modules') continue;
|
||||
const path = join(dir, name);
|
||||
if (statSync(path).isDirectory()) walk(path, files);
|
||||
else if (SOURCE.test(name) && !SKIP.test(name)) files.push(path);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function nameOf(node) {
|
||||
return node && node.name && (ts.isIdentifier(node.name) || ts.isPrivateIdentifier(node.name)) ? node.name.text : null;
|
||||
}
|
||||
|
||||
/** The class a type annotation names: `FooRepository`, `Foo | undefined`, `Readonly<Foo>` all give `Foo...`. */
|
||||
function typeName(type) {
|
||||
if (!type) return null;
|
||||
if (ts.isTypeReferenceNode(type)) {
|
||||
const n = type.typeName;
|
||||
return ts.isIdentifier(n) ? n.text : n.right.text;
|
||||
}
|
||||
if (ts.isUnionTypeNode(type)) {
|
||||
for (const t of type.types) {
|
||||
const found = typeName(t);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Every class in the tree, with its methods and the classes its constructor injects. */
|
||||
export function collect(serverDir) {
|
||||
const srcDir = join(serverDir, 'src');
|
||||
if (!existsSync(srcDir) || !statSync(srcDir).isDirectory()) {
|
||||
throw new Error(`src/ does not exist under ${serverDir}: the check would pass without looking at anything`);
|
||||
}
|
||||
const classes = new Map();
|
||||
for (const path of walk(srcDir)) {
|
||||
const file = relative(serverDir, path).split('\\').join('/');
|
||||
const sf = ts.createSourceFile(path, readFileSync(path, 'utf8'), ts.ScriptTarget.Latest, true);
|
||||
const visit = (node) => {
|
||||
if (ts.isClassDeclaration(node) && node.name) {
|
||||
const cls = {
|
||||
name: node.name.text,
|
||||
file,
|
||||
repository: file.startsWith(REPOSITORY_DIR),
|
||||
fields: new Map(),
|
||||
methods: new Map(),
|
||||
standalone: new Set(),
|
||||
independent: new Set(),
|
||||
};
|
||||
for (const member of node.members) {
|
||||
if (ts.isConstructorDeclaration(member)) {
|
||||
for (const p of member.parameters) {
|
||||
const field = ts.isIdentifier(p.name) ? p.name.text : null;
|
||||
const type = typeName(p.type);
|
||||
if (field && type) cls.fields.set(field, type);
|
||||
}
|
||||
} else if (ts.isPropertyDeclaration(member) && member.initializer && ts.isArrowFunction(member.initializer)) {
|
||||
const method = nameOf(member);
|
||||
if (method) cls.methods.set(method, member.initializer);
|
||||
} else if (ts.isPropertyDeclaration(member)) {
|
||||
const field = nameOf(member);
|
||||
const type = typeName(member.type);
|
||||
if (field && type) cls.fields.set(field, type);
|
||||
} else if (ts.isMethodDeclaration(member) && member.body) {
|
||||
const method = nameOf(member);
|
||||
if (method) cls.methods.set(method, member);
|
||||
const tags = method ? ts.getJSDocTags(member).map((tag) => tag.tagName.text) : [];
|
||||
if (tags.includes(STANDALONE_TAG)) cls.standalone.add(method);
|
||||
if (tags.includes(INDEPENDENT_TAG)) cls.independent.add(method);
|
||||
}
|
||||
}
|
||||
// Two classes of one name: keep the first; the second is reached by no field type.
|
||||
if (!classes.has(cls.name)) classes.set(cls.name, cls);
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(sf);
|
||||
}
|
||||
return classes;
|
||||
}
|
||||
|
||||
/** `this.x` → 'x', `this.#x` → '#x', otherwise null. */
|
||||
function thisMember(expr) {
|
||||
if (ts.isPropertyAccessExpression(expr) && expr.expression.kind === ts.SyntaxKind.ThisKeyword) return expr.name.text;
|
||||
return null;
|
||||
}
|
||||
|
||||
function exits(stmt) {
|
||||
if (!stmt) return false;
|
||||
if (ts.isReturnStatement(stmt) || ts.isThrowStatement(stmt)) return true;
|
||||
if (ts.isBlock(stmt)) return stmt.statements.length > 0 && exits(stmt.statements[stmt.statements.length - 1]);
|
||||
if (ts.isIfStatement(stmt)) return exits(stmt.thenStatement) && exits(stmt.elseStatement);
|
||||
return false;
|
||||
}
|
||||
|
||||
export function analyse(classes) {
|
||||
const units = new Map(); // "Class.method" → number
|
||||
const inProgress = new Set();
|
||||
const callSites = new Map(); // "Class.method" → [{ caller, covered }]
|
||||
const reading = []; // the "Class.method" whose body is being read
|
||||
const sites = new Map(); // "Class.method" → the calls that added a unit, for --explain
|
||||
const frames = [];
|
||||
const locals = []; // per method read: local `const f = async () => ...` helpers, counted where they are called
|
||||
const running = new Set();
|
||||
|
||||
/** Records the call that added a unit to the method being read, and returns that unit. */
|
||||
function unit(node, label) {
|
||||
const sf = node.getSourceFile();
|
||||
const line = sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
|
||||
frames[frames.length - 1]?.push(`${line}: ${label}`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
/** What `callee` is: { cls, method } on a known class, a base write, or null. */
|
||||
function resolve(cls, callee) {
|
||||
if (!ts.isPropertyAccessExpression(callee)) return null;
|
||||
const method = callee.name.text;
|
||||
const own = thisMember(callee);
|
||||
if (own !== null) return cls.methods.has(own) ? { cls, method: own } : null;
|
||||
const field = thisMember(callee.expression);
|
||||
if (field === null) return null;
|
||||
const target = classes.get(cls.fields.get(field));
|
||||
if (!target) return null;
|
||||
if (target.methods.has(method)) return { cls: target, method };
|
||||
if (target.repository && BASE_WRITES.has(method)) return { write: true };
|
||||
return null;
|
||||
}
|
||||
|
||||
function directWrite(cls, call) {
|
||||
if (!cls.repository) return false;
|
||||
const callee = call.expression;
|
||||
if (ts.isPropertyAccessExpression(callee) && WRITE_CALLS.has(callee.name.text)) {
|
||||
// `this.update(...)` on a repository's own method is resolved as a call instead.
|
||||
if (callee.expression.kind === ts.SyntaxKind.ThisKeyword && cls.methods.has(callee.name.text)) return false;
|
||||
return true;
|
||||
}
|
||||
const first = call.arguments[0];
|
||||
return Boolean(first && (ts.isStringLiteral(first) || ts.isNoSubstitutionTemplateLiteral(first)) && RAW_WRITE.test(first.text));
|
||||
}
|
||||
|
||||
/** Write units of `node`, read inside `cls`. `covered` is true inside a transactional callback. */
|
||||
function count(cls, node, covered) {
|
||||
if (!node) return 0;
|
||||
if (ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) return 0;
|
||||
if (ts.isBlock(node) || ts.isSourceFile(node) || ts.isModuleBlock(node)) return statements(cls, node.statements, 0, covered);
|
||||
if (ts.isIfStatement(node)) {
|
||||
return count(cls, node.expression, covered) + Math.max(count(cls, node.thenStatement, covered), count(cls, node.elseStatement, covered));
|
||||
}
|
||||
if (ts.isConditionalExpression(node)) {
|
||||
return count(cls, node.condition, covered) + Math.max(count(cls, node.whenTrue, covered), count(cls, node.whenFalse, covered));
|
||||
}
|
||||
if (ts.isSwitchStatement(node)) {
|
||||
let max = 0;
|
||||
for (const clause of node.caseBlock.clauses) max = Math.max(max, statements(cls, clause.statements, 0, covered));
|
||||
return count(cls, node.expression, covered) + max;
|
||||
}
|
||||
if (ts.isForStatement(node) || ts.isForOfStatement(node) || ts.isForInStatement(node) || ts.isWhileStatement(node) || ts.isDoStatement(node)) {
|
||||
let head = 0;
|
||||
if (ts.isForStatement(node)) head = count(cls, node.initializer, covered);
|
||||
else if (!ts.isDoStatement(node) && !ts.isWhileStatement(node)) head = count(cls, node.expression, covered);
|
||||
return head + 2 * count(cls, node.statement, covered);
|
||||
}
|
||||
if (ts.isCallExpression(node)) return call(cls, node, covered);
|
||||
if (
|
||||
ts.isVariableDeclaration(node) &&
|
||||
ts.isIdentifier(node.name) &&
|
||||
node.initializer &&
|
||||
(ts.isArrowFunction(node.initializer) || ts.isFunctionExpression(node.initializer))
|
||||
) {
|
||||
locals[locals.length - 1].set(node.name.text, node.initializer.body);
|
||||
return 0;
|
||||
}
|
||||
let total = 0;
|
||||
ts.forEachChild(node, (child) => {
|
||||
total += count(cls, child, covered);
|
||||
});
|
||||
return total;
|
||||
}
|
||||
|
||||
/** A block read statement by statement: an `if` that always leaves competes with the rest of the block. */
|
||||
function statements(cls, list, from, covered) {
|
||||
let total = 0;
|
||||
for (let i = from; i < list.length; i++) {
|
||||
const stmt = list[i];
|
||||
if (ts.isIfStatement(stmt) && !stmt.elseStatement && exits(stmt.thenStatement)) {
|
||||
const rest = statements(cls, list, i + 1, covered);
|
||||
return total + count(cls, stmt.expression, covered) + Math.max(count(cls, stmt.thenStatement, covered), rest);
|
||||
}
|
||||
total += count(cls, stmt, covered);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function call(cls, node, covered) {
|
||||
const callee = node.expression;
|
||||
const method = ts.isPropertyAccessExpression(callee) ? callee.name.text : null;
|
||||
const receiver = ts.isPropertyAccessExpression(callee) ? count(cls, callee.expression, covered) : count(cls, callee, covered);
|
||||
if (method === 'transactional') {
|
||||
let inner = 0;
|
||||
frames.push([]); // what runs inside is one unit, not a list of its own
|
||||
for (const arg of node.arguments) inner += count(cls, arg, true);
|
||||
frames.pop();
|
||||
return receiver + (inner > 0 ? unit(node, 'transactional(...)') : 0);
|
||||
}
|
||||
let args = 0;
|
||||
for (const arg of node.arguments) args += count(cls, arg, covered);
|
||||
if (method && LOOP_CALLBACKS.has(method)) args *= 2;
|
||||
const local = ts.isIdentifier(callee) ? callee.text : null;
|
||||
const helper = local !== null ? locals[locals.length - 1].get(local) : undefined;
|
||||
if (helper && !running.has(helper)) {
|
||||
running.add(helper);
|
||||
const inner = count(cls, helper, covered);
|
||||
running.delete(helper);
|
||||
return receiver + args + inner;
|
||||
}
|
||||
const text = ts.isPropertyAccessExpression(callee) ? callee.getText().replace(/\s+/g, '') : method;
|
||||
if (directWrite(cls, node)) return receiver + args + unit(node, text);
|
||||
const target = resolve(cls, callee);
|
||||
if (!target) return receiver + args;
|
||||
if (target.write) return receiver + args + unit(node, text);
|
||||
if (target.cls.standalone.has(target.method)) return receiver + args;
|
||||
const key = `${target.cls.name}.${target.method}`;
|
||||
const list = callSites.get(key) ?? [];
|
||||
list.push({ caller: reading[reading.length - 1], covered });
|
||||
callSites.set(key, list);
|
||||
return receiver + args + (unitsOf(target.cls, target.method) > 0 ? unit(node, text) : 0);
|
||||
}
|
||||
|
||||
function unitsOf(cls, method) {
|
||||
const key = `${cls.name}.${method}`;
|
||||
if (units.has(key)) return units.get(key);
|
||||
if (inProgress.has(key)) return 0; // recursion: the outer frame counts the body once
|
||||
inProgress.add(key);
|
||||
const fn = cls.methods.get(method);
|
||||
frames.push([]);
|
||||
locals.push(new Map());
|
||||
reading.push(key);
|
||||
const n = count(cls, fn.body, false);
|
||||
reading.pop();
|
||||
locals.pop();
|
||||
sites.set(key, frames.pop());
|
||||
inProgress.delete(key);
|
||||
units.set(key, n);
|
||||
return n;
|
||||
}
|
||||
|
||||
for (const cls of classes.values()) for (const method of cls.methods.keys()) unitsOf(cls, method);
|
||||
|
||||
// Bound to a transaction: called at least once, and every call either sits in a
|
||||
// transactional callback or comes from a method that is bound itself. Grown from
|
||||
// nothing to a fixed point, so a cycle of methods calling each other binds none.
|
||||
const bound = new Set();
|
||||
for (let grew = true; grew; ) {
|
||||
grew = false;
|
||||
for (const [key, list] of callSites) {
|
||||
if (bound.has(key) || list.length === 0) continue;
|
||||
if (list.every((site) => site.covered || bound.has(site.caller))) {
|
||||
bound.add(key);
|
||||
grew = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const reported = {};
|
||||
const explained = {};
|
||||
for (const cls of classes.values()) {
|
||||
for (const method of cls.methods.keys()) {
|
||||
const key = `${cls.name}.${method}`;
|
||||
const n = units.get(key);
|
||||
if (n < 2) continue;
|
||||
if (bound.has(key) || cls.independent.has(method)) continue;
|
||||
reported[`${cls.file}#${key}`] = n;
|
||||
explained[`${cls.file}#${key}`] = sites.get(key);
|
||||
}
|
||||
}
|
||||
return { found: Object.fromEntries(Object.entries(reported).sort(([a], [b]) => a.localeCompare(b))), sites: explained };
|
||||
}
|
||||
|
||||
/** The baseline as committed. Missing or malformed stops the run instead of reading as empty. */
|
||||
export function readBaseline(path) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(readFileSync(path, 'utf8'));
|
||||
} catch (err) {
|
||||
throw new Error(`scripts/tx-writes-baseline.json cannot be read: ${err.message}`);
|
||||
}
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('scripts/tx-writes-baseline.json must be an object of methods to write counts');
|
||||
}
|
||||
for (const [key, n] of Object.entries(parsed)) {
|
||||
if (!Number.isInteger(n) || n < 2) {
|
||||
throw new Error(`scripts/tx-writes-baseline.json: ${key} holds ${JSON.stringify(n)}, expected an integer of at least 2`);
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/** The baseline lowered to what the code holds now. Never raises an entry, never adds one. */
|
||||
export function lowerBaseline(baseline, found) {
|
||||
const lowered = {};
|
||||
for (const [key, allowed] of Object.entries(baseline)) {
|
||||
if (key in found) lowered[key] = Math.min(allowed, found[key]);
|
||||
}
|
||||
return Object.fromEntries(Object.entries(lowered).sort(([a], [b]) => a.localeCompare(b)));
|
||||
}
|
||||
|
||||
export function compare(baseline, found) {
|
||||
const grown = Object.entries(found).filter(([key, n]) => n > (baseline[key] ?? 1));
|
||||
const lowered = lowerBaseline(baseline, found);
|
||||
const stale = Object.entries(baseline).filter(([key, n]) => lowered[key] !== n);
|
||||
return { grown, stale };
|
||||
}
|
||||
|
||||
function main(argv) {
|
||||
const dirArg = argv.find((a) => a.startsWith('--dir='));
|
||||
const serverDir = dirArg ? resolve(dirArg.slice('--dir='.length)) : fileURLToPath(new URL('..', import.meta.url));
|
||||
const baselinePath = join(serverDir, 'scripts', 'tx-writes-baseline.json');
|
||||
|
||||
const { found, sites } = analyse(collect(serverDir));
|
||||
if (argv.includes('--list') || argv.includes('--explain')) {
|
||||
for (const [key, n] of Object.entries(found)) {
|
||||
console.log(`${n}\t${key}`);
|
||||
if (argv.includes('--explain')) for (const site of sites[key]) console.log(`\t\t${site}`);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
let baseline = readBaseline(baselinePath);
|
||||
if (argv.includes('--update')) {
|
||||
baseline = lowerBaseline(baseline, found);
|
||||
writeFileSync(baselinePath, JSON.stringify(baseline, null, 2) + '\n');
|
||||
}
|
||||
|
||||
const { grown, stale } = compare(baseline, found);
|
||||
for (const [key, n] of grown) {
|
||||
const entry = baseline[key];
|
||||
console.error(
|
||||
`FAIL ${key}: ${n} writes outside one transaction` +
|
||||
(entry ? `, its baseline is ${entry}. ` : '. ') +
|
||||
'Wrap the sequence in await this.uow.transactional(...) in the service.',
|
||||
);
|
||||
}
|
||||
for (const [key, entry] of stale) {
|
||||
console.error(
|
||||
`FAIL ${key} is held at ${entry} in scripts/tx-writes-baseline.json, ` +
|
||||
(key in found ? `but it has ${found[key]} now.` : 'but it is no longer reported.'),
|
||||
);
|
||||
}
|
||||
if (stale.length) {
|
||||
console.error('Run npm run lint:tx -- --update to lower the baseline with the change that fixed it.');
|
||||
}
|
||||
console.log(`tx: ${Object.keys(found).length} method(s) with writes outside one transaction, ${Object.keys(baseline).length} held at their baseline`);
|
||||
return grown.length || stale.length ? 1 : 0;
|
||||
}
|
||||
|
||||
const isCli =
|
||||
Boolean(process.argv[1]) && existsSync(process.argv[1]) && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||
if (isCli) {
|
||||
try {
|
||||
process.exitCode = main(process.argv.slice(2));
|
||||
} catch (err) {
|
||||
console.error(`FAIL ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user