Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export default [
// TypeScript strict rules
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_', destructuredArrayIgnorePattern: '^_' }],
'@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports', fixStyle: 'inline-type-imports' }],
'@typescript-eslint/no-non-null-assertion': 'warn',
'@typescript-eslint/naming-convention': [
Expand Down Expand Up @@ -110,7 +110,7 @@ export default [
...typescript.configs.recommended.rules,
// E2E tem fixtures, helpers e selectors — relaxar regras de produção:
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_', destructuredArrayIgnorePattern: '^_' }],
'@typescript-eslint/no-non-null-assertion': 'off',
'no-console': 'off',
'no-empty-pattern': 'off', // Playwright fixtures: ({}, testInfo) => ...
Expand Down Expand Up @@ -175,7 +175,7 @@ export default [
'react/react-in-jsx-scope': 'off',
'react/prop-types': 'off',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_', destructuredArrayIgnorePattern: '^_' }],
'@typescript-eslint/no-non-null-assertion': 'off',
'no-console': 'off',
// Tests podem usar mocks/stubs com nomes não convencionais
Expand Down
329 changes: 329 additions & 0 deletions scripts/codemod-unused-vars.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,329 @@
#!/usr/bin/env node
/**
* Codemod for `@typescript-eslint/no-unused-vars`. Walks the ESLint
* output, locates each unused identifier in source, and applies the
* right transform per declaration kind:
*
* - Unused IMPORT named specifiers → removed from the import (the
* entire import is dropped if it becomes empty).
* - Unused IMPORT default → import line removed.
* - Unused FUNCTION PARAMETERS → renamed to `_name` to satisfy the
* `argsIgnorePattern: '^_'` config (cannot be removed without
* breaking arity for callers).
* - Unused DESTRUCTURED VARIABLES → renamed to `_name` for the same
* reason (siblings in the destructure pattern may be used).
* - Unused LOCAL VARIABLES (`const`/`let`) → renamed to `_name` (safer
* than removal: the right-hand side may have intentional side
* effects; a manual review pass can drop them later).
*
* Skipped: identifiers that look already-prefixed (`_name`); identifiers
* inside JSX attributes (rare false positive of TS inference).
*
* Usage:
* node scripts/codemod-unused-vars.mjs # rewrite src/
* node scripts/codemod-unused-vars.mjs --check # exit 1 if any
* # remaining unused
* node scripts/codemod-unused-vars.mjs --dry-run # log what would
* # change without
* # writing
*/
import { readFile, writeFile } from 'node:fs/promises';
import { spawnSync } from 'node:child_process';

const args = new Set(process.argv.slice(2));
const CHECK_ONLY = args.has('--check');
const DRY_RUN = args.has('--dry-run');

/**
* Run eslint and return all `@typescript-eslint/no-unused-vars` errors
* grouped by file. Each entry: { file, errors: [{ line, column, name }] }.
*/
function gatherErrors() {
const proc = spawnSync(
'npx',
['eslint', 'src', '--max-warnings=500', '--format=json'],
{ encoding: 'utf8', maxBuffer: 200 * 1024 * 1024 },
);
// eslint exits non-zero when there are violations — that's expected.
let parsed;
try {
parsed = JSON.parse(proc.stdout);
} catch (e) {
console.error('eslint did not produce valid JSON output');
console.error(proc.stderr);
process.exit(2);
}
const byFile = new Map();
for (const result of parsed) {
for (const msg of result.messages) {
if (msg.ruleId !== '@typescript-eslint/no-unused-vars') continue;
// Extract the identifier name from the message text:
// "'foo' is defined but never used. ..."
// "'foo' is assigned a value but never used. ..."
const m = msg.message.match(/^'([^']+)'\s+is\s+(defined|assigned)/);
if (!m) continue;
const name = m[1];
if (!byFile.has(result.filePath)) byFile.set(result.filePath, []);
byFile.get(result.filePath).push({
line: msg.line,
column: msg.column,
name,
});
}
}
return byFile;
}

/**
* Locate the identifier `name` starting at (line, column) — 1-based — in
* the file source. Returns the absolute character index or -1.
*/
function findIdentifier(src, line, column, name) {
const lines = src.split('\n');
if (line - 1 >= lines.length) return -1;
const offsetInLine = column - 1;
// ESLint columns can occasionally point at the keyword (let/const/import)
// rather than the identifier itself; search for the name on the line if
// the direct lookup misses.
let lineStart = 0;
for (let i = 0; i < line - 1; i++) lineStart += lines[i].length + 1;
if (lines[line - 1].slice(offsetInLine, offsetInLine + name.length) === name) {
return lineStart + offsetInLine;
}
const found = lines[line - 1].indexOf(name, offsetInLine);
if (found !== -1) return lineStart + found;
// Fall back: search the whole line
const found2 = lines[line - 1].indexOf(name);
return found2 === -1 ? -1 : lineStart + found2;
}

/**
* Determine the declaration kind from the source surrounding the
* identifier. Returns one of: 'import-named' | 'import-default' |
* 'param' | 'destructure' | 'local' | 'unknown'.
*/
function classify(src, idx, name) {
// Find the line containing idx
const lineStart = src.lastIndexOf('\n', idx - 1) + 1;
const lineEnd = src.indexOf('\n', idx);
const lineText = src.slice(lineStart, lineEnd === -1 ? src.length : lineEnd);

// import line — single-line OR multi-line (`import { Foo,\n Bar } from "x"`)
// Look back from idx for the start of the statement (max 800 chars).
const back = src.slice(Math.max(0, idx - 800), idx);
// Find the LAST 'import' before idx, then check if a 'from' has appeared
// after it (which would mean the import is closed and we're past it).
const lastImportRel = back.lastIndexOf('import ');
const lastFromRel = back.lastIndexOf('from ');
const insideImport =
lastImportRel !== -1 && (lastFromRel === -1 || lastImportRel > lastFromRel);
if (insideImport || /^\s*import\b/.test(lineText)) {
// Inside braces? → named import
// Use the same look-back to find the most recent `{`/`}` and decide.
const lastOpenBrace = back.lastIndexOf('{');
const lastCloseBrace = back.lastIndexOf('}');
if (lastOpenBrace !== -1 && lastOpenBrace > lastCloseBrace) {
return 'import-named';
}
return 'import-default';
}

// Look back (within the same statement) for a destructure opener.
// Heuristic: in the 200 chars before idx, was there a `{` or `[` not
// matched by a `}` or `]`?
const window = src.slice(Math.max(0, idx - 400), idx);
const opens = (window.match(/[{[]/g) || []).length;
const closes = (window.match(/[}\]]/g) || []).length;
const inDestructure = opens > closes;

// Function param? Look backward for `(` not matched and not preceded
// by something that suggests a call (identifier).
// Heuristic: in the same 400-char window, more `(` than `)`?
const opensP = (window.match(/\(/g) || []).length;
const closesP = (window.match(/\)/g) || []).length;
const inParens = opensP > closesP;

// const/let/var declaration on the same line (immediately before idx)?
if (/\b(?:const|let|var)\b\s+(?:\{[^}]*|\[[^\]]*)?$/.test(lineText.slice(0, idx - lineStart))
|| /^\s*(?:const|let|var)\b/.test(lineText)) {
if (inDestructure) return 'destructure';
return 'local';
}

if (inDestructure) return 'destructure';
if (inParens) return 'param';
return 'unknown';
}

const stats = { import: 0, param: 0, local: 0, destructure: 0, skipped: 0 };

const byFile = gatherErrors();
if (CHECK_ONLY) {
let total = 0;
for (const errors of byFile.values()) total += errors.length;
if (total > 0) {
console.error(`✖ ${total} unused identifier(s) across ${byFile.size} file(s).`);
console.error('Run: node scripts/codemod-unused-vars.mjs');
process.exit(1);
}
console.log('✅ no @typescript-eslint/no-unused-vars violations.');
process.exit(0);
}

let filesChanged = 0;

for (const [file, errors] of byFile) {
let src = await readFile(file, 'utf8');
// Process from highest line/column to lowest so edits don't invalidate
// earlier coordinates.
errors.sort((a, b) => (b.line - a.line) || (b.column - a.column));

let mutated = false;

for (const err of errors) {
if (err.name.startsWith('_')) {
stats.skipped++;
continue;
}
const idx = findIdentifier(src, err.line, err.column, err.name);
if (idx === -1) {
stats.skipped++;
continue;
}
const kind = classify(src, idx, err.name);

if (kind === 'import-named') {
// Drop this named specifier from the import. If the import becomes
// empty (no default, no named, no namespace), drop the entire line.
const newSrc = removeImportNamedSpec(src, idx, err.name);
if (newSrc === null) {
stats.skipped++;
continue;
}
src = newSrc;
stats.import++;
mutated = true;
} else if (kind === 'import-default') {
// Drop the entire import line.
const newSrc = removeImportLine(src, idx);
if (newSrc === null) {
stats.skipped++;
continue;
}
src = newSrc;
stats.import++;
mutated = true;
} else if (kind === 'param' || kind === 'destructure' || kind === 'local') {
// Prefix with `_`. Safe: matches the eslint argsIgnorePattern /
// varsIgnorePattern. Need to ensure we only rename the binding,
// not other occurrences elsewhere — the binding lives at idx and
// is exactly `err.name.length` chars long.
src = src.slice(0, idx) + '_' + src.slice(idx);
stats[kind === 'destructure' ? 'destructure' : kind === 'param' ? 'param' : 'local']++;
mutated = true;
} else {
stats.skipped++;
}
}

if (mutated) {
if (DRY_RUN) {
console.log(`would write ${file}`);
} else {
await writeFile(file, src, 'utf8');
}
filesChanged++;
}
}

console.log('');
console.log(
`Files changed: ${filesChanged}. Edits — imports: ${stats.import}, ` +
`params: ${stats.param}, destructure: ${stats.destructure}, ` +
`locals: ${stats.local}, skipped: ${stats.skipped}.`,
);

/**
* Remove the named specifier `name` from the import statement that contains
* `idx`. Returns the new source, or null if the parse failed.
*
* Handles:
* import { Foo, Bar } from "x" → import { Bar } from "x"
* import { Foo, type T } from "x" → import { type T } from "x"
* import { Foo as F } from "x" → removes the alias clause
* import Default, { Foo } from "x" → removes only the {Foo}; if braces
* become empty, drops the whole brace
* group but keeps the default import.
*/
function removeImportNamedSpec(src, idx, name) {
// Find the brace containing idx (back to nearest `{`, forward to `}`).
const openBrace = src.lastIndexOf('{', idx);
const closeBrace = src.indexOf('}', idx);
if (openBrace === -1 || closeBrace === -1) return null;

const body = src.slice(openBrace + 1, closeBrace);
const specs = body.split(',').map((s) => s.trim()).filter(Boolean);

// Match the specifier whose binding name (post-`as`) equals `name`.
// For `Foo as Bar`, the binding is `Bar`. For `type Foo`, the binding
// is `Foo`.
const idxOfMatch = specs.findIndex((s) => {
const trimmed = s.replace(/^type\s+/, '').trim();
const asMatch = trimmed.match(/\bas\s+([A-Za-z0-9_$]+)$/);
const local = asMatch ? asMatch[1] : trimmed;
return local === name;
});
if (idxOfMatch === -1) return null;

specs.splice(idxOfMatch, 1);

// Find the import statement bounds.
const importStart = src.lastIndexOf('import', openBrace);
let importEnd = src.indexOf(';', closeBrace);
if (importEnd === -1) importEnd = src.indexOf('\n', closeBrace);
if (importEnd === -1) importEnd = src.length;

const importLine = src.slice(importStart, importEnd + 1);

// Was there a default import or namespace alongside the braces?
const beforeBrace = src.slice(importStart, openBrace);
const hasDefault = /import\s+[A-Za-z0-9_$]+\s*,\s*\{/.test(beforeBrace) ||
/import\s+[A-Za-z0-9_$]+\s*,\s*\*/.test(beforeBrace);
const hasNamespace = /\*\s+as\s+[A-Za-z0-9_$]+/.test(importLine);

if (specs.length === 0) {
if (hasDefault || hasNamespace) {
// Drop the {} group and its leading comma.
// Find the comma after the default ident.
const trimmedBefore = beforeBrace.replace(/,\s*$/, ' ');
const newImport = trimmedBefore + src.slice(closeBrace + 1, importEnd + 1);
return src.slice(0, importStart) + newImport.trimEnd().replace(/\s+/g, ' ').replace(/^\s*import\s+/, 'import ') + src.slice(importEnd + 1);
}
// No default/namespace — drop the whole import line.
return removeImportLine(src, importStart);
}

// Rebuild the brace body.
const newBody = ` ${specs.join(', ')} `;
return src.slice(0, openBrace + 1) + newBody + src.slice(closeBrace);
}

/**
* Remove the import statement that contains `idx`. Drops trailing newline.
*/
function removeImportLine(src, idx) {
const lineStart = src.lastIndexOf('\n', idx - 1) + 1;
// Statement may span lines; find the terminating `;` or end-of-line that
// closes a single-line import.
let lineEnd = src.indexOf(';', idx);
if (lineEnd === -1 || lineEnd > src.indexOf('\n', idx) && src.indexOf('\n', idx) !== -1 && !src.slice(idx, src.indexOf('\n', idx)).includes('{')) {
// Single-line import without trailing semicolon
lineEnd = src.indexOf('\n', idx);
if (lineEnd === -1) return null;
} else {
lineEnd += 1; // include the `;`
}
// Include the trailing newline if present
if (src[lineEnd] === '\n') lineEnd += 1;
return src.slice(0, lineStart) + src.slice(lineEnd);
}
5 changes: 2 additions & 3 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useState, type FC } from "react";
import { useEffect, useState } from "react";
import { Toaster } from "@/components/ui/toaster";
import { Toaster as Sonner } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
Expand All @@ -15,7 +15,6 @@ import { DevRoute } from "@/components/layout/DevRoute";
import { DeprecatedRoute } from "@/components/layout/DeprecatedRoute";
import { AppProviders } from "@/components/providers/AppProviders";
import { AccessibilityProvider, AriaLiveProvider } from "@/components/a11y";
import LoadingScreen from "@/components/LoadingScreen";
import { useGlobalErrorCatcher } from "@/hooks/useErrorHandler";
import { markBootSuccessful } from "@/lib/chunk-recovery";
import { getFallback } from "@/components/layout/SkeletonLoaders";
Expand Down Expand Up @@ -154,7 +153,7 @@ function RouteSuspense({ children }: { children: React.ReactNode }) {
}

const App = () => {
const [isOnline, setIsOnline] = useState(navigator.onLine);
const [_isOnline, setIsOnline] = useState(navigator.onLine);
useGlobalErrorCatcher();

// Apply saved theme on boot (ThemeInitializer handles re-apply on mode change)
Expand Down
2 changes: 1 addition & 1 deletion src/components/admin/DiscountApprovalHeaderBadge.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { forwardRef, useEffect } from "react";
import { useEffect } from "react";
import { Shield } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
Expand Down
2 changes: 1 addition & 1 deletion src/components/admin/GroupPersonalizationManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export function GroupPersonalizationManager() {
selectedGroup, setSelectedGroup,
groups, groupsLoading,
components, componentsLoading,
locations, techniques, locationTechniques,
locations, techniques, _locationTechniques,
addComponent, updateComponent, deleteComponent,
addLocation, updateLocation, deleteLocation,
addTechnique, updateTechnique, deleteTechnique,
Expand Down
Loading
Loading