feat(ci): add ESLint to both workspaces with a defect-only CI gate (#60)
SonarQube Analysis / sonarqube (pull_request) Successful in 3m24s
Tests / lint (pull_request) Successful in 1m54s
Tests / backend-unit (pull_request) Successful in 43s
Tests / frontend-e2e (pull_request) Failing after 8m27s

TypeScript's strict mode checks types and nothing else, so nothing enforced the React hook rules, the SonarJS rules, or unhandled-promise detection. Adds a flat config per workspace, a lint script in each, and a lint job in tests.yml.

The rule selection is the substance of this change and is measured rather than guessed. A full-strength config reports 435 violations across 50 files, but 325 of those are the no-unsafe-* family from recommendedTypeChecked, every one downstream of pool.query() returning any rows and untyped fetch responses. Typing those boundaries is the whole of #65, so enabling the rules here would ship a linter whose output is three-quarters another issue's backlog — the reliable way to teach everyone to ignore lint output. This enables recommended plus the two type-aware rules that catch defects rather than describe type debt, which leaves 110 findings.

Both configs downgrade every preset to a warning and then list the error rules explicitly at the bottom, so the CI gate is readable in one place instead of inferred from four presets' defaults. Errors are no-floating-promises, no-misused-promises, rules-of-hooks, exhaustive-deps and jsx-a11y/alt-text; everything else warns. No --max-warnings flag is needed because ESLint already exits non-zero on errors and zero on warnings. no-misused-promises runs with checksVoidReturn.attributes false, since onClick={async () => ...} is idiomatic React and safe when the handler catches its own errors — at the default it flags every antd button in the admin screens, 25 of its 28 hits, and a rule that is 89% noise gets switched off within a week.

The 37 errors this surfaced were mostly not the mechanical fix they looked like. The plan assumed the 30 floating promises were fire-and-forget loaders that already handled their own failures, which was true of the one sampled when writing the design and false for most of the rest: Admin, Categories, Customers, Tags, Settings, Account and CustomerAuthContext all had no rejection handling at all, so `void` on them would have hidden real failures rather than annotated deliberate ones. Each of those loaders now catches and surfaces the failure before the call site voids it. The CustomerAuthContext one was a live bug — a rejected fetchMe left loading true forever, rendering as a permanent spinner instead of a signed-out page.

Admin's load became a useCallback so its effect can name it honestly rather than suppress the dependency, Categories' drop handler was split so the function antd receives returns void as its type says, and Cart's effect now names refreshCartContext, which is a useCallback with an empty dependency list and so cannot re-run it. The only disable added is in asyncRoute, where returning a promise where Express expects void is the entire point of the wrapper and the promise cannot reject.

Two of the issue's premises did not survive measurement, both recorded in the spec: exhaustive-deps flags 2 cases rather than the 10 inferred from empty dependency arrays, and the backend was already clean on the defect rules because #59 wrapped every async route.

Verified: lint, build, 78 unit, 134 integration and 83 e2e all pass in both workspaces, and the CI gate was confirmed to fail by introducing a deliberate violation rather than by assuming the job is wired correctly.

Closes #60
This commit is contained in:
2026-08-19 14:08:37 -05:00
parent 3cb6a42fb3
commit c058b3ed2e
21 changed files with 5351 additions and 65 deletions
+87
View File
@@ -0,0 +1,87 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import reactHooks from 'eslint-plugin-react-hooks';
import jsxA11y from 'eslint-plugin-jsx-a11y';
import sonarjs from 'eslint-plugin-sonarjs';
import globals from 'globals';
// Named `.mjs` because this package is CommonJS — `eslint.config.js` would be
// parsed as CJS and the imports above would fail.
//
// Policy: every preset is downgraded to advisory, and the rules that actually
// fail the build are listed once at the bottom. That way the CI gate is
// readable in one place rather than inferred from four presets' defaults.
// The reasoning behind the split, and the measurements it rests on, are in
// docs/superpowers/specs/2026-08-19-eslint-design.md.
/**
* Rewrites a preset's enabled rules to `warn`, preserving each rule's options.
* Rules the preset explicitly turned off stay off — a preset that disables a
* rule means it, and flipping those to `warn` turns the whole of SonarJS's
* opt-in catalogue (file headers, naming conventions) into daily noise.
*/
const advisory = (config) => ({
...config,
rules: Object.fromEntries(
Object.entries(config.rules ?? {}).map(([rule, level]) => {
const severity = Array.isArray(level) ? level[0] : level;
if (severity === 'off' || severity === 0) return [rule, level];
return [rule, Array.isArray(level) ? ['warn', ...level.slice(1)] : 'warn'];
})
),
});
export default tseslint.config(
{ ignores: ['dist/**', 'playwright-report/**', 'test-results/**', 'eslint.config.mjs'] },
...[
js.configs.recommended,
...tseslint.configs.recommended,
sonarjs.configs.recommended,
// v7 of this plugin ships the React Compiler rule set alongside the two
// classic rules. This is React 18 with no compiler in the build, so those
// extra rules advise against a stricter model than the code was written
// for — worth seeing (`purity` catches a real `Date.now()` in render), not
// worth failing a build over.
reactHooks.configs.flat['recommended-latest'],
{ rules: jsxA11y.flatConfigs.recommended.rules },
].map(advisory),
{ plugins: { 'jsx-a11y': jsxA11y } },
{
// `tests/` and the Playwright specs are deliberately out of scope for now:
// tsconfig.json only includes `src`, so type-aware linting has no program
// for them, and widening it is a separate change with its own count.
files: ['src/**/*.{ts,tsx}'],
languageOptions: {
globals: globals.browser,
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
// Hooks called conditionally break React outright.
'react-hooks/rules-of-hooks': 'error',
// The stale-closure rule. PR #11 fixed a cart badge that did not update
// after account creation, which is precisely what this catches.
'react-hooks/exhaustive-deps': 'error',
// An unawaited promise fails silently — the same class of defect as the
// unwrapped async routes in #59, and what the project's Playwright notes
// already warn about for missing `await`.
'@typescript-eslint/no-floating-promises': 'error',
// `attributes: false` because `onClick={async () => ...}` is idiomatic
// React and safe when the handler catches its own errors. Left at the
// default this rule flags every antd button in the admin screens — 25 of
// its 28 hits here — and a rule that is 89% noise gets switched off.
'@typescript-eslint/no-misused-promises': [
'error',
{ checksVoidReturn: { attributes: false } },
],
// A storefront image with no alt text is unusable in a screen reader, and
// the fix is one attribute.
'jsx-a11y/alt-text': 'error',
},
}
);
+3497
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -5,6 +5,7 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint src",
"test:e2e": "playwright test"
},
"dependencies": {
@@ -18,13 +19,20 @@
"remark-gfm": "^4.0.0"
},
"devDependencies": {
"@eslint/js": "^9.39.5",
"@playwright/test": "^1.47.0",
"@types/pg": "^8.23.1",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"eslint": "^9.39.5",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-sonarjs": "^4.2.0",
"globals": "^17.11.0",
"pg": "^8.23.0",
"typescript": "^5.5.4",
"typescript-eslint": "^8.67.0",
"vite": "^5.4.0"
}
}
+3 -3
View File
@@ -92,7 +92,7 @@ export default function App() {
return;
}
setLoading(true);
const timer = setTimeout(load, FILTER_DEBOUNCE_MS);
const timer = setTimeout(() => void load(), FILTER_DEBOUNCE_MS);
return () => clearTimeout(timer);
}, [load, awaitingAuth, needsFavoritesAuth]);
@@ -103,7 +103,7 @@ export default function App() {
// Adding to cart flips an item to reserved, and the filter options' price
// bounds shift as inventory changes.
const reload = useCallback(() => {
load();
void load();
fetchFilterOptions().then(setOptions).catch(() => undefined);
}, [load]);
@@ -171,7 +171,7 @@ export default function App() {
showIcon
message="Couldn't load items"
description="The server didn't return the catalogue. This is usually temporary."
action={<Button size="small" onClick={() => { setLoading(true); load(); }}>Retry</Button>}
action={<Button size="small" onClick={() => { setLoading(true); void load(); }}>Retry</Button>}
/>
) : needsFavoritesAuth ? (
<Empty description="Sign in to see the items you have favorited">
+26 -18
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
Layout, Table, Button, Form, Input, InputNumber, Upload, Modal,
Space, Tag, Typography, Switch, message, Image as AntImage, theme, Tabs,
@@ -43,24 +43,32 @@ function Inventory() {
// arrive out of order and an older one can repaint stale rows over a newer
// result. Only the most recently issued request is allowed to set state.
const latestRequest = useRef(0);
const load = (active: ItemFilters = filters) => {
// useCallback rather than a plain function so the effect below can depend on
// it honestly: a function rebuilt every render would either loop forever in
// the dependency array or have to be suppressed out of it.
const load = useCallback((active: ItemFilters = filters) => {
const seq = ++latestRequest.current;
return fetchAdminItems(active).then(rows => {
if (seq === latestRequest.current) setItems(rows);
});
};
return fetchAdminItems(active)
.then(rows => {
if (seq === latestRequest.current) setItems(rows);
})
// Without this the table simply keeps showing whatever it had, so a
// failed refetch after a save looks identical to a save that did not
// change anything.
.catch(() => message.error('Could not load items'));
}, [filters]);
// The item form needs the current category tree and tag list; both change
// from the sibling tabs, so they're refetched whenever the modal opens.
const loadOptions = () => Promise.all([
const loadOptions = useCallback(() => Promise.all([
fetchAdminCategories().then(setCategories),
fetchAdminTags().then(setTags)
]);
]).catch(() => message.error('Could not load categories and tags')), []);
// Refetch whenever the filters change — filtering is server-side so the
// result stays correct regardless of how many items exist.
useEffect(() => { load(filters); }, [filters]);
useEffect(() => { loadOptions(); }, []);
useEffect(() => { void load(filters); }, [load, filters]);
useEffect(() => { void loadOptions(); }, [loadOptions]);
function applyFilters(next: ItemFilters) { setFilters(next); }
function clearFilters() { setFilters(EMPTY_FILTERS); }
@@ -70,7 +78,7 @@ function Inventory() {
form.resetFields();
setFileList([]);
setDescription('');
loadOptions();
void loadOptions();
setModalOpen(true);
}
@@ -84,7 +92,7 @@ function Inventory() {
});
setFileList([]);
setDescription(item.description || '');
loadOptions();
void loadOptions();
setModalOpen(true);
}
@@ -114,8 +122,8 @@ function Inventory() {
message.success(editingItem ? 'Item updated' : 'Item added');
setModalOpen(false);
load();
loadOptions();
void load();
void loadOptions();
}
// Status changes silently did nothing on failure — the row simply stayed put
@@ -127,7 +135,7 @@ function Inventory() {
message.error(`Couldn't ${label}${(err as Error).message}`);
return;
}
load();
void load();
}
async function handleDelete(id: number) {
@@ -138,7 +146,7 @@ function Inventory() {
return;
}
message.success('Item deleted');
load();
void load();
}
async function handleDeleteImage(itemId: number, imageId: number) {
@@ -149,7 +157,7 @@ function Inventory() {
return;
}
message.success('Image removed');
load();
void load();
setEditingItem(prev => prev && prev.id === itemId
? { ...prev, images: prev.images.filter(img => img.id !== imageId) }
: prev);
@@ -162,7 +170,7 @@ function Inventory() {
render: (images: Item['images']) =>
images[0] ? (
<span style={{ position: 'relative', display: 'inline-block' }}>
<img src={images[0].image_path} style={{ width: 60 }} />
<img src={images[0].image_path} alt="" style={{ width: 60 }} />
{images.length > 1 && (
<Tag style={{ position: 'absolute', bottom: -4, right: -8, fontSize: 10 }}>+{images.length - 1}</Tag>
)}
+11 -5
View File
@@ -74,10 +74,11 @@ export default function Categories() {
expansionInitialized.current = true;
}
})
.catch(() => message.error('Could not load categories'))
.finally(() => setLoading(false));
}
useEffect(() => { load(); }, []);
useEffect(() => { void load(); }, []);
function openNew(parent: number | null) {
setEditing(null);
@@ -110,7 +111,7 @@ export default function Categories() {
// Reveal where the category just landed instead of filing it out of sight.
if (parentId !== null) expand(parentId);
setModalOpen(false);
load();
void load();
} catch (err) {
message.error((err as Error).message);
}
@@ -137,7 +138,7 @@ export default function Categories() {
`Deleted ${result.deleted_categories} categor${result.deleted_categories === 1 ? 'y' : 'ies'}, ` +
`uncategorized ${result.uncategorized_items} item${result.uncategorized_items === 1 ? '' : 's'}`
);
load();
void load();
}
});
}
@@ -145,7 +146,7 @@ export default function Categories() {
// Dragging a node onto another reparents it. The server rejects a move that
// would put a node beneath its own descendant, so a refused drop just
// reloads the unchanged tree.
const handleDrop: TreeProps['onDrop'] = async (info) => {
const dropCategory = async (info: Parameters<NonNullable<TreeProps['onDrop']>>[0]) => {
const dragId = Number(info.dragNode.key);
const dropId = Number(info.node.key);
const dropToGap = !info.dropToGap ? dropId : findNode(tree, dropId)?.parent_id ?? null;
@@ -157,9 +158,14 @@ export default function Categories() {
} catch (err) {
message.error((err as Error).message);
}
load();
void load();
};
// Kept separate from dropCategory so the handler antd receives returns void,
// as its type says. An async function here would hand Tree a promise it never
// awaits.
const handleDrop: TreeProps['onDrop'] = (info) => void dropCategory(info);
function toTreeData(nodes: CategoryNode[]): DataNode[] {
return nodes.map((node) => ({
key: node.id,
+10 -6
View File
@@ -22,10 +22,14 @@ export default function Customers() {
const [togglingId, setTogglingId] = useState<number | null>(null);
function load() {
return fetchCustomers().then(rows => { setCustomers(rows); setLoading(false); });
return fetchCustomers()
.then(rows => { setCustomers(rows); setLoading(false); })
// Without this a failed load leaves the spinner up forever, with no
// indication that anything went wrong.
.catch(() => { setLoading(false); message.error('Could not load customers'); });
}
useEffect(() => { load(); }, []);
useEffect(() => { void load(); }, []);
async function openDetail(id: number) {
setDrawerOpen(true);
@@ -83,7 +87,7 @@ export default function Customers() {
setTogglingId(null);
}
message.success(disabling ? 'Account disabled' : 'Account re-enabled');
load();
void load();
}
});
}
@@ -103,7 +107,7 @@ export default function Customers() {
// Refresh both the popup and the row count behind it, so the count can't
// disagree with the list it opened from.
setReserved(await fetchReservedItems(reservedFor.id));
load();
void load();
}
const columns: ColumnsType<CustomerSummary> = [
@@ -151,7 +155,7 @@ export default function Customers() {
// The whole row opens the customer drawer, so without this the
// click reaches both handlers and the drawer opens behind the
// reserved-items dialog.
onClick={(event) => { event.stopPropagation(); openReserved(customer); }}
onClick={(event) => { event.stopPropagation(); void openReserved(customer); }}
>
{count} item{Number(count) === 1 ? '' : 's'}
</Button>
@@ -208,7 +212,7 @@ export default function Customers() {
loading={loading}
dataSource={customers}
columns={columns}
onRow={row => ({ onClick: () => openDetail(row.id), style: { cursor: 'pointer' } })}
onRow={row => ({ onClick: () => void openDetail(row.id), style: { cursor: 'pointer' } })}
pagination={{ pageSize: 10 }}
/>
+7 -4
View File
@@ -9,10 +9,13 @@ export default function Settings() {
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchAdminSettings().then(s => {
form.setFieldsValue({ cartExpiryHours: s.cartExpiryHours });
setLoading(false);
});
fetchAdminSettings()
.then(s => {
form.setFieldsValue({ cartExpiryHours: s.cartExpiryHours });
})
// A rejection here used to leave the form spinning indefinitely.
.catch(() => message.error('Could not load settings'))
.finally(() => setLoading(false));
}, [form]);
async function handleSave() {
+4 -3
View File
@@ -32,10 +32,11 @@ export default function Tags() {
setLoading(true);
return fetchAdminTags()
.then(setTags)
.catch(() => message.error('Could not load tags'))
.finally(() => setLoading(false));
}
useEffect(() => { load(); }, []);
useEffect(() => { void load(); }, []);
function openNew() {
setEditing(null);
@@ -68,7 +69,7 @@ export default function Tags() {
message.success('Tag added');
}
setModalOpen(false);
load();
void load();
} catch (err) {
message.error((err as Error).message);
}
@@ -83,7 +84,7 @@ export default function Tags() {
onOk: async () => {
await deleteTag(tag.id);
message.success('Tag deleted');
load();
void load();
}
});
}
+10 -5
View File
@@ -45,14 +45,16 @@ export default function Cart() {
function loadAll() {
setLoading(true);
Promise.all([fetchCart(), fetchAddresses(), fetchConfig()]).then(([cartData, addrs, cfg]) => {
void Promise.all([fetchCart(), fetchAddresses(), fetchConfig()]).then(([cartData, addrs, cfg]) => {
setItems(cartData.items);
setAddresses(addrs);
const def = addrs.find(a => a.is_default);
setSelectedAddressId(def ? def.id : (addrs[0]?.id ?? null));
setConfig(cfg);
setLoading(false);
});
})
// Anything here failing left the cart on a spinner with no explanation.
.catch(() => message.error('Could not load your cart'))
.finally(() => setLoading(false));
}
useEffect(() => { if (customer) loadAll(); }, [customer]);
@@ -129,7 +131,10 @@ export default function Cart() {
message.error('Checkout error, please try again.');
}
}).render('#paypal-cart-buttons');
}, [paypalReady, selectedAddressId, items.length]);
// refreshCartContext is a useCallback with an empty dependency list, so
// naming it here cannot re-run this effect and re-render the PayPal
// buttons — it just makes the dependency honest.
}, [paypalReady, selectedAddressId, items.length, refreshCartContext]);
if (authLoading || loading) return <Spin style={{ margin: 48 }} />;
@@ -154,7 +159,7 @@ export default function Cart() {
renderItem={item => (
<List.Item actions={[<Button danger size="small" onClick={() => handleRemove(item.item_id)}>Remove</Button>]}>
<List.Item.Meta
avatar={item.images[0] && <img src={item.images[0].image_path} style={{ width: 60, height: 60, objectFit: 'cover' }} />}
avatar={item.images[0] && <img src={item.images[0].image_path} alt="" style={{ width: 60, height: 60, objectFit: 'cover' }} />}
title={item.name}
description={
<Text type={new Date(item.expires_at).getTime() - Date.now() < 60 * 60 * 1000 ? 'danger' : 'secondary'}>
+4 -4
View File
@@ -56,7 +56,7 @@ export default function ItemCard({ item, onChanged }: Props) {
setAuthModalOpen(true);
return;
}
doAddToCart();
void doAddToCart();
}
// Asked only once a customer has actually favorited something, so the reason
@@ -110,7 +110,7 @@ export default function ItemCard({ item, onChanged }: Props) {
setAuthModalOpen(true);
return;
}
doToggleFavorite();
void doToggleFavorite();
}
const hasMultiple = item.images.length > 1;
@@ -183,8 +183,8 @@ export default function ItemCard({ item, onChanged }: Props) {
onClose={() => setAuthModalOpen(false)}
onSuccess={() => {
setAuthModalOpen(false);
if (pendingAction === 'favorite') doToggleFavorite();
else doAddToCart();
if (pendingAction === 'favorite') void doToggleFavorite();
else void doAddToCart();
}}
/>
</Card>
+1 -1
View File
@@ -19,7 +19,7 @@ export default function Account({ onClose }: Props) {
const navigate = useNavigate();
useEffect(() => {
if (customer) fetchMyOrders().then(setOrders);
if (customer) void fetchMyOrders().then(setOrders).catch(() => message.error('Could not load your orders'));
}, [customer]);
useEffect(() => {
@@ -25,7 +25,12 @@ export function CustomerAuthProvider({ children }: { children: React.ReactNode }
const refresh = useCallback(() => {
setLoading(true);
fetchMe().then(c => { setCustomer(c); setLoading(false); });
// A rejection here previously left `loading` true forever, which renders
// as a permanent spinner rather than a signed-out page.
void fetchMe()
.then(c => setCustomer(c))
.catch(() => setCustomer(null))
.finally(() => setLoading(false));
}, []);
useEffect(() => { refresh(); }, [refresh]);