diff --git a/server/CLAUDE.md b/server/CLAUDE.md index 63952867e..799d0c520 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -32,7 +32,7 @@ npm run lint:query-api # MikroORM QueryBuilder calls in the repositories may 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 five 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`) importing a domain, a domain importing another domain's internals, and `src/db` importing `src/nest`. 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 six 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`, 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. **`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 231bd4cd6..f6b681f13 100644 --- a/server/scripts/import-boundaries-baseline.json +++ b/server/scripts/import-boundaries-baseline.json @@ -96,5 +96,216 @@ "db/migrations/Migration20200101010300_encrypt_any_plaintext_immich_api_key_values.ts -> nest/common/crypto/apiKeyCrypto.ts", "db/orm.ts -> nest/database/request-context.ts", "db/reseat-booked-nights.ts -> nest/accommodations/night-seat.ts" + ], + "foreignRepositories": [ + "accommodations -> BudgetItems (owned by budget)", + "accommodations -> DayAssignments (owned by assignments)", + "accommodations -> Days (owned by days)", + "accommodations -> Places (owned by places)", + "accommodations -> Reservations (owned by reservations)", + "accommodations -> RoadtripVias (owned by roadtrip)", + "addons -> AppSettings (owned by settings)", + "addons -> Users (owned by auth)", + "admin -> Addons (owned by addons)", + "admin -> AppSettings (owned by settings)", + "admin -> AuditLog (owned by audit)", + "admin -> DocumentProviders (owned by doc-sync)", + "admin -> McpTokens (owned by tokens)", + "admin -> OauthTokens (owned by oauth)", + "admin -> PhotoProviderFields (owned by addons)", + "admin -> PhotoProviders (owned by addons)", + "admin -> Places (owned by places)", + "admin -> PushSubscriptions (owned by notifications)", + "admin -> TripFiles (owned by files)", + "admin -> Trips (owned by trips)", + "admin -> Users (owned by auth)", + "airports -> ReservationEndpoints (owned by reservations)", + "airports -> Reservations (owned by reservations)", + "assignments -> Days (owned by days)", + "assignments -> Places (owned by places)", + "assignments -> RoadtripVias (owned by roadtrip)", + "assignments -> Tours (owned by tours)", + "assignments -> TripMembers (owned by trip-membership)", + "atlas -> AppSettings (owned by settings)", + "atlas -> Places (owned by places)", + "atlas -> ReservationEndpoints (owned by reservations)", + "atlas -> Trips (owned by trips)", + "audit -> Users (owned by auth)", + "auth -> AppSettings (owned by settings)", + "auth -> BudgetItems (owned by budget)", + "auth -> BudgetSettlements (owned by budget)", + "auth -> JourneyContributors (owned by journey)", + "auth -> JourneyEntries (owned by journey)", + "auth -> JourneyShareTokens (owned by journey)", + "auth -> Journeys (owned by journey)", + "auth -> McpTokens (owned by tokens)", + "auth -> OauthTokens (owned by oauth)", + "auth -> PluginUserErasureQueue (owned by plugins)", + "auth -> Plugins (owned by plugins)", + "auth -> PushSubscriptions (owned by notifications)", + "auth -> ShareTokens (owned by share)", + "auth -> TripMembers (owned by trip-membership)", + "auth -> Trips (owned by trips)", + "auth-core -> AppSettings (owned by settings)", + "auth-core -> Users (owned by auth)", + "auth-core -> WebauthnCredentials (owned by auth)", + "booking-import -> Days (owned by days)", + "booking-import -> Reservations (owned by reservations)", + "budget -> Places (owned by places)", + "budget -> Reservations (owned by reservations)", + "budget -> TripMembers (owned by trip-membership)", + "budget -> Trips (owned by trips)", + "calendar -> DayNotes (owned by day-notes)", + "calendar -> Days (owned by days)", + "calendar -> Reservations (owned by reservations)", + "calendar -> Trips (owned by trips)", + "collab -> Trips (owned by trips)", + "collections -> Categories (owned by categories)", + "collections -> PlaceRatings (owned by places)", + "collections -> Places (owned by places)", + "collections -> Tags (owned by tags)", + "collections -> TripMembers (owned by trip-membership)", + "collections -> Trips (owned by trips)", + "collections -> Users (owned by auth)", + "day-notes -> Days (owned by days)", + "days -> DayAccommodations (owned by accommodations)", + "days -> DayAssignments (owned by assignments)", + "days -> DayNotes (owned by day-notes)", + "days -> ReservationEndpoints (owned by reservations)", + "days -> Reservations (owned by reservations)", + "days -> RoadtripDayBoundaries (owned by roadtrip)", + "days -> RoadtripVias (owned by roadtrip)", + "days -> Trips (owned by trips)", + "doc-sync -> AppSettings (owned by settings)", + "doc-sync -> FileLinks (owned by files)", + "doc-sync -> TripFiles (owned by files)", + "doc-sync -> Trips (owned by trips)", + "feeds -> Trips (owned by trips)", + "feeds -> Users (owned by auth)", + "files -> AppSettings (owned by settings)", + "files -> BudgetItems (owned by budget)", + "files -> DayAssignments (owned by assignments)", + "files -> Places (owned by places)", + "files -> Reservations (owned by reservations)", + "files -> Users (owned by auth)", + "google-quota -> AppSettings (owned by settings)", + "integrations -> AppSettings (owned by settings)", + "integrations -> BucketList (owned by atlas)", + "integrations -> Days (owned by days)", + "integrations -> Places (owned by places)", + "integrations -> ReservationEndpoints (owned by reservations)", + "integrations -> Reservations (owned by reservations)", + "integrations -> Trips (owned by trips)", + "integrations -> Users (owned by auth)", + "journey -> Places (owned by places)", + "journey -> Trips (owned by trips)", + "journey -> Users (owned by auth)", + "llm-parse -> Addons (owned by addons)", + "maps -> AppSettings (owned by settings)", + "maps -> Places (owned by places)", + "maps -> Users (owned by auth)", + "mcp-shared -> Trips (owned by trips)", + "mcp-shared -> Users (owned by auth)", + "memories -> JourneyContributors (owned by journey)", + "memories -> JourneyPhotos (owned by journey)", + "memories -> Journeys (owned by journey)", + "memories -> PhotoProviders (owned by addons)", + "memories -> TrekPhotos (owned by photos)", + "memories -> TripPhotos (owned by photos)", + "memories -> Trips (owned by trips)", + "memories -> Users (owned by auth)", + "notifications -> AppSettings (owned by settings)", + "notifications -> Settings (owned by settings)", + "notifications -> TodoItems (owned by todo)", + "notifications -> Trips (owned by trips)", + "notifications -> Users (owned by auth)", + "oidc -> AppSettings (owned by settings)", + "oidc -> InviteTokens (owned by auth)", + "oidc -> Users (owned by auth)", + "packing -> TripMembers (owned by trip-membership)", + "packing -> Trips (owned by trips)", + "permissions -> AppSettings (owned by settings)", + "permissions -> Trips (owned by trips)", + "photos -> JourneyPhotos (owned by journey)", + "place-enrichment -> AppSettings (owned by settings)", + "place-enrichment -> PlaceDetailsCache (owned by maps)", + "place-photos -> CollectionPlaces (owned by collections)", + "place-photos -> Places (owned by places)", + "place-shadow -> AppSettings (owned by settings)", + "places -> BudgetItems (owned by budget)", + "places -> Categories (owned by categories)", + "places -> CollectionPlaces (owned by collections)", + "places -> DayAssignments (owned by assignments)", + "places -> Tags (owned by tags)", + "places -> TripMembers (owned by trip-membership)", + "places -> Trips (owned by trips)", + "plugins -> DayAccommodations (owned by accommodations)", + "plugins -> Days (owned by days)", + "plugins -> JourneyEntries (owned by journey)", + "plugins -> NotificationChannelPreferences (owned by notifications)", + "plugins -> Places (owned by places)", + "plugins -> Reservations (owned by reservations)", + "plugins -> Settings (owned by settings)", + "plugins -> Trips (owned by trips)", + "plugins -> Users (owned by auth)", + "public-api -> BucketList (owned by atlas)", + "public-api -> DayNotes (owned by day-notes)", + "public-api -> Days (owned by days)", + "public-api -> Places (owned by places)", + "public-api -> Reservations (owned by reservations)", + "public-api -> Trips (owned by trips)", + "query-helpers -> AssignmentParticipants (owned by assignments)", + "query-helpers -> PlaceRatings (owned by places)", + "query-helpers -> Tags (owned by tags)", + "realtime -> AppSettings (owned by settings)", + "realtime -> Users (owned by auth)", + "reservations -> BudgetItems (owned by budget)", + "reservations -> DayAccommodations (owned by accommodations)", + "reservations -> DayAssignments (owned by assignments)", + "reservations -> Days (owned by days)", + "reservations -> Places (owned by places)", + "reservations -> TripMembers (owned by trip-membership)", + "reservations -> Trips (owned by trips)", + "reservations -> Users (owned by auth)", + "roadtrip -> DayAccommodations (owned by accommodations)", + "roadtrip -> DayAssignments (owned by assignments)", + "roadtrip -> Days (owned by days)", + "roadtrip -> Places (owned by places)", + "roadtrip -> Plugins (owned by plugins)", + "roadtrip -> ReservationDayPositions (owned by reservations)", + "roadtrip -> ReservationEndpoints (owned by reservations)", + "roadtrip -> Reservations (owned by reservations)", + "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)", + "share -> DayAssignments (owned by assignments)", + "share -> DayNotes (owned by day-notes)", + "share -> Days (owned by days)", + "share -> PackingItems (owned by packing)", + "share -> Places (owned by places)", + "share -> Reservations (owned by reservations)", + "share -> Trips (owned by trips)", + "storage -> AppSettings (owned by settings)", + "system-notices -> AppSettings (owned by settings)", + "system-notices -> Trips (owned by trips)", + "system-notices -> Users (owned by auth)", + "todo -> TripMembers (owned by trip-membership)", + "tokens -> Users (owned by auth)", + "tours -> Places (owned by places)", + "transit -> AppSettings (owned by settings)", + "transit -> Users (owned by auth)", + "trip-members -> TripMembers (owned by trip-membership)", + "trip-members -> Trips (owned by trips)", + "trip-members -> Users (owned by auth)", + "trip-membership -> Trips (owned by trips)", + "trip-read-model -> Trips (owned by trips)", + "unsplash -> AppSettings (owned by settings)", + "unsplash -> Users (owned by auth)", + "vacay -> SchoolHolidayRegions (owned by school-holidays)", + "vacay -> Users (owned by auth)" ] } diff --git a/server/scripts/import-boundaries.mjs b/server/scripts/import-boundaries.mjs index cff7c6eb8..a06a87d11 100644 --- a/server/scripts/import-boundaries.mjs +++ b/server/scripts/import-boundaries.mjs @@ -37,6 +37,15 @@ * importing domain, so moving a file inside A changes nothing. * 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 + * 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 + * injection of an entity the domain already reads is not new. + * Asking the owner instead (a service such as + * trip-membership's TripAccessService) is what shrinks it. * * Each rule's baseline lists today's violations. A violation not in it fails * the check, and so does an entry that no longer occurs, until --update drops @@ -56,7 +65,7 @@ import { fileURLToPath } from 'node:url'; const require = createRequire(import.meta.url); const ts = require('typescript'); -export const RULES = ['fileCycles', 'domainCycles', 'sharedImportsDomain', 'domainInternals', 'dbImportsNest']; +export const RULES = ['fileCycles', 'domainCycles', 'sharedImportsDomain', 'domainInternals', 'dbImportsNest', 'foreignRepositories']; /** * Folders under src/nest whose every file is shared infrastructure, open to all domains. @@ -271,6 +280,48 @@ export function isPublic(target) { return PUBLIC_SUFFIXES.some((s) => target.endsWith(s)); } +/** Every @InjectRepository(Entity) under src/nest: { 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 }); + } + } + return found; +} + +/** scripts/repository-owners.json: entity class -> owning domain. Missing is an empty map. */ +export function readOwners(path) { + if (!existsSync(path)) return {}; + const parsed = JSON.parse(readFileSync(path, 'utf8')); + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('scripts/repository-owners.json must map each entity class to its owning domain'); + } + return parsed; +} + +/** foreignRepositories keys, plus the injected entities the owner map does not know. */ +export function analyseRepositories(injections, owners) { + const foreign = new Set(); + const unowned = new Set(); + for (const { domain, entity, file } of injections) { + const owner = owners[entity]; + if (typeof owner !== 'string') { + unowned.add(`${entity} (${file})`); + continue; + } + if (owner !== domain) foreign.add(`${domain} -> ${entity} (owned by ${owner})`); + } + return { foreignRepositories: [...foreign].sort(), unowned: [...unowned].sort() }; +} + /** The violations of every rule, each a sorted list of stable string keys. */ export function analyse(edges) { const runtime = edges.filter((e) => e.runtime); @@ -301,6 +352,7 @@ export function analyse(edges) { sharedImportsDomain: sorted(sharedImportsDomain), domainInternals: sorted(domainInternals), dbImportsNest: sorted(dbImportsNest), + foreignRepositories: [], }; } @@ -347,6 +399,8 @@ const HINTS = { sharedImportsDomain: 'The shared kernel is imported by every domain, so it may not import one. Move the piece into the kernel, or inject it from the domain.', dbImportsNest: 'src/db sits below src/nest. Move what the data layer needs into src/db (or src/utils) instead.', + foreignRepositories: + "This injects the repository of a table another domain owns. Ask the owner's service instead (e.g. TripAccessService for trip visibility), or move the code to the owning domain.", }; function main(argv) { @@ -362,6 +416,16 @@ function main(argv) { return 1; } const found = analyse(edges); + const { foreignRepositories, unowned } = analyseRepositories( + collectRepositoryInjections(serverDir), + readOwners(join(serverDir, 'scripts', 'repository-owners.json')), + ); + if (unowned.length) { + // An entity without an owner is an injection the rule cannot judge. + for (const u of unowned) console.error(`FAIL no owner for injected entity ${u}: add it to scripts/repository-owners.json`); + return 1; + } + found.foreignRepositories = foreignRepositories; let baseline = readBaseline(baselinePath); if (update) { const now = Object.fromEntries(RULES.map((rule) => [rule, new Set(found[rule])])); diff --git a/server/scripts/repository-owners.json b/server/scripts/repository-owners.json new file mode 100644 index 000000000..569ba0077 --- /dev/null +++ b/server/scripts/repository-owners.json @@ -0,0 +1,132 @@ +{ + "Addons": "addons", + "AppSettings": "settings", + "AssignmentParticipants": "assignments", + "AuditLog": "audit", + "BucketList": "atlas", + "BudgetCategoryOrder": "budget", + "BudgetItemMembers": "budget", + "BudgetItemPayers": "budget", + "BudgetItems": "budget", + "BudgetSettlements": "budget", + "Categories": "categories", + "CollabLinks": "collab", + "CollabMessageReactions": "collab", + "CollabMessages": "collab", + "CollabNotes": "collab", + "CollabPollVotes": "collab", + "CollabPolls": "collab", + "CollectionLabels": "collections", + "CollectionMembers": "collections", + "CollectionPlaceRatings": "collections", + "CollectionPlaces": "collections", + "Collections": "collections", + "DawarichConnections": "integrations", + "DawarichVisitSuggestions": "integrations", + "DayAccommodations": "accommodations", + "DayAssignments": "assignments", + "DayNotes": "day-notes", + "Days": "days", + "DocumentConnections": "doc-sync", + "DocumentProviderFields": "doc-sync", + "DocumentProviders": "doc-sync", + "DocumentSyncItems": "doc-sync", + "FileLinks": "files", + "GoogleApiUsage": "google-quota", + "GooglePlacePhotoMeta": "place-photos", + "HiddenCountries": "atlas", + "HiddenRegions": "atlas", + "IdempotencyKeys": "common", + "InviteTokens": "auth", + "JourneyBooks": "journey", + "JourneyContributors": "journey", + "JourneyEntries": "journey", + "JourneyEntryPhotos": "journey", + "JourneyPhotos": "journey", + "JourneyShareTokens": "journey", + "JourneyTrips": "journey", + "Journeys": "journey", + "McpTokens": "tokens", + "NotificationChannelPreferences": "notifications", + "Notifications": "notifications", + "OauthClients": "oauth", + "OauthConsents": "oauth", + "OauthTokens": "oauth", + "PackingBags": "packing", + "PackingCategoryAssignees": "packing", + "PackingItemContributors": "packing", + "PackingItems": "packing", + "PackingTemplateCategories": "packing", + "PackingTemplateItems": "packing", + "PackingTemplates": "packing", + "PasswordResetTokens": "auth", + "PhotoProviderFields": "addons", + "PhotoProviders": "addons", + "PlaceDetailsCache": "maps", + "PlaceRatings": "places", + "PlaceRegions": "atlas", + "PlaceShadowPicks": "place-shadow", + "Places": "places", + "PluginActions": "plugins", + "PluginCapabilityAudit": "plugins", + "PluginEgressHosts": "plugins", + "PluginEntityMetadata": "plugins", + "PluginErrorLog": "plugins", + "PluginMetaMigrations": "plugins", + "PluginOauthState": "plugins", + "PluginOauthTokens": "plugins", + "PluginScheduledTasks": "plugins", + "PluginSettingsFields": "plugins", + "PluginUserConfig": "plugins", + "PluginUserErasureQueue": "plugins", + "Plugins": "plugins", + "PushSubscriptions": "notifications", + "ReservationDayPositions": "reservations", + "ReservationEndpoints": "reservations", + "ReservationEndpointsEntity": "reservations", + "ReservationTravelers": "reservations", + "ReservationTravelersEntity": "reservations", + "Reservations": "reservations", + "RoadtripDayBoundaries": "roadtrip", + "RoadtripDayTracks": "roadtrip", + "RoadtripPreferencesEntity": "roadtrip", + "RoadtripVias": "roadtrip", + "RouteUsageDaily": "route-usage", + "SchoolHolidayCountries": "school-holidays", + "SchoolHolidayPeriods": "school-holidays", + "SchoolHolidayRegions": "school-holidays", + "Settings": "settings", + "ShareTokens": "share", + "Tags": "tags", + "TodoCategoryAssignees": "todo", + "TodoItems": "todo", + "TourTypes": "tours", + "TourWaypoints": "tours", + "Tours": "tours", + "TrekPhotoCacheMeta": "memories", + "TrekPhotos": "photos", + "TripAlbumLinks": "memories", + "TripDocumentLinks": "doc-sync", + "TripFiles": "files", + "TripInviteTokens": "trip-invite", + "TripMembers": "trip-membership", + "TripPhotos": "photos", + "Trips": "trips", + "UserNoticeDismissals": "system-notices", + "UserSessions": "sessions", + "Users": "auth", + "VacayCompanyHolidays": "vacay", + "VacayEntries": "vacay", + "VacayHolidayCalendars": "vacay", + "VacayPlanMembers": "vacay", + "VacayPlans": "vacay", + "VacayShares": "vacay", + "VacayUserColors": "vacay", + "VacayUserSettingsEntity": "vacay", + "VacayUserYears": "vacay", + "VacayYears": "vacay", + "VisitedCountries": "atlas", + "VisitedRegions": "atlas", + "WebauthnChallenges": "auth", + "WebauthnCredentials": "auth" +} diff --git a/server/tests/unit/scripts/import-boundaries.test.ts b/server/tests/unit/scripts/import-boundaries.test.ts index 49e801f8e..56cdc9207 100644 --- a/server/tests/unit/scripts/import-boundaries.test.ts +++ b/server/tests/unit/scripts/import-boundaries.test.ts @@ -16,7 +16,7 @@ import { afterEach, describe, expect, it } from 'vitest'; const SCRIPT = path.join(__dirname, '../../../scripts/import-boundaries.mjs'); -const EMPTY = { fileCycles: [], domainCycles: [], sharedImportsDomain: [], domainInternals: [], dbImportsNest: [] }; +const EMPTY = { fileCycles: [], domainCycles: [], sharedImportsDomain: [], domainInternals: [], dbImportsNest: [], foreignRepositories: [] }; interface ExecError { status: number | null; @@ -226,6 +226,32 @@ describe('import-boundaries.mjs', () => { expect(res.out).toContain('unresolved import x/a.ts -> ./missing'); }); + it('BOUND-012: fails on a repository another domain owns, passes the owner and a baselined entry', () => { + const owners = JSON.stringify({ Trips: 'trips', Todos: 'todo' }); + const files = { + 'nest/trips/trips.service.ts': '@InjectRepository(Trips) class S {}\nexport { S };\n', + 'nest/todo/todo.service.ts': '@InjectRepository(Todos) class T {}\n@InjectRepository(Trips) class U {}\nexport { T, U };\n', + }; + const withOwners = (baseline: unknown) => { + const dir = serverRoot(files, baseline); + writeFileSync(path.join(dir, 'scripts/repository-owners.json'), owners); + return dir; + }; + const bad = run(withOwners(EMPTY)); + expect(bad.status).toBe(1); + expect(bad.out).toContain('FAIL foreignRepositories: todo -> Trips (owned by trips)'); + expect(bad.out).not.toContain('trips -> Trips'); + expect(bad.out).not.toContain('todo -> Todos'); + expect(run(withOwners({ ...EMPTY, foreignRepositories: ['todo -> Trips (owned by trips)'] })).status).toBe(0); + }); + + it('BOUND-013: fails closed on an injected entity without an owner', () => { + const dir = serverRoot({ 'nest/a/a.service.ts': '@InjectRepository(Mystery) class A {}\nexport { A };\n' }); + const res = run(dir); + expect(res.status).toBe(1); + expect(res.out).toContain('no owner for injected entity Mystery (nest/a/a.service.ts)'); + }); + 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",