From 04f38f98764346afb0b83ad46271c3761c787d97 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 10 Oct 2026 13:07:28 +0200 Subject: [PATCH] chore(server): eslint keeps fast-xml-parser inside place-import and the WebDAV client --- server/eslint.config.mjs | 69 +++++++++++----- .../restricted-imports-xml.test.ts | 79 +++++++++++++++++++ 2 files changed, 129 insertions(+), 19 deletions(-) create mode 100644 server/tests/unit/eslint-rules/restricted-imports-xml.test.ts diff --git a/server/eslint.config.mjs b/server/eslint.config.mjs index 7c986e4b8..ec3e9fa41 100644 --- a/server/eslint.config.mjs +++ b/server/eslint.config.mjs @@ -140,6 +140,27 @@ const MCP_DEMO_SELECTORS = [ { selector: "CallExpression[callee.name='demoDenied']", message: DEMO_GATE_MESSAGE }, ]; +// XML is read in exactly two places: the GPX and KML codecs of place-import, and +// the WebDAV client (PROPFIND answers). One parser setup per format instead of +// one per caller is what keeps a GPX read in one module the same as in another. +const XML_PARSER_PATH = { + name: 'fast-xml-parser', + message: + 'XML is parsed in src/nest/place-import (gpx.codec.ts, kml.codec.ts) and the WebDAV client only. Read GPX/KML through PlaceImportService instead of configuring another parser.', +}; + +const DRIVER_PATH = { + name: 'better-sqlite3', + message: + 'The SQLite driver is an implementation detail of src/db. Go through the ORM (src/db/entities + src/db/repositories) or UnitOfWork; the handle itself is owned by src/db/database.ts.', +}; + +const SERVICES_WALL = { + group: ['**/services/*', '**/services/**/*'], + message: + 'src/services/ is deleted. New backend code goes to src/nest// (service + controller + module, registered in app.module.ts). See src/nest/README.md.', +}; + export default tseslint.config( gitignore({ strict: false }), { @@ -319,25 +340,31 @@ export default tseslint.config( 'src/db/reseat-booked-nights.ts', ], rules: { - 'no-restricted-imports': [ - 'error', - { - paths: [ - { - name: 'better-sqlite3', - message: - 'The SQLite driver is an implementation detail of src/db. Go through the ORM (src/db/entities + src/db/repositories) or UnitOfWork; the handle itself is owned by src/db/database.ts.', - }, - ], - patterns: [ - { - group: ['**/services/*', '**/services/**/*'], - message: - 'src/services/ is deleted. New backend code goes to src/nest// (service + controller + module, registered in app.module.ts). See src/nest/README.md.', - }, - ], - }, - ], + 'no-restricted-imports': ['error', { paths: [DRIVER_PATH, XML_PARSER_PATH], patterns: [SERVICES_WALL] }], + }, + }, + { + // The files the block above leaves out for the driver still may not parse + // XML: restated with XML_PARSER_PATH and the services wall only. + files: [ + 'src/db/database.ts', + 'src/db/connection.ts', + 'src/db/orm-driver.ts', + 'src/db/durability.ts', + 'src/nest/plugins/host/plugin-data.service.ts', + 'src/db/reseat-booked-nights.ts', + ], + rules: { + 'no-restricted-imports': ['error', { paths: [XML_PARSER_PATH], patterns: [SERVICES_WALL] }], + }, + }, + { + // The two homes of the XML parser (see XML_PARSER_PATH). Restated without + // it, for the same flat-config reason as above: this block's options + // replace the previous block's for the files it matches. + files: ['src/nest/place-import/**/*.ts', 'src/nest/doc-sync/providers/webdav.client.ts'], + rules: { + 'no-restricted-imports': ['error', { paths: [DRIVER_PATH], patterns: [SERVICES_WALL] }], }, }, { @@ -372,6 +399,7 @@ export default tseslint.config( message: 'Repositories are dialect-neutral: import from @mikro-orm/sql / @mikro-orm/core. The driver lives in src/db/orm-driver.ts.', }, + XML_PARSER_PATH, ], patterns: [ { @@ -433,6 +461,9 @@ export default tseslint.config( 'no-restricted-imports': [ 'error', { + // Restated for the flat-config reason above: without them the kit + // would drop the driver and XML walls every other src file holds. + paths: [DRIVER_PATH, XML_PARSER_PATH], patterns: [ { // A regex rather than gitignore-style groups: the two allowed files sit diff --git a/server/tests/unit/eslint-rules/restricted-imports-xml.test.ts b/server/tests/unit/eslint-rules/restricted-imports-xml.test.ts new file mode 100644 index 000000000..1d73faf5c --- /dev/null +++ b/server/tests/unit/eslint-rules/restricted-imports-xml.test.ts @@ -0,0 +1,79 @@ +import tsParser from '@typescript-eslint/parser'; + +import { ESLint, Linter } from 'eslint'; +import path from 'path'; +import { describe, expect, it } from 'vitest'; + +// fast-xml-parser is allowed in place-import's codecs and the WebDAV client +// only, through no-restricted-imports in eslint.config.mjs. That rule's options +// are replaced, not merged, by every flat-config block that restates it, so a +// later block can silently drop the restriction (or the driver and services +// walls beside it). This asks ESLint for the options it really applies to a +// path and runs them over an import of each restricted module. + +const serverRoot = path.resolve(__dirname, '../../..'); +const eslint = new ESLint({ cwd: serverRoot }); + +async function restrictedImportsFor(file: string): Promise { + const config = (await eslint.calculateConfigForFile(path.join(serverRoot, file))) as { + rules?: Record; + }; + return config.rules?.['no-restricted-imports']?.slice(1) ?? []; +} + +async function flags(file: string, source: string): Promise { + const options = await restrictedImportsFor(file); + const linter = new Linter({ configType: 'flat' }); + const result = linter.verify( + source, + [{ files: ['**/*.ts'], languageOptions: { parser: tsParser }, rules: { 'no-restricted-imports': ['error', ...(options as object[])] } }], + 'probe.ts', + ); + return result.some((m) => m.ruleId === 'no-restricted-imports'); +} + +const XML = "import { XMLParser } from 'fast-xml-parser';\n"; +const DRIVER = "import Database from 'better-sqlite3';\n"; +const SERVICES = "import { x } from '../services/x';\n"; + +describe('fast-xml-parser stays in place-import and the WebDAV client', () => { + it.each([ + 'src/nest/places/places.service.ts', + 'src/nest/collections/collections.service.ts', + 'src/nest/tours/tours.service.ts', + 'src/nest/maps/maps.service.ts', + 'src/db/repositories/Places.repository.ts', + // The driver's own homes and the plugin sandbox's database, which the + // driver wall leaves out, and the RPC kit, which restates the rule. + 'src/db/database.ts', + 'src/db/connection.ts', + 'src/db/orm-driver.ts', + 'src/db/durability.ts', + 'src/db/reseat-booked-nights.ts', + 'src/nest/plugins/host/plugin-data.service.ts', + 'src/nest-rpc/rpc-kit/registry.ts', + ])('refuses it in %s', async (file) => { + expect(await flags(file, XML)).toBe(true); + }); + + it.each([ + 'src/nest/place-import/gpx.codec.ts', + 'src/nest/place-import/kml.codec.ts', + 'src/nest/doc-sync/providers/webdav.client.ts', + ])('allows it in %s, and keeps the driver and services walls there', async (file) => { + expect(await flags(file, XML)).toBe(false); + expect(await flags(file, DRIVER)).toBe(true); + expect(await flags(file, SERVICES)).toBe(true); + }); + + it('keeps the services wall in the driver-exempt files and the driver wall in the RPC kit', async () => { + expect(await flags('src/nest/plugins/host/plugin-data.service.ts', SERVICES)).toBe(true); + expect(await flags('src/nest/plugins/host/plugin-data.service.ts', DRIVER)).toBe(false); + expect(await flags('src/nest-rpc/rpc-kit/registry.ts', DRIVER)).toBe(true); + }); + + it('keeps the driver and services walls where the parser is refused', async () => { + expect(await flags('src/nest/places/places.service.ts', DRIVER)).toBe(true); + expect(await flags('src/nest/places/places.service.ts', SERVICES)).toBe(true); + }); +});