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.
This commit is contained in:
Maurice
2026-10-10 19:46:25 +02:00
parent 0f6ae8b928
commit ebd8a4f8c9
5 changed files with 230 additions and 16 deletions
+1 -1
View File
@@ -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/<domain>` 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 (`<domain>.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/<domain>` 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 (`<domain>.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.
+116 -2
View File
@@ -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)"
+60 -13
View File
@@ -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/<domain> folders: domain A
* domainCycles the same, between src/nest/<domain> 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/<A>/ injecting (@InjectRepository) the
* repository of an entity another domain owns. Ownership is
* a class in src/nest/<A>/ (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/<domain>/..., 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 `<anything>.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;
+3
View File
@@ -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",
@@ -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",