From ebd8a4f8c983e52dbdbcc0fdcea3713d0f531c72 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 10 Oct 2026 19:46:25 +0200 Subject: [PATCH] build(server): lint:boundaries counts the RPC and MCP layers as domains and sees getRepository calls src/nest-rpc, src/nest-mcp and src/mcp import domains and are imported by them, but the domain map only knew src/nest, so their edges dropped out of domainCycles and their repository injections out of foreignRepositories. Each layer now counts as one domain. The repository rule reads the syntax tree for @InjectRepository(X) and for getRepository(X) on an EntityManager, so a comment no longer counts and a foreign read through the EntityManager no longer passes. The baseline takes the cycles and the repository reaches the wider view finds; settings -> Users goes, it was a comment. --- server/CLAUDE.md | 2 +- .../scripts/import-boundaries-baseline.json | 118 +++++++++++++++++- server/scripts/import-boundaries.mjs | 73 +++++++++-- server/scripts/repository-owners.json | 3 + .../unit/scripts/import-boundaries.test.ts | 50 ++++++++ 5 files changed, 230 insertions(+), 16 deletions(-) diff --git a/server/CLAUDE.md b/server/CLAUDE.md index 4ab6a207f..e8dd08b9f 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -37,7 +37,7 @@ npm run lint:response-contracts # route handlers without @ResponseContract may o npm run contracts:dto-open # open shapes in the createZodDto schemas may only shrink (runs in the unit tests) ``` -**`lint:boundaries`** (`scripts/import-boundaries.mjs`) parses every import under `src/` and holds seven lists in `scripts/import-boundaries-baseline.json`: load-time file cycles (type-only and lazy imports, and the MikroORM entity relations, do not count), dependencies between two `nest/` folders that already depend on each other, shared-kernel files (`nest/common`, `nest/database`, `nest/app-config`, `nest/auth-core`) importing a domain, a domain importing another domain's internals, `src/db` importing `src/nest`, a file under a domain's `providers/` folder importing that domain's orchestrator (`.service.ts`) or `MapsService` (a provider knows how to ask one source; the orchestrator decides which one answers), and a domain injecting (`@InjectRepository`) the repository of an entity another domain owns (`foreignRepositories`; the owner of every injected entity is listed in `scripts/repository-owners.json`, and an entity missing there stops the run). For trip visibility, inject `TripAccessService` (trip-membership) instead of `TripsRepository`. A domain's public surface is its `*.module`, `*.service`, `*.guard`, `*.decorator`, `*.types`, `*.interface`, `*.contract`, `*.constants` and `*.logger` files plus the short `PUBLIC_FILES` list in the script; anything else is an internal. A new entry fails the check, and so does a baseline entry that no longer occurs, so the fix lands with `npm run lint:boundaries -- --update`, which drops such entries and never adds one. +**`lint:boundaries`** (`scripts/import-boundaries.mjs`) parses every import under `src/` and holds seven lists (`src/nest-rpc`, `src/nest-mcp` and `src/mcp` count as one domain each, so moving a file out of `src/nest` does not take its edges out of the count) in `scripts/import-boundaries-baseline.json`: load-time file cycles (type-only and lazy imports, and the MikroORM entity relations, do not count), dependencies between two `nest/` folders that already depend on each other, shared-kernel files (`nest/common`, `nest/database`, `nest/app-config`, `nest/auth-core`) importing a domain, a domain importing another domain's internals, `src/db` importing `src/nest`, a file under a domain's `providers/` folder importing that domain's orchestrator (`.service.ts`) or `MapsService` (a provider knows how to ask one source; the orchestrator decides which one answers), and a domain reaching (`@InjectRepository(X)` or `em.getRepository(X)`) the repository of an entity another domain owns (`foreignRepositories`; the owner of every injected entity is listed in `scripts/repository-owners.json`, and an entity missing there stops the run). For trip visibility, inject `TripAccessService` (trip-membership) instead of `TripsRepository`. A domain's public surface is its `*.module`, `*.service`, `*.guard`, `*.decorator`, `*.types`, `*.interface`, `*.contract`, `*.constants` and `*.logger` files plus the short `PUBLIC_FILES` list in the script; anything else is an internal. A new entry fails the check, and so does a baseline entry that no longer occurs, so the fix lands with `npm run lint:boundaries -- --update`, which drops such entries and never adds one. **`contracts:dto-open`** (`tests/unit/contracts/dto-open-shapes.test.ts`, wrapped by `scripts/dto-open-shapes.mjs`) finds every `export class X extends createZodDto(...)` under `src/`, imports it and counts the open shapes (loose object, record of unknown, `z.unknown()`/`z.any()`, `z.custom()`) in the schema the Zod pipe validates, with shared's `contracts:open` walk. Each DTO class is held to its entry in `scripts/dto-open-shapes-baseline.json`, and a new one may hold none, whatever its schema is called and wherever it is declared. A `createZodDto` call that is not an exported class, or two DTO classes with one name, fail the check. After narrowing a schema, `npm run contracts:dto-open -- --update` lowers the baseline (it never raises or adds an entry). It needs `shared/` built. diff --git a/server/scripts/import-boundaries-baseline.json b/server/scripts/import-boundaries-baseline.json index 82c83cbe3..8a8dcce71 100644 --- a/server/scripts/import-boundaries-baseline.json +++ b/server/scripts/import-boundaries-baseline.json @@ -2,27 +2,106 @@ "fileCycles": [], "domainCycles": [ "accommodations -> assignments", + "accommodations -> nest-rpc", "accommodations -> places", "assignments -> days", + "assignments -> journey", + "assignments -> nest-rpc", + "atlas -> nest-rpc", + "auth -> budget", + "auth -> files", "auth -> plugins", + "budget -> nest-rpc", + "calendar -> days", + "calendar -> reservations", + "categories -> nest-rpc", + "collab -> files", + "collab -> nest-rpc", + "collections -> nest-rpc", + "collections -> places", + "day-notes -> nest-rpc", "days -> accommodations", "days -> assignments", + "days -> nest-rpc", "days -> places", + "files -> nest-rpc", "geo -> maps", + "integrations -> assignments", + "integrations -> atlas", + "integrations -> journey", + "integrations -> places", "integrations -> reservations", + "journey -> atlas", + "journey -> files", + "journey -> nest-rpc", "maps -> geo", "memories -> photos", + "nest-rpc -> plugins", + "packing -> nest-rpc", "photos -> memories", "places -> accommodations", "places -> assignments", + "places -> atlas", + "places -> journey", + "places -> nest-rpc", + "plugins -> accommodations", + "plugins -> assignments", + "plugins -> atlas", + "plugins -> budget", + "plugins -> categories", + "plugins -> collab", + "plugins -> collections", + "plugins -> day-notes", + "plugins -> days", + "plugins -> files", + "plugins -> journey", + "plugins -> nest-rpc", + "plugins -> packing", + "plugins -> places", + "plugins -> reservations", + "plugins -> tags", + "plugins -> todo", "plugins -> trips", + "plugins -> vacay", + "plugins -> weather", + "reservations -> accommodations", + "reservations -> assignments", + "reservations -> budget", + "reservations -> days", "reservations -> integrations", + "reservations -> nest-rpc", + "tags -> nest-rpc", + "todo -> nest-rpc", "trip-members -> auth", + "trip-members -> budget", "trip-members -> trips", + "trip-read-model -> accommodations", + "trip-read-model -> budget", + "trip-read-model -> collab", + "trip-read-model -> days", + "trip-read-model -> files", + "trip-read-model -> packing", + "trip-read-model -> places", + "trip-read-model -> reservations", + "trip-read-model -> todo", "trip-read-model -> trip-members", "trip-read-model -> trips", + "trips -> accommodations", + "trips -> budget", + "trips -> calendar", + "trips -> collab", + "trips -> days", + "trips -> files", + "trips -> nest-rpc", + "trips -> packing", + "trips -> places", + "trips -> reservations", + "trips -> todo", "trips -> trip-members", - "trips -> trip-read-model" + "trips -> trip-read-model", + "trips -> vacay", + "vacay -> nest-rpc", + "weather -> nest-rpc" ], "sharedImportsDomain": [ "nest/common/idempotency-cleanup.job.ts -> nest/audit/audit-log.logger.ts", @@ -61,6 +140,7 @@ "journey -> storage/storage-upload.factory.ts", "maps -> geo/nominatim.client.ts", "maps -> settings/instance-api-keys.ts", + "nest-rpc -> plugins/protocol/envelope.ts", "place-enrichment -> maps/maps.helpers.ts", "place-enrichment -> maps/trek-places.client.ts", "places -> atlas/atlas-geo.ts", @@ -144,8 +224,10 @@ "auth -> TripMembers (owned by trip-membership)", "auth -> Trips (owned by trips)", "auth-core -> AppSettings (owned by settings)", + "auth-core -> UserSessions (owned by sessions)", "auth-core -> Users (owned by auth)", "auth-core -> WebauthnCredentials (owned by auth)", + "backup -> UserSessions (owned by sessions)", "booking-import -> Days (owned by days)", "booking-import -> Reservations (owned by reservations)", "budget -> Places (owned by places)", @@ -164,6 +246,7 @@ "collections -> TripMembers (owned by trip-membership)", "collections -> Trips (owned by trips)", "collections -> Users (owned by auth)", + "common -> Users (owned by auth)", "day-notes -> Days (owned by days)", "days -> DayAccommodations (owned by accommodations)", "days -> DayAssignments (owned by assignments)", @@ -184,6 +267,7 @@ "files -> DayAssignments (owned by assignments)", "files -> Places (owned by places)", "files -> Reservations (owned by reservations)", + "files -> UserSessions (owned by sessions)", "files -> Users (owned by auth)", "google-quota -> AppSettings (owned by settings)", "integrations -> AppSettings (owned by settings)", @@ -210,6 +294,8 @@ "memories -> TripPhotos (owned by photos)", "memories -> Trips (owned by trips)", "memories -> Users (owned by auth)", + "nest-rpc -> Trips (owned by trips)", + "nest-rpc -> Users (owned by auth)", "notifications -> AppSettings (owned by settings)", "notifications -> Settings (owned by settings)", "notifications -> TodoItems (owned by todo)", @@ -235,6 +321,10 @@ "places -> Tags (owned by tags)", "places -> TripMembers (owned by trip-membership)", "places -> Trips (owned by trips)", + "platform -> Photos (owned by photos)", + "platform -> ShareTokens (owned by share)", + "platform -> UserSessions (owned by sessions)", + "platform -> Users (owned by auth)", "plugins -> DayAccommodations (owned by accommodations)", "plugins -> Days (owned by days)", "plugins -> JourneyEntries (owned by journey)", @@ -243,6 +333,7 @@ "plugins -> Reservations (owned by reservations)", "plugins -> Settings (owned by settings)", "plugins -> Trips (owned by trips)", + "plugins -> UserSessions (owned by sessions)", "plugins -> Users (owned by auth)", "public-api -> BucketList (owned by atlas)", "public-api -> DayNotes (owned by day-notes)", @@ -274,7 +365,6 @@ "roadtrip -> Users (owned by auth)", "route-usage -> AppSettings (owned by settings)", "school-holidays -> VacayHolidayCalendars (owned by vacay)", - "settings -> Users (owned by auth)", "share -> BudgetItems (owned by budget)", "share -> Categories (owned by categories)", "share -> CollabMessages (owned by collab)", @@ -299,6 +389,30 @@ "trip-members -> Users (owned by auth)", "trip-membership -> Trips (owned by trips)", "trip-read-model -> Trips (owned by trips)", + "trips -> AssignmentParticipants (owned by assignments)", + "trips -> BudgetCategoryOrder (owned by budget)", + "trips -> BudgetItemMembers (owned by budget)", + "trips -> BudgetItemPayers (owned by budget)", + "trips -> BudgetItems (owned by budget)", + "trips -> DayAccommodations (owned by accommodations)", + "trips -> DayAssignments (owned by assignments)", + "trips -> DayNotes (owned by day-notes)", + "trips -> Days (owned by days)", + "trips -> JourneyEntries (owned by journey)", + "trips -> PackingBags (owned by packing)", + "trips -> PackingItems (owned by packing)", + "trips -> Places (owned by places)", + "trips -> Reservations (owned by reservations)", + "trips -> RoadtripDayBoundaries (owned by roadtrip)", + "trips -> RoadtripDayTracks (owned by roadtrip)", + "trips -> RoadtripPreferences (owned by roadtrip)", + "trips -> RoadtripVias (owned by roadtrip)", + "trips -> Tags (owned by tags)", + "trips -> TodoItems (owned by todo)", + "trips -> TourWaypoints (owned by tours)", + "trips -> Tours (owned by tours)", + "trips -> TripMembers (owned by trip-membership)", + "trips -> Users (owned by auth)", "unsplash -> AppSettings (owned by settings)", "unsplash -> Users (owned by auth)", "vacay -> SchoolHolidayRegions (owned by school-holidays)" diff --git a/server/scripts/import-boundaries.mjs b/server/scripts/import-boundaries.mjs index 1f5154c10..06eaf764c 100644 --- a/server/scripts/import-boundaries.mjs +++ b/server/scripts/import-boundaries.mjs @@ -16,7 +16,9 @@ * So are the imports of src/db/entities/: MikroORM relations * point both ways by design and are read through `() => X` * thunks after every file has loaded. - * domainCycles the same, between src/nest/ folders: domain A + * domainCycles the same, between src/nest/ folders (and the + * layers in LAYERS, src/nest-rpc, src/nest-mcp and src/mcp, + * each counted as one domain): domain A * imports from B while B (directly or through others) * imports from A. Keyed by the domain pair, so a further * import along a dependency that already exists is not new; @@ -38,8 +40,10 @@ * dbImportsNest a file under src/db/ importing from src/nest/. The data * layer sits below the domains, never the other way round. * foreignRepositories - * a class in src/nest// injecting (@InjectRepository) the - * repository of an entity another domain owns. Ownership is + * a class in src/nest// (or one of LAYERS) reaching the + * repository of an entity another domain owns, through + * @InjectRepository(X) or a getRepository(X) call on an + * EntityManager. Ownership is * the map in scripts/repository-owners.json (entity class -> * owning domain folder); an injected entity missing from it * stops the run. Keyed by domain and entity, so a second @@ -282,7 +286,22 @@ function cycleEdges(pairs) { return out; } -const domainOf = (file) => file.match(/^nest\/([^/]+)\//)?.[1] ?? null; +/** + * Layers outside src/nest that work like a domain of their own: the plugin RPC + * kit, the MCP decorator layer and the process-wide MCP state. They import + * domains and domains import them, so they take part in domainCycles and + * foreignRepositories under their folder name; moving a file out of src/nest + * does not take its edges out of the count. + */ +export const LAYERS = ['nest-rpc', 'nest-mcp', 'mcp']; + +/** The domain a src/-relative path belongs to: nest//..., one of LAYERS, or null. */ +export const domainOf = (file) => { + const nest = file.match(/^nest\/([^/]+)\//)?.[1]; + if (nest) return nest; + const top = file.split('/')[0]; + return file.includes('/') && LAYERS.includes(top) ? top : null; +}; /** The orchestrators no provider may import: its own domain's main service, and MapsService everywhere. */ function isOrchestratorOf(providerFile, target) { @@ -293,6 +312,8 @@ function isOrchestratorOf(providerFile, target) { const isProviderFile = (file) => /^nest\/[^/]+\/(?:.+\/)?providers\//.test(file); export function isPublic(target) { + // A layer outside src/nest is a toolkit every domain is written against. + if (!target.startsWith('nest/')) return true; const inner = target.slice('nest/'.length); const domain = inner.split('/')[0]; if (SHARED_DOMAINS.has(domain)) return true; @@ -300,18 +321,44 @@ export function isPublic(target) { return PUBLIC_SUFFIXES.some((s) => target.endsWith(s)); } -/** Every @InjectRepository(Entity) under src/nest: { domain, entity, file }. */ +/** + * The entity classes a file reaches a repository of: `@InjectRepository(X)` + * and `.getRepository(X)`, read from the syntax tree so a comment or + * a string naming them does not count. + */ +export function repositoryEntitiesOf(text, fileName = 'file.ts') { + const source = ts.createSourceFile(fileName, text, ts.ScriptTarget.Latest, false, ts.ScriptKind.TS); + const found = []; + const visit = (node) => { + if (ts.isCallExpression(node) && node.arguments.length > 0 && ts.isIdentifier(node.arguments[0])) { + const callee = node.expression; + const name = ts.isIdentifier(callee) + ? callee.text + : ts.isPropertyAccessExpression(callee) + ? callee.name.text + : null; + if (name === 'InjectRepository' || name === 'getRepository') found.push(node.arguments[0].text); + } + ts.forEachChild(node, visit); + }; + visit(source); + return found; +} + +/** Every repository a class under src/nest or one of LAYERS reaches: { domain, entity, file }. */ export function collectRepositoryInjections(serverDir) { const src = join(serverDir, 'src'); - const nest = join(src, 'nest'); - if (!existsSync(nest)) return []; const found = []; - for (const file of walk(nest)) { - const rel = relative(src, file).split(sep).join('/'); - const domain = domainOf(rel); - if (!domain) continue; - for (const m of readFileSync(file, 'utf8').matchAll(/@InjectRepository\(\s*([A-Za-z_$][\w$]*)\s*\)/g)) { - found.push({ domain, entity: m[1], file: rel }); + for (const top of ['nest', ...LAYERS]) { + const dir = join(src, top); + if (!existsSync(dir)) continue; + for (const file of walk(dir)) { + const rel = relative(src, file).split(sep).join('/'); + const domain = domainOf(rel); + if (!domain) continue; + for (const entity of repositoryEntitiesOf(readFileSync(file, 'utf8'), file)) { + found.push({ domain, entity, file: rel }); + } } } return found; diff --git a/server/scripts/repository-owners.json b/server/scripts/repository-owners.json index 569ba0077..746517165 100644 --- a/server/scripts/repository-owners.json +++ b/server/scripts/repository-owners.json @@ -62,6 +62,7 @@ "PasswordResetTokens": "auth", "PhotoProviderFields": "addons", "PhotoProviders": "addons", + "Photos": "photos", "PlaceDetailsCache": "maps", "PlaceRatings": "places", "PlaceRegions": "atlas", @@ -89,9 +90,11 @@ "Reservations": "reservations", "RoadtripDayBoundaries": "roadtrip", "RoadtripDayTracks": "roadtrip", + "RoadtripPreferences": "roadtrip", "RoadtripPreferencesEntity": "roadtrip", "RoadtripVias": "roadtrip", "RouteUsageDaily": "route-usage", + "SchedulerLeases": "scheduling", "SchoolHolidayCountries": "school-holidays", "SchoolHolidayPeriods": "school-holidays", "SchoolHolidayRegions": "school-holidays", diff --git a/server/tests/unit/scripts/import-boundaries.test.ts b/server/tests/unit/scripts/import-boundaries.test.ts index 6c0d2890d..c8d0ff628 100644 --- a/server/tests/unit/scripts/import-boundaries.test.ts +++ b/server/tests/unit/scripts/import-boundaries.test.ts @@ -291,6 +291,56 @@ describe('import-boundaries.mjs', () => { expect(res.out).toContain('no owner for injected entity Mystery (nest/a/a.service.ts)'); }); + it('BOUND-015: getRepository calls count like injections, a comment naming one does not', () => { + const owners = JSON.stringify({ Trips: 'trips', Users: 'auth' }); + const dir = serverRoot({ + 'nest/perm/perm.guard.ts': [ + '// em.getRepository(Users) would be foreign too', + 'class G { run(em: any) { return em.getRepository(Trips).findAccessible(1, 2); } }', + 'export { G };', + '', + ].join('\n'), + }); + writeFileSync(path.join(dir, 'scripts/repository-owners.json'), owners); + const res = run(dir); + expect(res.status).toBe(1); + expect(res.out).toContain('FAIL foreignRepositories: perm -> Trips (owned by trips)'); + expect(res.out).not.toContain('Users'); + }); + + it('BOUND-016: src/nest-rpc, src/nest-mcp and src/mcp are domains of their own for cycles and repositories', () => { + const owners = JSON.stringify({ Trips: 'trips' }); + const dir = serverRoot({ + 'nest/addons/addons.service.ts': [ + "import { kit } from '../../nest-rpc/rpc-kit/kit';", + 'export class AddonsService { k = kit; }', + '', + ].join('\n'), + 'nest-rpc/rpc-kit/kit.ts': 'export const kit = 1;\n', + 'nest-rpc/plugin-guards.service.ts': [ + "import { AddonsService } from '../nest/addons/addons.service';", + '@InjectRepository(Trips) class PluginGuardsService { a = AddonsService; }', + 'export { PluginGuardsService };', + '', + ].join('\n'), + 'nest/weather/weather.rpc.ts': [ + "import { PluginGuardsService } from '../../nest-rpc/plugin-guards.service';", + 'export const r = PluginGuardsService;', + '', + ].join('\n'), + }); + writeFileSync(path.join(dir, 'scripts/repository-owners.json'), owners); + const res = run(dir); + expect(res.status).toBe(1); + expect(res.out).toContain('FAIL domainCycles: addons -> nest-rpc'); + expect(res.out).toContain('FAIL domainCycles: nest-rpc -> addons'); + expect(res.out).toContain('FAIL foreignRepositories: nest-rpc -> Trips (owned by trips)'); + // The layers are toolkits: a domain using one reaches no internals, and + // weather, which nothing imports back, is on no cycle. + expect(res.out).not.toContain('FAIL domainInternals'); + expect(res.out).not.toContain('weather -> nest-rpc'); + }); + it('BOUND-010: resolves .js specifiers and index files', () => { const dir = serverRoot({ 'x/a.ts': "import { b } from './b.js';\nimport { c } from './c';\nexport const a = [b, c];\n",