From 96a3a5a267d70add01fba4c458d16ce530cfd6f7 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sun, 20 Sep 2026 22:21:12 +0200 Subject: [PATCH] fix(agent): pin the tooling lint's import resolver inside the repo tools/ has no node_modules, so eslint-plugin-import resolved its default resolver by the bare name "node" from the linted file's directory, found no dependency root, and escaped to Bun's global install cache. The copy apps/api/bun.lock pins (eslint-import-resolver-node 0.3.10) was never reached. That stayed harmless until 0.4.0 was published and landed in the global cache, at which point the lookup started finding a module whose bindings are not materialized when eslint-module-utils validates the resolver interface. Every import rule then reports "Resolve error" instead of running: 214 errors on a clean checkout. Because the failure depends on what the ambient cache happens to hold, any unrelated dependency bump flips the CI cache key and flips the lint with it. Point the resolver at the installed file by absolute path so the lockfile decides, matching how this config already reaches for eslint/lib/api.js and .prettierrc.json. Guard it structurally rather than by symptom: assert every declared import/resolver key is an absolute path inside the repo. A test that only checked lint output for "Resolve error" passed against the unfixed config on a machine whose cache happened to resolve, so it gated nothing. --- tools/agent/quality.test.ts | 78 ++++++++++++++++++++++++++++++++++++- tools/eslint.config.mjs | 10 ++++- 2 files changed, 86 insertions(+), 2 deletions(-) diff --git a/tools/agent/quality.test.ts b/tools/agent/quality.test.ts index d29fea87..06de0c58 100644 --- a/tools/agent/quality.test.ts +++ b/tools/agent/quality.test.ts @@ -1,5 +1,6 @@ import { expect, test } from "bun:test"; -import { join } from "node:path"; +import { existsSync } from "node:fs"; +import { isAbsolute, join } from "node:path"; import { fileURLToPath } from "node:url"; import { ESLint } from "../../apps/api/node_modules/eslint"; @@ -37,3 +38,78 @@ test("tooling lint rejects unsafe shortcuts and suppression while accepting type expect(valid?.errorCount).toBe(0); expect(valid?.warningCount).toBe(0); }, 30000); + +/* + * calculateConfigForFile is typed as `any`, so narrow it by hand rather than + * asserting: an unreadable shape yields no resolvers and fails the test. + */ +function declaredResolvers(config: unknown): string[] { + if ( + typeof config !== "object" || + config === null || + !("settings" in config) + ) { + return []; + } + + const { settings } = config; + + if ( + typeof settings !== "object" || + settings === null || + !("import/resolver" in settings) + ) { + return []; + } + + const resolver = settings["import/resolver"]; + + if (typeof resolver !== "object" || resolver === null) { + return []; + } + + return Object.keys(resolver); +} + +test("tooling lint pins its import resolver inside the repo", async () => { + /* + * tools/ has no node_modules. A resolver named only by string ("node") is + * looked up from the linted file's directory, finds no dependency root, and + * escapes to Bun's global install cache, whose version no lockfile here + * pins. Every import rule then reports "Resolve error" instead of running. + * Assert the declared resolver is an absolute path inside the repo, which + * holds regardless of what the ambient cache happens to contain. + */ + const lint = new ESLint({ + cwd: join(ROOT, "tools"), + overrideConfig: { + languageOptions: { + parserOptions: { disallowAutomaticSingleRunInference: true }, + }, + }, + }); + const probePath = join(ROOT, "tools/agent/inventory.ts"); + const config: unknown = await lint.calculateConfigForFile(probePath); + const resolvers = declaredResolvers(config); + + expect(resolvers.length).toBeGreaterThan(0); + + for (const resolver of resolvers) { + expect(isAbsolute(resolver)).toBe(true); + expect(resolver.startsWith(ROOT)).toBe(true); + expect(existsSync(resolver)).toBe(true); + } + + const [result] = await lint.lintText( + 'import { verify } from "./../agent/verification";\n\nexport const probe = verify;\n', + { filePath: probePath } + ); + const messages = result?.messages ?? []; + + expect( + messages.some((message) => message.message.includes("Resolve error")) + ).toBe(false); + expect(messages.map((message) => message.ruleId)).toContain( + "import/no-useless-path-segments" + ); +}, 30000); diff --git a/tools/eslint.config.mjs b/tools/eslint.config.mjs index 0f240383..fe070a1f 100644 --- a/tools/eslint.config.mjs +++ b/tools/eslint.config.mjs @@ -17,7 +17,15 @@ export default [ { plugins: scriptConfig.plugins, linterOptions: { ...scriptConfig.linterOptions, noInlineConfig: true }, - settings: scriptConfig.settings ?? {}, + settings: { + ...(scriptConfig.settings ?? {}), + // tools/ has no node_modules, so eslint-plugin-import would resolve its + // named "node" resolver against Bun's global cache instead of the API's + // pinned copy. Point at the installed file so the lockfile decides. + "import/resolver": { + [`${apiRoot}node_modules/eslint-import-resolver-node/index.js`]: {}, + }, + }, files: ["agent/**/*.ts", "agent-evals/**/*.ts"], languageOptions: { ...scriptConfig.languageOptions,