From d4fbd018189050d24d2e8546a6eaa083bfbf1ff7 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Fri, 22 May 2026 15:48:41 +0300 Subject: [PATCH 01/10] test(integration): skip tier-gated suites when GitLab license unavailable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add tierGate helper that detects the target GitLab tier once in globalSetup (currentLicense GraphQL query) and writes it to a tmp file for synchronous read at test parse time. Provides describeIfTier / itIfTier wrappers that emit describe.skip / it.skip with a clear "[skipped: requires X, detected Y]" suffix when the detected tier is below the required minimum. Apply to the suites that currently hard-fail on Free instances: - requirements.test.ts: VERIFICATION_STATUS widget is Ultimate-only - debug-widget-assignment.test.ts: Epics + colour widget are Premium+ - data-lifecycle.test.ts: GROUP-level Epics + subgroup parent-epic + Epic- filtered list_work_items assertions are Premium+ (other steps still run) These suites now report as pending on Free, not failed — the underlying behaviour is "feature unavailable in this environment", not "code broken". Closes #428 --- tests/integration/data-lifecycle.test.ts | 303 +++++++++--------- .../debug-widget-assignment.test.ts | 4 +- tests/integration/requirements.test.ts | 3 +- tests/setup/globalSetup.js | 28 ++ tests/setup/tierGate.ts | 90 ++++++ 5 files changed, 278 insertions(+), 150 deletions(-) create mode 100644 tests/setup/tierGate.ts diff --git a/tests/integration/data-lifecycle.test.ts b/tests/integration/data-lifecycle.test.ts index 96d628d2c..10e9c4137 100644 --- a/tests/integration/data-lifecycle.test.ts +++ b/tests/integration/data-lifecycle.test.ts @@ -18,6 +18,7 @@ import { GITLAB_TOKEN, GITLAB_API_URL, updateTestData, getTestData } from '../se import { GraphQLClient } from '../../src/graphql/client'; import { ConnectionManager } from '../../src/services/ConnectionManager'; import { getWorkItemTypes } from '../../src/utils/workItemTypes'; +import { itIfTier, describeIfTier } from '../setup/tierGate'; import { gql } from 'graphql-tag'; import { IntegrationTestHelper } from './helpers/registry-helper'; @@ -583,177 +584,183 @@ describe('🔄 Data Lifecycle - Complete Infrastructure Setup', () => { ); }); - it('should create GROUP-level work items (Epics - depends on group)', async () => { - const testData = getTestData(); - expect(testData.group?.id).toBeDefined(); - console.log( - '🔧 Creating GROUP-level work items (Epics) using GraphQL with dynamic type discovery...', - ); - - // Get work item types directly using utility function (not exposed as tool) - console.log('🔍 Getting work item types for group namespace using internal utility...'); - const groupWorkItemTypes = await getWorkItemTypes(testData.group!.path); - console.log( - '📋 Available group work item types:', - groupWorkItemTypes.map((t) => `${t.name}(${t.id})`).join(', '), - ); - - const epicType = groupWorkItemTypes.find((t) => t.name === 'Epic'); - expect(epicType).toBeDefined(); - - const createdGroupWorkItems: any[] = []; - - // 🚨 CRITICAL: Create diverse Epics to test with and without widgets - // Get created labels and milestones for widget testing - const labels = testData.labels || []; - const milestones = testData.milestones || []; - const user = testData.user; + itIfTier( + 'premium', + 'should create GROUP-level work items (Epics - depends on group)', + async () => { + const testData = getTestData(); + expect(testData.group?.id).toBeDefined(); + console.log( + '🔧 Creating GROUP-level work items (Epics) using GraphQL with dynamic type discovery...', + ); - const groupWorkItemsData: Array<{ - title: string; - description: string; - workItemType: string; - assigneeIds?: string[]; - labelIds?: string[]; - milestoneId?: string; - }> = [ - { - title: `Test Epic (No Widgets) ${timestamp}`, - description: 'Test epic with no widgets - GROUP LEVEL ONLY (using handler)', - workItemType: 'EPIC', - // No assignees, labels, or milestones - tests conditional widget inclusion - }, - { - title: `Test Epic (With Widgets) ${timestamp}`, - description: 'Test epic with widgets - GROUP LEVEL ONLY (using handler)', - workItemType: 'EPIC', - assigneeIds: user ? [`gid://gitlab/User/${user.id}`] : undefined, - labelIds: labels.length > 0 ? [labels[0].id.toString()] : undefined, - milestoneId: milestones.length > 0 ? milestones[0].id.toString() : undefined, - }, - ]; + // Get work item types directly using utility function (not exposed as tool) + console.log('🔍 Getting work item types for group namespace using internal utility...'); + const groupWorkItemTypes = await getWorkItemTypes(testData.group!.path); + console.log( + '📋 Available group work item types:', + groupWorkItemTypes.map((t) => `${t.name}(${t.id})`).join(', '), + ); - for (const workItemData of groupWorkItemsData) { - try { - console.log(` 🔧 Creating ${workItemData.workItemType} via create_work_item handler...`); + const epicType = groupWorkItemTypes.find((t) => t.name === 'Epic'); + expect(epicType).toBeDefined(); + + const createdGroupWorkItems: any[] = []; + + // 🚨 CRITICAL: Create diverse Epics to test with and without widgets + // Get created labels and milestones for widget testing + const labels = testData.labels || []; + const milestones = testData.milestones || []; + const user = testData.user; + + const groupWorkItemsData: Array<{ + title: string; + description: string; + workItemType: string; + assigneeIds?: string[]; + labelIds?: string[]; + milestoneId?: string; + }> = [ + { + title: `Test Epic (No Widgets) ${timestamp}`, + description: 'Test epic with no widgets - GROUP LEVEL ONLY (using handler)', + workItemType: 'EPIC', + // No assignees, labels, or milestones - tests conditional widget inclusion + }, + { + title: `Test Epic (With Widgets) ${timestamp}`, + description: 'Test epic with widgets - GROUP LEVEL ONLY (using handler)', + workItemType: 'EPIC', + assigneeIds: user ? [`gid://gitlab/User/${user.id}`] : undefined, + labelIds: labels.length > 0 ? [labels[0].id.toString()] : undefined, + milestoneId: milestones.length > 0 ? milestones[0].id.toString() : undefined, + }, + ]; - // Step 1: Create work item with basic parameters (CREATE doesn't support widgets) - const workItem = (await helper.createWorkItem({ - namespace: testData.group!.path, - title: workItemData.title, - workItemType: workItemData.workItemType, - description: workItemData.description, - })) as any; + for (const workItemData of groupWorkItemsData) { + try { + console.log( + ` 🔧 Creating ${workItemData.workItemType} via create_work_item handler...`, + ); - // Step 2: Add widgets iteratively if data exists for each widget type - console.log( - ` 🔍 Widget check: workItem=${!!workItem}, title="${workItemData.title}", includesWithWidgets=${workItemData.title?.includes('With Widgets')}`, - ); - if (workItem && workItemData.title?.includes('With Widgets')) { - console.log(` 🔧 Adding widgets to ${workItemData.workItemType}...`); + // Step 1: Create work item with basic parameters (CREATE doesn't support widgets) + const workItem = (await helper.createWorkItem({ + namespace: testData.group!.path, + title: workItemData.title, + workItemType: workItemData.workItemType, + description: workItemData.description, + })) as any; - // Try to add assignees widget if assignee data exists - if (workItemData.assigneeIds && workItemData.assigneeIds.length > 0) { - try { - console.log(` 🔧 Adding assignees widget...`); - const assigneeUpdate = (await helper.updateWorkItem({ - id: workItem.id, - assigneeIds: workItemData.assigneeIds, - })) as any; - if (assigneeUpdate) { - console.log(` ✅ Added assignees: ${workItemData.assigneeIds.length}`); + // Step 2: Add widgets iteratively if data exists for each widget type + console.log( + ` 🔍 Widget check: workItem=${!!workItem}, title="${workItemData.title}", includesWithWidgets=${workItemData.title?.includes('With Widgets')}`, + ); + if (workItem && workItemData.title?.includes('With Widgets')) { + console.log(` 🔧 Adding widgets to ${workItemData.workItemType}...`); + + // Try to add assignees widget if assignee data exists + if (workItemData.assigneeIds && workItemData.assigneeIds.length > 0) { + try { + console.log(` 🔧 Adding assignees widget...`); + const assigneeUpdate = (await helper.updateWorkItem({ + id: workItem.id, + assigneeIds: workItemData.assigneeIds, + })) as any; + if (assigneeUpdate) { + console.log(` ✅ Added assignees: ${workItemData.assigneeIds.length}`); + console.log( + ` 🔍 Assignee update response widgets:`, + assigneeUpdate.widgets?.find((w: any) => w.type === 'ASSIGNEES')?.assignees + ?.nodes?.length || 0, + ); + Object.assign(workItem, assigneeUpdate); + } + } catch (assigneeError) { console.log( - ` 🔍 Assignee update response widgets:`, - assigneeUpdate.widgets?.find((w: any) => w.type === 'ASSIGNEES')?.assignees - ?.nodes?.length || 0, + ` ⚠️ Could not add assignees to ${workItemData.workItemType}:`, + assigneeError, ); - Object.assign(workItem, assigneeUpdate); } - } catch (assigneeError) { - console.log( - ` ⚠️ Could not add assignees to ${workItemData.workItemType}:`, - assigneeError, - ); } - } - // Try to add labels widget if label data exists - if (workItemData.labelIds && workItemData.labelIds.length > 0) { - try { - console.log(` 🔧 Adding labels widget...`); - const labelUpdate = (await helper.updateWorkItem({ - id: workItem.id, - labelIds: workItemData.labelIds, - })) as any; - if (labelUpdate) { - console.log(` ✅ Added labels: ${workItemData.labelIds.length}`); + // Try to add labels widget if label data exists + if (workItemData.labelIds && workItemData.labelIds.length > 0) { + try { + console.log(` 🔧 Adding labels widget...`); + const labelUpdate = (await helper.updateWorkItem({ + id: workItem.id, + labelIds: workItemData.labelIds, + })) as any; + if (labelUpdate) { + console.log(` ✅ Added labels: ${workItemData.labelIds.length}`); + console.log( + ` 🔍 Label update response widgets:`, + labelUpdate.widgets?.find((w: any) => w.type === 'LABELS')?.labels?.nodes + ?.length || 0, + ); + Object.assign(workItem, labelUpdate); + } + } catch (labelError) { console.log( - ` 🔍 Label update response widgets:`, - labelUpdate.widgets?.find((w: any) => w.type === 'LABELS')?.labels?.nodes - ?.length || 0, + ` ⚠️ Could not add labels to ${workItemData.workItemType}:`, + labelError, ); - Object.assign(workItem, labelUpdate); } - } catch (labelError) { - console.log( - ` ⚠️ Could not add labels to ${workItemData.workItemType}:`, - labelError, - ); } - } - // Try to add milestone widget if milestone data exists - if (workItemData.milestoneId) { - try { - console.log(` 🔧 Adding milestone widget...`); - const milestoneUpdate = (await helper.updateWorkItem({ - id: workItem.id, - milestoneId: workItemData.milestoneId, - })) as any; - if (milestoneUpdate) { - console.log(` ✅ Added milestone: 1`); - Object.assign(workItem, milestoneUpdate); + // Try to add milestone widget if milestone data exists + if (workItemData.milestoneId) { + try { + console.log(` 🔧 Adding milestone widget...`); + const milestoneUpdate = (await helper.updateWorkItem({ + id: workItem.id, + milestoneId: workItemData.milestoneId, + })) as any; + if (milestoneUpdate) { + console.log(` ✅ Added milestone: 1`); + Object.assign(workItem, milestoneUpdate); + } + } catch (milestoneError) { + console.log( + ` ⚠️ Could not add milestone to ${workItemData.workItemType}:`, + milestoneError, + ); } - } catch (milestoneError) { - console.log( - ` ⚠️ Could not add milestone to ${workItemData.workItemType}:`, - milestoneError, - ); } } - } - console.log( - ` 🔍 Widget testing - ${workItemData.workItemType}:`, - workItemData.assigneeIds - ? `assignees: ${workItemData.assigneeIds.length}` - : 'no assignees', - workItemData.labelIds ? `labels: ${workItemData.labelIds.length}` : 'no labels', - workItemData.milestoneId ? 'milestone: 1' : 'no milestone', - ); + console.log( + ` 🔍 Widget testing - ${workItemData.workItemType}:`, + workItemData.assigneeIds + ? `assignees: ${workItemData.assigneeIds.length}` + : 'no assignees', + workItemData.labelIds ? `labels: ${workItemData.labelIds.length}` : 'no labels', + workItemData.milestoneId ? 'milestone: 1' : 'no milestone', + ); - if (workItem) { - createdGroupWorkItems.push(workItem); + if (workItem) { + createdGroupWorkItems.push(workItem); + console.log( + ` ✅ Created GROUP-level ${workItemData.workItemType}: ${workItem.iid} (Type: ${workItem.workItemType.name})`, + ); + } else { + console.log(` ⚠️ Handler returned null for ${workItemData.workItemType}`); + } + } catch (error) { console.log( - ` ✅ Created GROUP-level ${workItemData.workItemType}: ${workItem.iid} (Type: ${workItem.workItemType.name})`, + ` ⚠️ Could not create GROUP work item via handler: ${workItemData.title}`, + error, ); - } else { - console.log(` ⚠️ Handler returned null for ${workItemData.workItemType}`); } - } catch (error) { - console.log( - ` ⚠️ Could not create GROUP work item via handler: ${workItemData.title}`, - error, - ); } - } - // Store group work items separately - updateTestData({ groupWorkItems: createdGroupWorkItems }); + // Store group work items separately + updateTestData({ groupWorkItems: createdGroupWorkItems }); - expect(createdGroupWorkItems.length).toBeGreaterThan(0); - console.log(`✅ Created ${createdGroupWorkItems.length} GROUP-level work items (Epics)`); - }); + expect(createdGroupWorkItems.length).toBeGreaterThan(0); + console.log(`✅ Created ${createdGroupWorkItems.length} GROUP-level work items (Epics)`); + }, + ); it('should verify work items actually have widgets (assignees, labels, milestones)', async () => { const testData = getTestData(); @@ -879,7 +886,7 @@ describe('🔄 Data Lifecycle - Complete Infrastructure Setup', () => { }, 30000); }); - describe('🏗️ Step 5.5: Subgroup and Parent Epic Infrastructure', () => { + describeIfTier('premium', '🏗️ Step 5.5: Subgroup and Parent Epic Infrastructure', () => { it('should create subgroup (depends on main group)', async () => { const testData = getTestData(); expect(testData.group?.id).toBeDefined(); @@ -1668,7 +1675,7 @@ describe('🔄 Data Lifecycle - Complete Infrastructure Setup', () => { }); describe('🔍 Step 7: Work Items Tools Validation', () => { - it('should test list_work_items with group namespace (Epics)', async () => { + itIfTier('premium', 'should test list_work_items with group namespace (Epics)', async () => { const testData = getTestData(); expect(testData.group?.id).toBeDefined(); expect(testData.groupWorkItems?.length).toBeGreaterThan(0); @@ -1730,7 +1737,7 @@ describe('🔄 Data Lifecycle - Complete Infrastructure Setup', () => { ); }); - it('should test list_work_items with type filtering', async () => { + itIfTier('premium', 'should test list_work_items with type filtering', async () => { const testData = getTestData(); console.log('🔍 Testing list_work_items with type filtering...'); diff --git a/tests/integration/debug-widget-assignment.test.ts b/tests/integration/debug-widget-assignment.test.ts index 170c7e54b..1e10a4cda 100644 --- a/tests/integration/debug-widget-assignment.test.ts +++ b/tests/integration/debug-widget-assignment.test.ts @@ -4,8 +4,10 @@ */ import { IntegrationTestHelper } from './helpers/registry-helper'; +import { describeIfTier } from '../setup/tierGate'; -describe('Debug Widget Assignment', () => { +// Epics + colour widget are Premium/Ultimate features — skip on Free instances. +describeIfTier('premium', 'Debug Widget Assignment', () => { let helper: IntegrationTestHelper; beforeAll(async () => { diff --git a/tests/integration/requirements.test.ts b/tests/integration/requirements.test.ts index 0e5437bed..3be261a5b 100644 --- a/tests/integration/requirements.test.ts +++ b/tests/integration/requirements.test.ts @@ -12,8 +12,9 @@ import { IntegrationTestHelper } from './helpers/registry-helper'; import { requireTestData } from '../setup/testConfig'; +import { describeIfTier } from '../setup/tierGate'; -describe('Requirements Verification - Integration Tests', () => { +describeIfTier('ultimate', 'Requirements Verification - Integration Tests', () => { let helper: IntegrationTestHelper; let createdRequirementId: string | undefined; let testProjectPath: string; diff --git a/tests/setup/globalSetup.js b/tests/setup/globalSetup.js index 4a95e1839..253b0a3e6 100644 --- a/tests/setup/globalSetup.js +++ b/tests/setup/globalSetup.js @@ -40,5 +40,33 @@ module.exports = async () => { throw new Error(`Missing required environment variables: ${missing.join(', ')}`); } + // Detect GitLab tier once. Premium/Ultimate-only test suites use this to + // skip rather than fail on Free instances. Result lives in a tmp file so + // each Jest worker can read it synchronously at setup load time, before + // describeIfTier blocks parse. + const tierFile = path.join(os.tmpdir(), 'gitlab-mcp-detected-tier.json'); + if (fs.existsSync(tierFile)) fs.unlinkSync(tierFile); + + try { + const res = await fetch(`${process.env.GITLAB_API_URL}/api/graphql`, { + method: 'POST', + headers: { + 'PRIVATE-TOKEN': process.env.GITLAB_TOKEN, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ query: '{ currentLicense { plan } }' }), + }); + const data = res.ok ? await res.json() : null; + const plan = (data?.data?.currentLicense?.plan ?? '').toLowerCase(); + let tier = 'free'; + if (plan.includes('ultimate') || plan.includes('gold')) tier = 'ultimate'; + else if (plan.includes('premium') || plan.includes('silver')) tier = 'premium'; + fs.writeFileSync(tierFile, JSON.stringify({ tier, plan })); + console.log(`🎫 Detected GitLab tier: ${tier}${plan ? ` (plan: ${plan})` : ''}`); + } catch (err) { + fs.writeFileSync(tierFile, JSON.stringify({ tier: 'free', plan: '' })); + console.warn(`⚠️ Tier detection failed (${err.message}) — defaulting to free`); + } + console.log('✅ Environment validated - starting test data lifecycle chain'); }; diff --git a/tests/setup/tierGate.ts b/tests/setup/tierGate.ts new file mode 100644 index 000000000..16d4fccc9 --- /dev/null +++ b/tests/setup/tierGate.ts @@ -0,0 +1,90 @@ +/** + * Tier-gating helper for integration tests. + * + * Some integration tests exercise features that require a paid GitLab tier + * (Premium/Ultimate). When the target instance is GitLab Free (or EE binary + * without a license), those tests must be SKIPPED rather than failed — the + * underlying behavior is "feature unavailable", not "code broken". + * + * Tier is detected once in globalSetup.js (one HTTP call against currentLicense) + * and written to a tmp file. This module reads it synchronously at module load + * so describeIfTier can be used at module top-level inside test files. + * + * Usage: + * + * import { describeIfTier, itIfTier } from '../setup/tierGate'; + * + * describeIfTier('ultimate', 'Requirements verification', () => { + * it('verifies a requirement', async () => { ... }); + * }); + * + * describe('mixed-tier suite', () => { + * it('runs on all tiers', () => { ... }); + * itIfTier('premium', 'runs on premium and ultimate', () => { ... }); + * }); + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +export type GitLabTier = 'free' | 'premium' | 'ultimate'; + +const TIER_RANK: Record = { free: 0, premium: 1, ultimate: 2 }; + +const TIER_FILE = path.join(os.tmpdir(), 'gitlab-mcp-detected-tier.json'); + +function readDetectedTier(): GitLabTier { + try { + if (!fs.existsSync(TIER_FILE)) return 'free'; + const raw = JSON.parse(fs.readFileSync(TIER_FILE, 'utf8')) as { tier?: string }; + const t = raw.tier; + if (t === 'ultimate' || t === 'premium' || t === 'free') return t; + return 'free'; + } catch { + return 'free'; + } +} + +const DETECTED_TIER: GitLabTier = readDetectedTier(); + +/** Return the tier detected during globalSetup, or 'free' as fallback. */ +export function getDetectedTier(): GitLabTier { + return DETECTED_TIER; +} + +/** True if the detected tier satisfies (>=) the required tier. */ +export function tierSatisfies(required: GitLabTier): boolean { + return TIER_RANK[DETECTED_TIER] >= TIER_RANK[required]; +} + +/** + * Describe block that runs only when the detected GitLab tier meets the + * required minimum. Otherwise emits describe.skip so the suite reports as + * pending instead of failing. + */ +export function describeIfTier(required: GitLabTier, name: string, fn: () => void): void { + if (tierSatisfies(required)) { + describe(name, fn); + } else { + describe.skip(`${name} [skipped: requires ${required}, detected ${DETECTED_TIER}]`, fn); + } +} + +/** + * It block that runs only when the detected GitLab tier meets the required + * minimum. Useful for a single tier-gated assertion inside an otherwise + * tier-agnostic describe block. + */ +export function itIfTier( + required: GitLabTier, + name: string, + fn: jest.ProvidesCallback, + timeout?: number, +): void { + if (tierSatisfies(required)) { + it(name, fn, timeout); + } else { + it.skip(`${name} [skipped: requires ${required}, detected ${DETECTED_TIER}]`, fn, timeout); + } +} From 79882ce6fb137d53d3ed5234ffdda92763393f9e Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Fri, 22 May 2026 16:41:49 +0300 Subject: [PATCH 02/10] fix(tier-gate): namespace tier cache by checkout hash and guard non-Error catch - Tier cache filename now includes a sha1(repo_root) prefix so concurrent Jest runs across worktrees (or NFS-shared tmpdirs) cannot clobber each other's detection result. - Tier detection catch formats the error defensively (`err instanceof Error ? err.message : String(err)`) so a non-Error throw cannot itself crash global setup. - tierGate.ts mirrors the same namespacing so workers read the right file. --- tests/setup/globalSetup.js | 15 +++++++++++++-- tests/setup/tierGate.ts | 11 ++++++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/tests/setup/globalSetup.js b/tests/setup/globalSetup.js index 253b0a3e6..68c0682f0 100644 --- a/tests/setup/globalSetup.js +++ b/tests/setup/globalSetup.js @@ -8,8 +8,18 @@ const path = require('path'); const fs = require('fs'); const os = require('os'); +const crypto = require('crypto'); const { config } = require('dotenv'); +// Namespace tmp artefacts by checkout root so concurrent runs across multiple +// worktrees (or different machines sharing a tmpdir over NFS) cannot clobber +// each other's tier-detection cache. +const REPO_HASH = crypto + .createHash('sha1') + .update(path.resolve(__dirname, '../..')) + .digest('hex') + .slice(0, 12); + module.exports = async () => { // Load .env.test file first (same as setupTests.ts) const envTestPath = path.resolve(__dirname, '../../.env.test'); @@ -44,7 +54,7 @@ module.exports = async () => { // skip rather than fail on Free instances. Result lives in a tmp file so // each Jest worker can read it synchronously at setup load time, before // describeIfTier blocks parse. - const tierFile = path.join(os.tmpdir(), 'gitlab-mcp-detected-tier.json'); + const tierFile = path.join(os.tmpdir(), `gitlab-mcp-detected-tier-${REPO_HASH}.json`); if (fs.existsSync(tierFile)) fs.unlinkSync(tierFile); try { @@ -65,7 +75,8 @@ module.exports = async () => { console.log(`🎫 Detected GitLab tier: ${tier}${plan ? ` (plan: ${plan})` : ''}`); } catch (err) { fs.writeFileSync(tierFile, JSON.stringify({ tier: 'free', plan: '' })); - console.warn(`⚠️ Tier detection failed (${err.message}) — defaulting to free`); + const reason = err instanceof Error ? err.message : String(err); + console.warn(`⚠️ Tier detection failed (${reason}) — defaulting to free`); } console.log('✅ Environment validated - starting test data lifecycle chain'); diff --git a/tests/setup/tierGate.ts b/tests/setup/tierGate.ts index 16d4fccc9..1b8ec834a 100644 --- a/tests/setup/tierGate.ts +++ b/tests/setup/tierGate.ts @@ -24,6 +24,7 @@ * }); */ +import * as crypto from 'crypto'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -32,7 +33,15 @@ export type GitLabTier = 'free' | 'premium' | 'ultimate'; const TIER_RANK: Record = { free: 0, premium: 1, ultimate: 2 }; -const TIER_FILE = path.join(os.tmpdir(), 'gitlab-mcp-detected-tier.json'); +// Mirror globalSetup.js: namespace by checkout root hash so concurrent runs +// across worktrees (or NFS-shared tmpdirs) don't collide on the same cache file. +const REPO_HASH = crypto + .createHash('sha1') + .update(path.resolve(__dirname, '../..')) + .digest('hex') + .slice(0, 12); + +const TIER_FILE = path.join(os.tmpdir(), `gitlab-mcp-detected-tier-${REPO_HASH}.json`); function readDetectedTier(): GitLabTier { try { From 795218ed10fd5970880de0a546b065eeadbd86b5 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Fri, 22 May 2026 17:19:46 +0300 Subject: [PATCH 03/10] fix(tier-gate): use sha256 for cache-key digest SonarCloud flags crypto.createHash('sha1') as a weak-hash security hotspot regardless of context. The hash here is a cache-key digest of the checkout path (used to namespace the tier-detection tmp file across worktrees), not a security primitive. Switch to sha256 to silence the false positive while keeping the namespacing semantics identical. --- tests/setup/globalSetup.js | 5 +++-- tests/setup/tierGate.ts | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/setup/globalSetup.js b/tests/setup/globalSetup.js index 68c0682f0..e1920a825 100644 --- a/tests/setup/globalSetup.js +++ b/tests/setup/globalSetup.js @@ -13,9 +13,10 @@ const { config } = require('dotenv'); // Namespace tmp artefacts by checkout root so concurrent runs across multiple // worktrees (or different machines sharing a tmpdir over NFS) cannot clobber -// each other's tier-detection cache. +// each other's tier-detection cache. sha256 here is a cache-key digest, not a +// security primitive — it just needs to be collision-resistant across paths. const REPO_HASH = crypto - .createHash('sha1') + .createHash('sha256') .update(path.resolve(__dirname, '../..')) .digest('hex') .slice(0, 12); diff --git a/tests/setup/tierGate.ts b/tests/setup/tierGate.ts index 1b8ec834a..2227cd3bc 100644 --- a/tests/setup/tierGate.ts +++ b/tests/setup/tierGate.ts @@ -35,8 +35,9 @@ const TIER_RANK: Record = { free: 0, premium: 1, ultimate: 2 // Mirror globalSetup.js: namespace by checkout root hash so concurrent runs // across worktrees (or NFS-shared tmpdirs) don't collide on the same cache file. +// sha256 here is a cache-key digest, not a security primitive. const REPO_HASH = crypto - .createHash('sha1') + .createHash('sha256') .update(path.resolve(__dirname, '../..')) .digest('hex') .slice(0, 12); From 5d60eecaff5269a8c5817eb5d6ac8106e0016bae Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Fri, 22 May 2026 17:32:10 +0300 Subject: [PATCH 04/10] fix(tier-gate): use Bearer auth and abort timeout for tier detection - Bearer header works for both PAT and OAuth tokens; PRIVATE-TOKEN would 401 on OAuth tokens and silently default to 'free', incorrectly skipping Premium/Ultimate suites when integration tests run in OAuth mode. - AbortController with 10s timeout prevents a hung TCP connection in globalSetup from blocking the entire suite startup indefinitely (Jest's per-test timeout doesn't apply at the globalSetup stage). --- tests/setup/globalSetup.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/setup/globalSetup.js b/tests/setup/globalSetup.js index e1920a825..995779d39 100644 --- a/tests/setup/globalSetup.js +++ b/tests/setup/globalSetup.js @@ -58,14 +58,22 @@ module.exports = async () => { const tierFile = path.join(os.tmpdir(), `gitlab-mcp-detected-tier-${REPO_HASH}.json`); if (fs.existsSync(tierFile)) fs.unlinkSync(tierFile); + // Bearer works for BOTH personal access tokens and OAuth tokens against + // GitLab; PRIVATE-TOKEN would 401 on OAuth tokens and silently default to + // 'free', incorrectly skipping Premium/Ultimate suites. + // AbortController guards against a hung connection blocking suite startup + // (Jest's per-test timeout doesn't apply in globalSetup). + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); try { const res = await fetch(`${process.env.GITLAB_API_URL}/api/graphql`, { method: 'POST', headers: { - 'PRIVATE-TOKEN': process.env.GITLAB_TOKEN, + Authorization: `Bearer ${process.env.GITLAB_TOKEN}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ query: '{ currentLicense { plan } }' }), + signal: controller.signal, }); const data = res.ok ? await res.json() : null; const plan = (data?.data?.currentLicense?.plan ?? '').toLowerCase(); @@ -78,6 +86,8 @@ module.exports = async () => { fs.writeFileSync(tierFile, JSON.stringify({ tier: 'free', plan: '' })); const reason = err instanceof Error ? err.message : String(err); console.warn(`⚠️ Tier detection failed (${reason}) — defaulting to free`); + } finally { + clearTimeout(timeoutId); } console.log('✅ Environment validated - starting test data lifecycle chain'); From 8ba764f01ce9f34f8d76dbc3231aa61f960a665e Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Fri, 22 May 2026 17:46:01 +0300 Subject: [PATCH 05/10] fix(tier-gate): treat HTTP/GraphQL errors as detection failure and harden cache IO - !res.ok and data.errors now throw into the catch path so auth/network breakage logs a warning instead of silently defaulting to 'free' and skipping every Premium/Ultimate suite - fs.rmSync(..., { force: true }) replaces unlinkSync to survive Windows file locks / EPERM without crashing the integration run - tierGate.ts validates the cross-process cache JSON with a Zod schema instead of ad-hoc parse + manual checks --- tests/setup/globalSetup.js | 15 +++++++++++++-- tests/setup/tierGate.ts | 15 +++++++++++---- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/tests/setup/globalSetup.js b/tests/setup/globalSetup.js index 995779d39..f4e0439c7 100644 --- a/tests/setup/globalSetup.js +++ b/tests/setup/globalSetup.js @@ -56,7 +56,9 @@ module.exports = async () => { // each Jest worker can read it synchronously at setup load time, before // describeIfTier blocks parse. const tierFile = path.join(os.tmpdir(), `gitlab-mcp-detected-tier-${REPO_HASH}.json`); - if (fs.existsSync(tierFile)) fs.unlinkSync(tierFile); + // force:true avoids ENOENT and keeps startup resilient to Windows file locks + // / EPERM that could otherwise crash the whole integration run. + fs.rmSync(tierFile, { force: true }); // Bearer works for BOTH personal access tokens and OAuth tokens against // GitLab; PRIVATE-TOKEN would 401 on OAuth tokens and silently default to @@ -75,7 +77,16 @@ module.exports = async () => { body: JSON.stringify({ query: '{ currentLicense { plan } }' }), signal: controller.signal, }); - const data = res.ok ? await res.json() : null; + // Treat non-2xx and GraphQL errors as DETECTION FAILURE (caught + warned) + // rather than as a Free-tier response — otherwise auth/network breakage + // would silently skip every Premium/Ultimate suite. + if (!res.ok) { + throw new Error(`Tier detection HTTP ${res.status}: ${res.statusText}`); + } + const data = await res.json(); + if (Array.isArray(data?.errors) && data.errors.length > 0) { + throw new Error(`Tier detection GraphQL error: ${data.errors[0]?.message ?? 'unknown'}`); + } const plan = (data?.data?.currentLicense?.plan ?? '').toLowerCase(); let tier = 'free'; if (plan.includes('ultimate') || plan.includes('gold')) tier = 'ultimate'; diff --git a/tests/setup/tierGate.ts b/tests/setup/tierGate.ts index 2227cd3bc..9d58e4b1e 100644 --- a/tests/setup/tierGate.ts +++ b/tests/setup/tierGate.ts @@ -28,11 +28,19 @@ import * as crypto from 'crypto'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import { z } from 'zod'; export type GitLabTier = 'free' | 'premium' | 'ultimate'; const TIER_RANK: Record = { free: 0, premium: 1, ultimate: 2 }; +// Cache file is written by globalSetup.js across a process boundary, so treat +// it as untrusted input and validate with Zod (project convention for any +// external/runtime-loaded payload). +const TierCacheSchema = z.object({ + tier: z.enum(['free', 'premium', 'ultimate']).optional(), +}); + // Mirror globalSetup.js: namespace by checkout root hash so concurrent runs // across worktrees (or NFS-shared tmpdirs) don't collide on the same cache file. // sha256 here is a cache-key digest, not a security primitive. @@ -47,10 +55,9 @@ const TIER_FILE = path.join(os.tmpdir(), `gitlab-mcp-detected-tier-${REPO_HASH}. function readDetectedTier(): GitLabTier { try { if (!fs.existsSync(TIER_FILE)) return 'free'; - const raw = JSON.parse(fs.readFileSync(TIER_FILE, 'utf8')) as { tier?: string }; - const t = raw.tier; - if (t === 'ultimate' || t === 'premium' || t === 'free') return t; - return 'free'; + const parsed = TierCacheSchema.safeParse(JSON.parse(fs.readFileSync(TIER_FILE, 'utf8'))); + if (!parsed.success) return 'free'; + return parsed.data.tier ?? 'free'; } catch { return 'free'; } From 3fe58bbcbb0c75826854b122424b7f9808e35a80 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Fri, 22 May 2026 18:29:15 +0300 Subject: [PATCH 06/10] fix(tier-gate): guard stale-cache cleanup against EPERM/EACCES MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fs.rmSync({ force: true }) only suppresses ENOENT — Windows file locks or permission errors would still throw and crash the entire integration run before any tests start. The subsequent writeFileSync overwrites the file anyway, so a failed unlink is not load-bearing; wrap in try/catch with a brief warning and proceed. Adds maxRetries=2 for transient locks. --- tests/setup/globalSetup.js | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/setup/globalSetup.js b/tests/setup/globalSetup.js index f4e0439c7..eea9bf073 100644 --- a/tests/setup/globalSetup.js +++ b/tests/setup/globalSetup.js @@ -56,9 +56,16 @@ module.exports = async () => { // each Jest worker can read it synchronously at setup load time, before // describeIfTier blocks parse. const tierFile = path.join(os.tmpdir(), `gitlab-mcp-detected-tier-${REPO_HASH}.json`); - // force:true avoids ENOENT and keeps startup resilient to Windows file locks - // / EPERM that could otherwise crash the whole integration run. - fs.rmSync(tierFile, { force: true }); + // Best-effort cleanup: force:true suppresses ENOENT but EPERM/EACCES (Windows + // file locks, perms) would still throw and crash the integration run before + // any tests start. writeFileSync below overwrites anyway, so a failed unlink + // is not load-bearing — just log and proceed. + try { + fs.rmSync(tierFile, { force: true, maxRetries: 2, retryDelay: 50 }); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + console.warn(`⚠️ Could not remove stale tier cache (${reason}) — will overwrite`); + } // Bearer works for BOTH personal access tokens and OAuth tokens against // GitLab; PRIVATE-TOKEN would 401 on OAuth tokens and silently default to From b6ff1cee91d6d180cd37339ae469d3ebb2713be6 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Fri, 22 May 2026 20:06:10 +0300 Subject: [PATCH 07/10] fix(tier-gate): use 'unknown' sentinel on detection failure instead of silent 'free' fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defaulting to 'free' when tier detection fails (network/auth/GraphQL error) made every Premium/Ultimate suite skip silently, producing a misleading green run with many pending tests. The intent of the catch path is to report failure, not to claim a Free instance. - globalSetup.js writes { tier: 'unknown', detectionFailed: true, reason } on any error in the detection flow - TierCacheSchema extended to accept 'unknown' + new optional fields - tierGate's internal type is DetectedTier = GitLabTier | 'unknown' (public GitLabTier unchanged — required minimum is still free|premium|ultimate) - tierSatisfies('unknown') returns true so describeIfTier / itIfTier do NOT skip; the suite runs and fails loudly with the real underlying error if the gated feature is genuinely unavailable - readDetectedTier also returns 'unknown' on missing cache file (was 'free') for the same reason: missing detection != confirmed Free instance --- tests/setup/globalSetup.js | 14 +++++++++++-- tests/setup/tierGate.ts | 41 ++++++++++++++++++++++++++++---------- 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/tests/setup/globalSetup.js b/tests/setup/globalSetup.js index eea9bf073..3c59e9e63 100644 --- a/tests/setup/globalSetup.js +++ b/tests/setup/globalSetup.js @@ -101,9 +101,19 @@ module.exports = async () => { fs.writeFileSync(tierFile, JSON.stringify({ tier, plan })); console.log(`🎫 Detected GitLab tier: ${tier}${plan ? ` (plan: ${plan})` : ''}`); } catch (err) { - fs.writeFileSync(tierFile, JSON.stringify({ tier: 'free', plan: '' })); + // Use 'unknown' sentinel on detection failure — NOT 'free'. Silently + // defaulting to 'free' would skip every Premium/Ultimate suite and produce + // a green run that hides real regressions. tierGate.ts treats 'unknown' + // as "do not gate" so the suites still run; if the underlying feature is + // genuinely unavailable, the test fails loudly with the real API error. const reason = err instanceof Error ? err.message : String(err); - console.warn(`⚠️ Tier detection failed (${reason}) — defaulting to free`); + fs.writeFileSync( + tierFile, + JSON.stringify({ tier: 'unknown', plan: '', detectionFailed: true, reason }), + ); + console.warn( + `⚠️ Tier detection failed (${reason}) — marking tier as 'unknown' (suites will run, not skip)`, + ); } finally { clearTimeout(timeoutId); } diff --git a/tests/setup/tierGate.ts b/tests/setup/tierGate.ts index 9d58e4b1e..be98845f1 100644 --- a/tests/setup/tierGate.ts +++ b/tests/setup/tierGate.ts @@ -32,13 +32,24 @@ import { z } from 'zod'; export type GitLabTier = 'free' | 'premium' | 'ultimate'; +/** + * Internal detection result. 'unknown' is a sentinel meaning globalSetup + * could not determine the tier (network failure / 4xx / GraphQL error). + * We never gate on 'unknown' — it bypasses skip logic so the suite runs + * and fails loudly with the real underlying error instead of producing a + * misleading green run with silent skips. + */ +type DetectedTier = GitLabTier | 'unknown'; + const TIER_RANK: Record = { free: 0, premium: 1, ultimate: 2 }; // Cache file is written by globalSetup.js across a process boundary, so treat // it as untrusted input and validate with Zod (project convention for any // external/runtime-loaded payload). const TierCacheSchema = z.object({ - tier: z.enum(['free', 'premium', 'ultimate']).optional(), + tier: z.enum(['free', 'premium', 'ultimate', 'unknown']).optional(), + detectionFailed: z.boolean().optional(), + reason: z.string().optional(), }); // Mirror globalSetup.js: namespace by checkout root hash so concurrent runs @@ -52,26 +63,36 @@ const REPO_HASH = crypto const TIER_FILE = path.join(os.tmpdir(), `gitlab-mcp-detected-tier-${REPO_HASH}.json`); -function readDetectedTier(): GitLabTier { +function readDetectedTier(): DetectedTier { try { - if (!fs.existsSync(TIER_FILE)) return 'free'; + if (!fs.existsSync(TIER_FILE)) return 'unknown'; const parsed = TierCacheSchema.safeParse(JSON.parse(fs.readFileSync(TIER_FILE, 'utf8'))); - if (!parsed.success) return 'free'; - return parsed.data.tier ?? 'free'; + if (!parsed.success || !parsed.data.tier) return 'unknown'; + return parsed.data.tier; } catch { - return 'free'; + return 'unknown'; } } -const DETECTED_TIER: GitLabTier = readDetectedTier(); +const DETECTED_TIER: DetectedTier = readDetectedTier(); -/** Return the tier detected during globalSetup, or 'free' as fallback. */ -export function getDetectedTier(): GitLabTier { +/** + * Return the tier detected during globalSetup. Returns 'unknown' when + * detection failed (network/auth/cache-miss) — caller can distinguish a + * confirmed Free instance from a failed detection. + */ +export function getDetectedTier(): DetectedTier { return DETECTED_TIER; } -/** True if the detected tier satisfies (>=) the required tier. */ +/** + * True if the detected tier satisfies (>=) the required tier. + * When detection failed ('unknown'), returns true — we'd rather run the + * gated suite and fail loudly with the real error than silently skip and + * hide regressions behind a misleading green-with-pending run. + */ export function tierSatisfies(required: GitLabTier): boolean { + if (DETECTED_TIER === 'unknown') return true; return TIER_RANK[DETECTED_TIER] >= TIER_RANK[required]; } From 88a9edca0b8e58c63907966089c7edf443335391 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Fri, 22 May 2026 20:18:19 +0300 Subject: [PATCH 08/10] fix(tier-gate): replace no-op rmSync retry options with explicit backoff loop fs.rmSync maxRetries/retryDelay are ignored unless recursive:true (Node docs). The previous call advertised retries that never fired. Replaced with an explicit 3-attempt loop with 50ms backoff so transient EPERM / EACCES locks on Windows actually get the intended retry window. --- tests/setup/globalSetup.js | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/tests/setup/globalSetup.js b/tests/setup/globalSetup.js index 3c59e9e63..083978cab 100644 --- a/tests/setup/globalSetup.js +++ b/tests/setup/globalSetup.js @@ -60,11 +60,21 @@ module.exports = async () => { // file locks, perms) would still throw and crash the integration run before // any tests start. writeFileSync below overwrites anyway, so a failed unlink // is not load-bearing — just log and proceed. - try { - fs.rmSync(tierFile, { force: true, maxRetries: 2, retryDelay: 50 }); - } catch (err) { - const reason = err instanceof Error ? err.message : String(err); - console.warn(`⚠️ Could not remove stale tier cache (${reason}) — will overwrite`); + // fs.rmSync's maxRetries/retryDelay are honoured only when recursive:true, + // so emulate the retry window explicitly with a small backoff loop. + let removed = false; + for (let attempt = 0; attempt < 3 && !removed; attempt += 1) { + try { + fs.rmSync(tierFile, { force: true }); + removed = true; + } catch (err) { + if (attempt === 2) { + const reason = err instanceof Error ? err.message : String(err); + console.warn(`⚠️ Could not remove stale tier cache (${reason}) — will overwrite`); + } else { + await new Promise((resolve) => setTimeout(resolve, 50)); + } + } } // Bearer works for BOTH personal access tokens and OAuth tokens against From 7267c3b16dc48d27b4cd20a3bfe2d6e056b9d173 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Fri, 22 May 2026 21:32:45 +0300 Subject: [PATCH 09/10] fix(tier-gate): use PRIVATE-TOKEN for PAT mode to match codebase auth convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligns globalSetup's tier-detection request with src/utils/fetch.ts: OAuth mode (OAUTH_ENABLED=true) → Authorization: Bearer PAT mode (default) → PRIVATE-TOKEN: PATs accept both headers on GitLab, but PRIVATE-TOKEN is the canonical PAT header and matches the rest of the codebase — reduces confusion when debugging auth issues and avoids divergence from the project's own conventions. --- tests/setup/globalSetup.js | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/setup/globalSetup.js b/tests/setup/globalSetup.js index 083978cab..91b9d2e6e 100644 --- a/tests/setup/globalSetup.js +++ b/tests/setup/globalSetup.js @@ -77,18 +77,24 @@ module.exports = async () => { } } - // Bearer works for BOTH personal access tokens and OAuth tokens against - // GitLab; PRIVATE-TOKEN would 401 on OAuth tokens and silently default to - // 'free', incorrectly skipping Premium/Ultimate suites. + // Match the codebase auth convention from src/utils/fetch.ts: + // OAuth mode → Authorization: Bearer + // PAT mode → PRIVATE-TOKEN: (GitLab's canonical PAT header) + // PATs also accept Bearer, but PRIVATE-TOKEN keeps us consistent with the + // rest of the codebase and avoids confusion when debugging auth issues. // AbortController guards against a hung connection blocking suite startup // (Jest's per-test timeout doesn't apply in globalSetup). + const oauthMode = String(process.env.OAUTH_ENABLED ?? '').toLowerCase() === 'true'; + const authHeaders = oauthMode + ? { Authorization: `Bearer ${process.env.GITLAB_TOKEN}` } + : { 'PRIVATE-TOKEN': process.env.GITLAB_TOKEN }; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); try { const res = await fetch(`${process.env.GITLAB_API_URL}/api/graphql`, { method: 'POST', headers: { - Authorization: `Bearer ${process.env.GITLAB_TOKEN}`, + ...authHeaders, 'Content-Type': 'application/json', }, body: JSON.stringify({ query: '{ currentLicense { plan } }' }), From 64feb2b1ae702d355a38e92ff36e28cdaa6c83a0 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Fri, 22 May 2026 21:50:34 +0300 Subject: [PATCH 10/10] fix(lint): cover .js files and add missing Node globals to JS eslint config yarn lint previously matched only **/*.ts, so .js files (globalSetup.js, sequencer.js, globalTeardown.js, etc.) escaped local lint and CI lint even though they had real no-undef errors. CodeRabbit's independent ESLint run caught it. - Extend lint and lint:fix globs to {ts,js} - Add AbortController, clearTimeout, clearInterval, setInterval, Promise, URL, Buffer to the .js globals block in eslint.config.mjs (previously had only setTimeout/fetch from the Node async surface) --- eslint.config.mjs | 7 +++++++ package.json | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 8f9a401c3..fde1a3252 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -20,7 +20,14 @@ export default [ process: 'readonly', console: 'readonly', setTimeout: 'readonly', + clearTimeout: 'readonly', + setInterval: 'readonly', + clearInterval: 'readonly', fetch: 'readonly', + AbortController: 'readonly', + Promise: 'readonly', + URL: 'readonly', + Buffer: 'readonly', }, }, rules: { diff --git a/package.json b/package.json index 95d7e29ff..ca86d4da2 100644 --- a/package.json +++ b/package.json @@ -449,8 +449,8 @@ "cleanup:test-groups": "node scripts/cleanup-test-groups.js", "cleanup:test-groups:dry-run": "node scripts/cleanup-test-groups.js --dry-run", "cleanup:test-groups:force": "node scripts/cleanup-test-groups.js --force", - "lint": "tsc --noEmit && eslint \"{src,tests}/**/*.ts\" ", - "lint:fix": "eslint \"{src,tests}/**/*.ts\" --fix", + "lint": "tsc --noEmit && eslint \"{src,tests}/**/*.{ts,js}\"", + "lint:fix": "eslint \"{src,tests}/**/*.{ts,js}\" --fix", "format": "prettier --write \"**/*.{js,ts,json,md}\"", "format:check": "prettier --check \"**/*.{js,ts,json,md}\"", "list-tools": "node dist/src/cli/list-tools.js",