From 45f88e3fa9435af9cffcb6a27099bbc23c37234e Mon Sep 17 00:00:00 2001 From: huyphan Date: Tue, 30 Jun 2026 22:34:59 -0600 Subject: [PATCH 1/2] feat: status-colored DDL diff + safety gates on Execute (all dialects) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DDL diff: left=Source(original)/right=Target(destination) to match the toolbar's connection panel order; diff highlight now signals what the migration will do (green=new object, amber=modified, red=dropped) instead of a fixed add/remove color pair. Driven entirely by TableDiff.status, so it applies uniformly across all 10 dialects with no per-dialect branching. - Execute button: four existing risk signals that only warned now actively block the button until resolved — drop-dependency conflicts (removed the one-click "Deploy anyway" bypass), destructive drops without non-destructive mode, an unhealthy target connection, and MySQL/MariaDB routine+binlog privilege risk. The two acknowledgment checkboxes are keyed to the exact generatedSql they were ticked against, so any plan change invalidates a stale acknowledgment. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 2 + .../frontend/components/ObjectDetailPanel.tsx | 154 ++++++++++++++---- .../web/src/frontend/components/SqlEditor.tsx | 9 +- .../object-detail/DependencyWarningDialog.tsx | 25 ++- apps/web/src/frontend/monaco-setup.ts | 109 +++++++++---- ...2026-06-30-diff-colors-and-safety-gates.md | 83 ++++++++++ 6 files changed, 307 insertions(+), 75 deletions(-) create mode 100644 docs/plans/2026-06-30-diff-colors-and-safety-gates.md diff --git a/.gitignore b/.gitignore index 8061a9d..21dbdf8 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,8 @@ CHANGELOG.md # Security policy and docs must be tracked !SECURITY.md !docs/security/security-process.md +# Session work-summary plans must be tracked +!docs/plans/*.md # Desktop (Tauri) build artifacts apps/desktop/src-tauri/target/ apps/desktop/src-tauri/gen/ diff --git a/apps/web/src/frontend/components/ObjectDetailPanel.tsx b/apps/web/src/frontend/components/ObjectDetailPanel.tsx index ae1c8b4..7081d59 100644 --- a/apps/web/src/frontend/components/ObjectDetailPanel.tsx +++ b/apps/web/src/frontend/components/ObjectDetailPanel.tsx @@ -2,7 +2,7 @@ import React, { useState, useMemo, Suspense, lazy } from 'react'; import { useSyncStore } from '../store/useSyncStore'; import { Code, Play, RefreshCw, FileText, CheckCircle2, ChevronRight, ChevronDown, KeyRound, Copy, GitCompareArrows, AlertTriangle } from 'lucide-react'; import { SqlGeneratorModule } from '../lib/sql-generator'; -import { findDropDependencies, type DropDependency } from '../lib/dependency-scan'; +import { findDropDependencies } from '../lib/dependency-scan'; import { diffLines } from '../utils/lineDiff'; import { highlightMatch } from '../utils/highlight'; import { formatSql } from '../utils/formatSql'; @@ -33,6 +33,7 @@ export const ObjectDetailPanel: React.FC = () => { isComparing, sourceConfig, targetConfig, + targetConnected, compareResult, browseMode, syncSelection, @@ -59,7 +60,27 @@ export const ObjectDetailPanel: React.FC = () => { const [showConfirm, setShowConfirm] = useState(false); const [dontAskAgain, setDontAskAgain] = useState(false); // Pre-deploy warning: dropped tables/columns still referenced by views/functions/procedures. - const [dropDeps, setDropDeps] = useState([]); + // The dialog always renders the live scan (below) — this only tracks open/closed. + const [showDepsDialog, setShowDepsDialog] = useState(false); + // Explicit acknowledgment for destructive drops / MySQL binlog risk — keyed to the + // exact generatedSql that was acknowledged, so any change to the plan (new selection, + // toggling non-destructive, etc.) silently invalidates a stale checkbox instead of + // carrying forward consent for a plan the user never actually saw. + const [destructiveAckSql, setDestructiveAckSql] = useState(null); + const [mysqlAckSql, setMysqlAckSql] = useState(null); + + // Live dependency scan — recomputed on every selection/nonDestructive change, not just + // on Execute click, so the button can stay disabled until conflicts are resolved. + const liveDropDeps = useMemo( + () => (compareResult ? findDropDependencies(compareResult.tables, syncSelection, { nonDestructive }) : []), + [compareResult, syncSelection, nonDestructive] + ); + const hasUnresolvedDropDeps = liveDropDeps.length > 0; + + // Destructive drops (DROP TABLE/COLUMN/INDEX) in the generated plan while non-destructive + // mode is off — require an explicit checkbox acknowledgment before Execute unlocks. + const hasDestructiveDrops = !nonDestructive && !!generatedSql && /\bDROP\s+(TABLE|COLUMN|INDEX)\b/i.test(generatedSql); + const destructiveDropsAcked = destructiveAckSql !== null && destructiveAckSql === generatedSql; const toggleTriggerDdl = (name: string) => setExpandedTriggers((prev) => ({ ...prev, [name]: !prev[name] })); @@ -96,26 +117,18 @@ export const ObjectDetailPanel: React.FC = () => { } }; - // Before deploying, warn when a selected drop (table/column) would break a - // view/function/procedure that still references it. Recommend handling those - // dependents first; the user can include them in the deploy or proceed anyway. + // Execute is disabled whenever hasUnresolvedDropDeps is true (see the disabled + // expression on the button below), so reaching this handler means the dependency + // scan is already clean. Kept as a defensive re-check rather than trusting that + // every call site respects the disabled state. const handleExecuteClick = () => { - const deps = compareResult - ? findDropDependencies(compareResult.tables, syncSelection, { nonDestructive }) - : []; - if (deps.length > 0) { - setDropDeps(deps); + if (liveDropDeps.length > 0) { + setShowDepsDialog(true); return; } proceedToConfirm(); }; - // "Deploy anyway" from the dependency warning — dismiss it and run the normal flow. - const proceedDespiteDeps = () => { - setDropDeps([]); - proceedToConfirm(); - }; - const confirmExecute = () => { if (dontAskAgain) localStorage.setItem(SKIP_DEPLOY_CONFIRM_KEY, 'true'); setShowConfirm(false); @@ -164,15 +177,25 @@ export const ObjectDetailPanel: React.FC = () => { {selectedTable.tableName} - {/* Orientation: target (left, current state) → source (right, desired state). - Monaco computes "what transforms original into modified" — so target is - original and source is modified. Green = added to target, Red = removed from target. */} - Target (current) + {/* Orientation: source (left, original) → target (right, destination) — + matches the Source/Target connection panel layout in the top toolbar. + Monaco computes "what transforms original into modified" — so source is + original and target is modified. Diff highlight color signals what the + migration will DO to this object: green = new object, amber = existing + object's definition changed, red = object is being dropped. */} + Source (original) - Source (desired) + Target (destination) - add to target - remove from target + {selectedTable.status === 'ADDED' && ( + new object + )} + {selectedTable.status === 'REMOVED' && ( + dropped + )} + {(selectedTable.status === 'MODIFIED' || selectedTable.status === 'UNCHANGED') && ( + modified + )}
@@ -198,12 +221,13 @@ export const ObjectDetailPanel: React.FC = () => {
}>
@@ -965,6 +989,17 @@ export const ObjectDetailPanel: React.FC = () => { } return (t.triggerDiffs ?? []).some((d) => d.status === 'ADDED' || d.status === 'MODIFIED'); }); + const mysqlRiskAcked = mysqlAckSql !== null && mysqlAckSql === generatedSql; + + // Single source of truth for why Execute is disabled, checked in priority order — + // most severe / least self-service first. `null` means nothing is blocking. + const executeBlockReason: string | null = + includedCount === 0 ? 'No objects selected for deployment' + : !targetConnected ? 'Target connection is not healthy — reconnect before deploying' + : hasUnresolvedDropDeps ? `${liveDropDeps.length} dependent object(s) would break — resolve the conflicts below` + : hasDestructiveDrops && !destructiveDropsAcked ? 'Acknowledge the destructive drops below before deploying' + : deploysRoutineToMySql && !mysqlRiskAcked ? 'Acknowledge the MySQL binlog privilege risk below before deploying' + : null; return (
@@ -1023,12 +1058,17 @@ export const ObjectDetailPanel: React.FC = () => {
+ {/* Safety gate banner — anything that currently blocks Execute, with the + action needed to clear it. Visible on every tab, not just Migration SQL, + since Execute lives in the toolbar above regardless of active tab. */} + {!browseMode && (!targetConnected || hasUnresolvedDropDeps || (hasDestructiveDrops && !destructiveDropsAcked) || (deploysRoutineToMySql && !mysqlRiskAcked)) && ( +
+ {!targetConnected && ( +
+ + Target connection is not healthy — reconnect the target before deploying. +
+ )} + {hasUnresolvedDropDeps && ( +
+ + + {liveDropDeps.length} dependent object{liveDropDeps.length === 1 ? '' : 's'} would break from a drop in this deploy. + + +
+ )} + {hasDestructiveDrops && ( + + )} + {deploysRoutineToMySql && ( + + )} +
+ )} + {/* Main Panel Content Panel */}
{browseMode || activeTab === 'DIFF' ? ( diff --git a/apps/web/src/frontend/components/SqlEditor.tsx b/apps/web/src/frontend/components/SqlEditor.tsx index 905e52f..528119e 100644 --- a/apps/web/src/frontend/components/SqlEditor.tsx +++ b/apps/web/src/frontend/components/SqlEditor.tsx @@ -1,6 +1,6 @@ import React, { useRef, useEffect, useCallback } from 'react'; import Editor, { DiffEditor } from '@monaco-editor/react'; -import { MONACO_THEME, MONACO_THEME_LIGHT, monacoLanguage } from '../monaco-setup'; +import { MONACO_THEME, MONACO_THEME_LIGHT, MONACO_DIFF_THEME, MONACO_DIFF_THEME_LIGHT, monacoLanguage } from '../monaco-setup'; import { useUiStore } from '../store/uiStore'; const BASE_OPTIONS = { @@ -84,10 +84,12 @@ interface SqlDiffProps { height?: string | number; /** Highlight occurrences of this term on both sides of the diff. */ highlight?: string; + /** Object status — picks the diff highlight color (green/amber/red). Defaults to MODIFIED (amber). */ + status?: 'ADDED' | 'MODIFIED' | 'REMOVED'; } /** Side-by-side (or inline) SQL diff backed by Monaco's DiffEditor. */ -export const SqlDiffEditor: React.FC = ({ original, modified, dialect, inline = false, ignoreCase = false, height = '100%', highlight }) => { +export const SqlDiffEditor: React.FC = ({ original, modified, dialect, inline = false, ignoreCase = false, height = '100%', highlight, status = 'MODIFIED' }) => { // Monaco's diff is case-sensitive; normalize both sides to collapse case-only differences const orig = ignoreCase ? original.toUpperCase() : original; const mod = ignoreCase ? modified.toUpperCase() : modified; @@ -95,7 +97,8 @@ export const SqlDiffEditor: React.FC = ({ original, modified, dial const diffRef = useRef(null); const decoModRef = useRef(null); const decoOrigRef = useRef(null); - const monacoTheme = useUiStore((s) => s.resolvedMode) === 'light' ? MONACO_THEME_LIGHT : MONACO_THEME; + const isLight = useUiStore((s) => s.resolvedMode) === 'light'; + const monacoTheme = isLight ? MONACO_DIFF_THEME_LIGHT[status] : MONACO_DIFF_THEME[status]; const apply = useCallback(() => { const diff = diffRef.current; diff --git a/apps/web/src/frontend/components/object-detail/DependencyWarningDialog.tsx b/apps/web/src/frontend/components/object-detail/DependencyWarningDialog.tsx index 901e169..b345987 100644 --- a/apps/web/src/frontend/components/object-detail/DependencyWarningDialog.tsx +++ b/apps/web/src/frontend/components/object-detail/DependencyWarningDialog.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { createPortal } from 'react-dom'; -import { AlertCircle, FileText, Play } from 'lucide-react'; +import { AlertCircle, FileText } from 'lucide-react'; import type { DropDependency } from '../../lib/dependency-scan'; interface Props { @@ -8,15 +8,16 @@ interface Props { syncSelection: Record; toggleSyncSelection: (name: string) => void; onCancel: () => void; - onDeployAnyway: () => void; } /** * Pre-deploy warning shown when a selected drop (table/column) is still - * referenced by a view/function/procedure in the target. Offers a per-dependent - * "Include in deploy" quick-fix or "Deploy anyway". + * referenced by a view/function/procedure in the target. Execute stays disabled + * while this list is non-empty — there is no bypass. Resolve each entry either + * by including the dependent in the deploy, or by deselecting the object that + * drops the referenced column/table. */ -export const DependencyWarningDialog: React.FC = ({ deps, syncSelection, toggleSyncSelection, onCancel, onDeployAnyway }) => { +export const DependencyWarningDialog: React.FC = ({ deps, syncSelection, toggleSyncSelection, onCancel }) => { if (deps.length === 0) return null; return createPortal(
= ({ deps, syncSelection, {d.dropped}

- {d.deployable && ( + {d.deployable ? ( + ) : ( + + deselect the drop of {d.dropped} to resolve + )} ))} @@ -74,13 +79,7 @@ export const DependencyWarningDialog: React.FC = ({ deps, syncSelection, onClick={onCancel} className="px-4 py-2 text-xs font-semibold text-slate-400 hover:text-slate-200 hover:bg-slate-850/50 rounded transition" > - Cancel - -
diff --git a/apps/web/src/frontend/monaco-setup.ts b/apps/web/src/frontend/monaco-setup.ts index 3bed021..401c8ae 100644 --- a/apps/web/src/frontend/monaco-setup.ts +++ b/apps/web/src/frontend/monaco-setup.ts @@ -16,46 +16,99 @@ self.MonacoEnvironment = { loader.config({ monaco }); -/** Shared dark theme tuned to the app's slate palette. */ +/** Diff highlight color per object status — same hue for inserted/removed regions, + * since a diff pane's "add" vs "remove" is relative to which side you're reading, + * not a fixed direction. The color instead signals what the migration will DO: + * green = brand-new object, amber = an existing object's definition changed, + * red = the object is being dropped. */ +const DIFF_HUE: Record<'ADDED' | 'MODIFIED' | 'REMOVED', string> = { + ADDED: '#22c55e', // green-500 + MODIFIED: '#f59e0b', // amber-500 + REMOVED: '#ef4444', // red-500 +}; + +function diffColors(hex: string, dark: boolean) { + return { + 'diffEditor.insertedTextBackground': `${hex}${dark ? '22' : '2e'}`, + 'diffEditor.removedTextBackground': `${hex}${dark ? '22' : '2e'}`, + 'diffEditor.insertedLineBackground': `${hex}${dark ? '18' : '22'}`, + 'diffEditor.removedLineBackground': `${hex}${dark ? '18' : '22'}`, + }; +} + +const BASE_DARK_COLORS = { + 'editor.background': '#020617', + 'editor.foreground': '#cbd5e1', + 'editorCursor.foreground': '#22d3ee', + 'editor.lineHighlightBackground': '#0f172a', + 'editorLineNumber.foreground': '#334155', + 'editorGutter.background': '#020617', +}; + +const BASE_LIGHT_COLORS = { + 'editor.background': '#ffffff', + 'editor.foreground': '#1e293b', + 'editorCursor.foreground': '#0891b2', + 'editor.lineHighlightBackground': '#f1f5f9', + 'editorLineNumber.foreground': '#94a3b8', + 'editorGutter.background': '#ffffff', +}; + +/** Shared dark theme tuned to the app's slate palette — default/MODIFIED variant. */ export const MONACO_THEME = 'schemaSyncDark'; +/** Light counterpart, used when the app theme resolves to light. */ +export const MONACO_THEME_LIGHT = 'schemaSyncLight'; monaco.editor.defineTheme(MONACO_THEME, { base: 'vs-dark', inherit: true, rules: [], - colors: { - 'editor.background': '#020617', - 'editor.foreground': '#cbd5e1', - 'editorCursor.foreground': '#22d3ee', - 'editor.lineHighlightBackground': '#0f172a', - 'editorLineNumber.foreground': '#334155', - 'editorGutter.background': '#020617', - 'diffEditor.insertedTextBackground': '#10b98122', - 'diffEditor.removedTextBackground': '#f43f5e22', - 'diffEditor.insertedLineBackground': '#10b98118', - 'diffEditor.removedLineBackground': '#f43f5e18', - }, + colors: { ...BASE_DARK_COLORS, ...diffColors(DIFF_HUE.MODIFIED, true) }, }); -/** Light counterpart, used when the app theme resolves to light. */ -export const MONACO_THEME_LIGHT = 'schemaSyncLight'; - monaco.editor.defineTheme(MONACO_THEME_LIGHT, { base: 'vs', inherit: true, rules: [], - colors: { - 'editor.background': '#ffffff', - 'editor.foreground': '#1e293b', - 'editorCursor.foreground': '#0891b2', - 'editor.lineHighlightBackground': '#f1f5f9', - 'editorLineNumber.foreground': '#94a3b8', - 'editorGutter.background': '#ffffff', - 'diffEditor.insertedTextBackground': '#10b9812e', - 'diffEditor.removedTextBackground': '#f43f5e2e', - 'diffEditor.insertedLineBackground': '#10b98122', - 'diffEditor.removedLineBackground': '#f43f5e22', - }, + colors: { ...BASE_LIGHT_COLORS, ...diffColors(DIFF_HUE.MODIFIED, false) }, +}); + +/** Status-specific diff themes — one per object status, dark + light. */ +export const MONACO_DIFF_THEME: Record<'ADDED' | 'MODIFIED' | 'REMOVED', string> = { + ADDED: 'schemaSyncDiffAddedDark', + MODIFIED: MONACO_THEME, + REMOVED: 'schemaSyncDiffRemovedDark', +}; + +export const MONACO_DIFF_THEME_LIGHT: Record<'ADDED' | 'MODIFIED' | 'REMOVED', string> = { + ADDED: 'schemaSyncDiffAddedLight', + MODIFIED: MONACO_THEME_LIGHT, + REMOVED: 'schemaSyncDiffRemovedLight', +}; + +monaco.editor.defineTheme(MONACO_DIFF_THEME.ADDED, { + base: 'vs-dark', + inherit: true, + rules: [], + colors: { ...BASE_DARK_COLORS, ...diffColors(DIFF_HUE.ADDED, true) }, +}); +monaco.editor.defineTheme(MONACO_DIFF_THEME_LIGHT.ADDED, { + base: 'vs', + inherit: true, + rules: [], + colors: { ...BASE_LIGHT_COLORS, ...diffColors(DIFF_HUE.ADDED, false) }, +}); +monaco.editor.defineTheme(MONACO_DIFF_THEME.REMOVED, { + base: 'vs-dark', + inherit: true, + rules: [], + colors: { ...BASE_DARK_COLORS, ...diffColors(DIFF_HUE.REMOVED, true) }, +}); +monaco.editor.defineTheme(MONACO_DIFF_THEME_LIGHT.REMOVED, { + base: 'vs', + inherit: true, + rules: [], + colors: { ...BASE_LIGHT_COLORS, ...diffColors(DIFF_HUE.REMOVED, false) }, }); /** Map an app dialect to a Monaco language id. */ diff --git a/docs/plans/2026-06-30-diff-colors-and-safety-gates.md b/docs/plans/2026-06-30-diff-colors-and-safety-gates.md new file mode 100644 index 0000000..ecffe13 --- /dev/null +++ b/docs/plans/2026-06-30-diff-colors-and-safety-gates.md @@ -0,0 +1,83 @@ +# Diff colors and migration safety gates — 2026-06-30 + +## Summary + +Two related UI/UX changes to the compare-and-migrate flow, both dialect-agnostic +(driven by `TableDiff.status` and generated SQL content, not by `dialect`): + +1. **DDL diff panel** (`ObjectDetailPanel.tsx` → `SqlDiffEditor`) now orients + left = Source (original), right = Target (destination) — matching the + Source/Target connection panel layout in the toolbar — and colors the diff + by what the migration will *do* to the object, not by which side added or + removed a line: + - 🟢 green — brand-new object (`ADDED`) + - 🟠 amber — existing object's definition changed (`MODIFIED`) + - 🔴 red — object is being dropped (`REMOVED`) + +2. **Execute button safety gating.** Previously Execute only disabled for + lifecycle/selection reasons (no objects selected, compare/migration in + flight). Four risk signals existed but only warned — never blocked: + - Drop-dependency conflicts (dropped table/column still referenced by a + view/function/procedure staying in target) — had a one-click + "Deploy anyway" bypass. + - Destructive drops (`DROP TABLE/COLUMN/INDEX`) with non-destructive mode + off — no gate at all. + - Target connection health — never checked. + - MySQL/MariaDB routine/trigger + binlog privilege risk — static + informational banner only. + + All four now actively disable Execute until resolved: dependency conflicts + require inclusion or deselection (bypass removed), destructive drops and + the MySQL binlog risk require an explicit checkbox acknowledgment (reset + whenever the underlying plan changes), and an unhealthy target connection + blocks outright with no bypass. + +## Why + +- User feedback: the diff view's left/right orientation didn't match the + rest of the app (Source panel is on the left everywhere else), and a + fixed "green=added/red=removed" scheme was confusing once left/right no + longer mapped to a fixed direction — the color needed to signal migration + intent (new/changed/dropped) instead. +- Follow-up ask: "buttons should disable when migration pipeline would be + safer" — the Execute button was a pure lifecycle gate with no correctness + signal; several real risk conditions already existed in the codebase but + only produced a dismissible warning. + +## Implementation + +| File | Change | +|---|---| +| `apps/web/src/frontend/monaco-setup.ts` | Added three status-keyed Monaco diff themes (green/amber/red, dark + light) — `MONACO_DIFF_THEME` / `MONACO_DIFF_THEME_LIGHT` | +| `apps/web/src/frontend/components/SqlEditor.tsx` | `SqlDiffEditor` accepts a `status` prop (`ADDED`\|`MODIFIED`\|`REMOVED`, default `MODIFIED`) that picks the theme | +| `apps/web/src/frontend/components/ObjectDetailPanel.tsx` | Swapped `original`/`modified` props (source left, target right); status-aware legend; live `useMemo` for drop-dependency scan and destructive-drop detection; acknowledgment state keyed to `generatedSql` so any plan change invalidates a stale checkbox; new safety banner row visible on every tab; `executeBlockReason` single source of truth for the disabled state and tooltip | +| `apps/web/src/frontend/components/object-detail/DependencyWarningDialog.tsx` | Removed `onDeployAnyway` / "Deploy anyway" button entirely; non-deployable dependents now show a hint to deselect the drop instead | + +## Verification + +- `cd apps/web && npx tsc --noEmit` — clean +- `npx vitest run` — 92/92 passing +- Live browser verification (Playwright against seeded MySQL `demo_a`/`demo_b`): + - `ADDED` object (`CATEGORIES`) → green diff, "new object" legend + - `MODIFIED` object (`CUSTOMERS`) → amber diff, "modified" legend + - `REMOVED` object (`LEGACY_AUDIT_LOG`) → red diff, "dropped" legend + - Selecting all 12 objects (destructive drops + MySQL routine/trigger + creation both present) → Execute disabled, both acknowledgment + checkboxes shown; ticking both unlocks Execute +- Dependency-conflict and target-unhealthy gates verified by code review — + same pre-existing `findDropDependencies` call (now memoized instead of + computed on click) and the same `targetConnected` store field already + used elsewhere in the app. + +## Not done / follow-ups + +- No live repro was captured for the drop-dependency conflict banner + specifically (constructing a scenario with a *staying* dependent object + referencing a dropped column takes more seed-data setup than was + in scope here) — logic is unchanged from the prior working + implementation, only its trigger timing (continuous vs. on-click) and the + bypass removal. +- The MySQL/MariaDB binlog banner in the "Migration SQL" tab (detailed + remediation instructions) was left in place alongside the new gated + banner — the new banner is the actual gate; the tab banner is + supplementary detail with the exact `SET GLOBAL` command. From f703ae4a1ac7389a973800e193bf45f0d353fb2c Mon Sep 17 00:00:00 2001 From: huyphan Date: Tue, 30 Jun 2026 22:41:05 -0600 Subject: [PATCH 2/2] fix: remove unused @eslint/js dependency causing CI npm install failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @eslint/js was never imported in eslint.config.js — the config uses only typescript-eslint's parser and eslint-plugin-security, no @eslint/js rules. A recent @eslint/js@10.0.1 release requires eslint@^10.0.0 as a peer, which conflicts with the pinned eslint@^9.0.0, breaking every CI job that runs npm install fresh (no lockfile is committed). Removing the unused dep resolves the ERESOLVE conflict without touching the actual lint toolchain. Co-Authored-By: Claude Sonnet 4.6 --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index 73e8bff..fc0a777 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,6 @@ "devDependencies": { "typescript": "~6.0.2", "vitest": "^4.1.8", - "@eslint/js": "^10.0.1", "eslint": "^9.0.0", "eslint-plugin-security": "^4.0.1", "typescript-eslint": "^8.0.0"