mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
fix(lint): see positional query keys and the app tree
Two holes, both proven with a probe that produced zero errors. The rule matched the object properties `queryKey:` and `mutationKey:` and nothing else, so the positional form was never seen — `setQueryData(["bare"], 1)` seeds the same cache cell a `queryKey:` reads, and `getQueryData` / `getQueryState` / the four defaults setters read and write it the same way. Those seven methods are checked at their first argument now, whether the call is bare, a member, or a computed member. The enforced roots were components, hooks, query modules, and files carrying their own "use client", which left src/app out. That is where the server prefetch pages build the keys the client later reads, and they carry no directive, so a server key diverging from the client's was unguarded. src/app is a root now. No violation surfaces on the current tree. The rule header records what it still cannot see: a key assembled into a variable before the call site, and the method-name match, which neither follows a project helper nor knows an unrelated object with the same method name.
This commit is contained in:
1 parent
84437d5f95
commit
1f5115fbf3
2 files changed
+172
-10
No files matched your search
@@ -34,9 +34,32 @@ ruleTester.run("queryKey-factory", rule, {
|
||||
code: 'useQuery({ queryKey: ["fixture"] });',
|
||||
filename: "/repo/src/components/__tests__/example.test.tsx",
|
||||
},
|
||||
// A server module outside every guarded root stays unguarded — the roots
|
||||
// are the scope decision, not an accident of this fixture.
|
||||
{
|
||||
code: 'useQuery({ queryKey: ["server-prefetch"] });',
|
||||
filename: "/repo/src/app/server-page.tsx",
|
||||
code: 'useQuery({ queryKey: ["server-only-lib"] });',
|
||||
filename: "/repo/src/lib/reports/build-report.ts",
|
||||
},
|
||||
// The positional cache API, used correctly: the key still comes from the
|
||||
// factory, so the shape that poisons the cache never arises.
|
||||
{
|
||||
code: "queryClient.setQueryData(queryKeys.example(id), next);",
|
||||
filename: "/repo/src/app/page.tsx",
|
||||
},
|
||||
{
|
||||
code: "const key = queryKeys.example(id); queryClient.setQueryData(key, next);",
|
||||
filename: "/repo/src/components/example.tsx",
|
||||
},
|
||||
// Tests seed caches with literal tuples on purpose; the factory directory
|
||||
// constructs them by definition. Both stay exempt on the positional form
|
||||
// exactly as they are on the object-property form.
|
||||
{
|
||||
code: 'client.setQueryData(["fixture"], 1);',
|
||||
filename: "/repo/src/hooks/__tests__/use-example.test.ts",
|
||||
},
|
||||
{
|
||||
code: 'client.setQueryData(["fixture"], 1);',
|
||||
filename: "/repo/src/lib/query-keys/__fixtures__/seed.ts",
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
@@ -60,5 +83,65 @@ ruleTester.run("queryKey-factory", rule, {
|
||||
filename: "/repo/src/lib/client-state.tsx",
|
||||
errors: [{ messageId: "bareArray" }],
|
||||
},
|
||||
// src/app is where the server prefetch pages build the keys the client
|
||||
// then reads. A key that differs from the client's is a cache miss at
|
||||
// best and a poisoned cell at worst, and without `"use client"` on the
|
||||
// page the rule used to skip the file entirely.
|
||||
{
|
||||
code: 'useQuery({ queryKey: ["server-prefetch"] });',
|
||||
filename: "/repo/src/app/server-page.tsx",
|
||||
errors: [{ messageId: "bareArray" }],
|
||||
},
|
||||
{
|
||||
code: 'queryClient.prefetchQuery({ queryKey: ["dashboard"], queryFn: load });',
|
||||
filename: "/repo/src/app/coach/page.tsx",
|
||||
errors: [{ messageId: "bareArray" }],
|
||||
},
|
||||
// The positional form of the same mistake. `setQueryData` seeds a cache
|
||||
// cell by key exactly as `queryKey:` reads one, so a bare array here is
|
||||
// the same defect written differently.
|
||||
{
|
||||
code: 'queryClient.setQueryData(["bare", "positional"], 1);',
|
||||
filename: "/repo/src/app/coach/page.tsx",
|
||||
errors: [{ messageId: "positionalArray" }],
|
||||
},
|
||||
{
|
||||
code: 'const cached = queryClient.getQueryData(["bare"]);',
|
||||
filename: "/repo/src/hooks/use-example.ts",
|
||||
errors: [{ messageId: "positionalArray" }],
|
||||
},
|
||||
{
|
||||
code: 'queryClient.getQueryState(["bare"]);',
|
||||
filename: "/repo/src/components/example.tsx",
|
||||
errors: [{ messageId: "positionalArray" }],
|
||||
},
|
||||
{
|
||||
code: 'queryClient.setQueryDefaults(["bare"], { staleTime: 0 });',
|
||||
filename: "/repo/src/lib/queries/use-example.ts",
|
||||
errors: [{ messageId: "positionalArray" }],
|
||||
},
|
||||
{
|
||||
code: 'queryClient.setMutationDefaults(["bare"], { retry: 0 });',
|
||||
filename: "/repo/src/components/example.tsx",
|
||||
errors: [{ messageId: "positionalArray" }],
|
||||
},
|
||||
// Destructured off the client, and reached through a computed member —
|
||||
// both spell the same call and both must be seen.
|
||||
{
|
||||
code: 'const { setQueryData } = useQueryClient(); setQueryData(["bare"], 1);',
|
||||
filename: "/repo/src/components/example.tsx",
|
||||
errors: [{ messageId: "positionalArray" }],
|
||||
},
|
||||
{
|
||||
code: 'queryClient["setQueryData"](["bare"], 1);',
|
||||
filename: "/repo/src/components/example.tsx",
|
||||
errors: [{ messageId: "positionalArray" }],
|
||||
},
|
||||
// Both holes in one file, which is the probe that found them.
|
||||
{
|
||||
code: 'queryClient.setQueryData(["bare", "positional"], 1); useQuery({ queryKey: ["bare", "object-property"] });',
|
||||
filename: "/repo/src/app/coach/page.tsx",
|
||||
errors: [{ messageId: "positionalArray" }, { messageId: "bareArray" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -2,16 +2,39 @@
|
||||
* @fileoverview ESLint rule — `queryKey` / `mutationKey` literal-array
|
||||
* factory enforcement across the complete client tree.
|
||||
*
|
||||
* Every TanStack key must come from `src/lib/query-keys/`. The rule flags
|
||||
* object-property `queryKey: [ … ]` and `mutationKey: [ … ]` values in
|
||||
* components, hooks, client query modules, and any source module declaring
|
||||
* top-level `"use client"`.
|
||||
* Every TanStack key must come from `src/lib/query-keys/`. The rule flags a
|
||||
* bare array wherever a key is supplied, in either of the two shapes TanStack
|
||||
* accepts:
|
||||
*
|
||||
* - the object property, `{ queryKey: [ … ] }` / `{ mutationKey: [ … ] }`;
|
||||
* - the FIRST POSITIONAL argument of the QueryClient methods that take a key
|
||||
* directly, `setQueryData([ … ], value)`, `getQueryData([ … ])`, and their
|
||||
* siblings below.
|
||||
*
|
||||
* The positional shape was invisible until 2026-09-03. `setQueryData` seeds
|
||||
* the same cache cell that `queryKey:` reads, so a literal there is the same
|
||||
* defect written differently — and a probe placing one in a component
|
||||
* produced no error at all.
|
||||
*
|
||||
* Enforced roots: `src/components/`, `src/hooks/`, `src/lib/queries/`,
|
||||
* `src/app/`, and any source module declaring top-level `"use client"`.
|
||||
* `src/app/` joined the list on the same date: the server prefetch pages there
|
||||
* build the keys the client subsequently reads, and a server key that differs
|
||||
* from the client's is a cache miss at best and a poisoned cell at worst. They
|
||||
* carry no `"use client"` directive, so nothing guarded them before.
|
||||
*
|
||||
* Accepted call-site shapes include direct factory calls, identifiers already
|
||||
* built from the factory, and conditionals selecting factory calls. Tests and
|
||||
* the factory definition directory are exempt because they intentionally
|
||||
* construct literal fixtures and tuples.
|
||||
*
|
||||
* Limits, so a green run is not read as more than it is: the positional check
|
||||
* matches by METHOD NAME, so a key handed to a helper of the project's own
|
||||
* naming (`seedCache(["…"], v)`) is not seen, and conversely an unrelated
|
||||
* object with a `setQueryData` method would be flagged. A key assembled into a
|
||||
* variable before the call site (`const k = ["a", id]`) is invisible to both
|
||||
* checks — only the literal at the key position is matched.
|
||||
*
|
||||
* @see src/lib/query-keys/
|
||||
* @see src/lib/__tests__/query-keys.test.ts
|
||||
*/
|
||||
@@ -22,7 +45,28 @@ const FACTORY_HOME_DIRECTORY = "src/lib/query-keys";
|
||||
|
||||
// Components and hooks are client-facing by convention. Query modules are
|
||||
// client-transitive even if a future refactor moves the directive to a barrel.
|
||||
const CLIENT_ROOTS = ["src/components/", "src/hooks/", "src/lib/queries/"];
|
||||
// `src/app/` is where the server prefetch pages seed the client's cache, so
|
||||
// their keys have to come from the same factory the client reads from.
|
||||
const GUARDED_ROOTS = [
|
||||
"src/components/",
|
||||
"src/hooks/",
|
||||
"src/lib/queries/",
|
||||
"src/app/",
|
||||
];
|
||||
|
||||
// QueryClient methods whose FIRST argument is a query key or mutation key,
|
||||
// supplied positionally rather than inside an options object. Everything else
|
||||
// on the client (`invalidateQueries`, `prefetchQuery`, …) takes a filters or
|
||||
// options object and is covered by the `queryKey:` property check.
|
||||
const POSITIONAL_KEY_METHODS = new Set([
|
||||
"setQueryData",
|
||||
"getQueryData",
|
||||
"getQueryState",
|
||||
"setQueryDefaults",
|
||||
"getQueryDefaults",
|
||||
"setMutationDefaults",
|
||||
"getMutationDefaults",
|
||||
]);
|
||||
|
||||
function toPosix(filename) {
|
||||
return filename.replace(/\\/g, "/");
|
||||
@@ -46,7 +90,7 @@ function isGuarded(filename, sourceCode) {
|
||||
return false;
|
||||
}
|
||||
if (isTestFile(posix)) return false;
|
||||
if (CLIENT_ROOTS.some((root) => posix.includes(root))) return true;
|
||||
if (GUARDED_ROOTS.some((root) => posix.includes(root))) return true;
|
||||
return sourceCode.ast.body.some(
|
||||
(statement) =>
|
||||
statement.type === "ExpressionStatement" &&
|
||||
@@ -54,6 +98,25 @@ function isGuarded(filename, sourceCode) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The called name, whether the call is bare (`setQueryData(…)`, destructured
|
||||
* off the client), a member (`qc.setQueryData(…)`), or a static computed
|
||||
* member (`qc["setQueryData"](…)`).
|
||||
*/
|
||||
function calleeName(callee) {
|
||||
if (callee.type === "Identifier") return callee.name;
|
||||
if (callee.type !== "MemberExpression") return null;
|
||||
if (!callee.computed && callee.property.type === "Identifier") {
|
||||
return callee.property.name;
|
||||
}
|
||||
if (callee.computed && callee.property.type === "Literal") {
|
||||
return typeof callee.property.value === "string"
|
||||
? callee.property.value
|
||||
: null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @type {import('eslint').Rule.RuleModule} */
|
||||
const queryKeyFactoryRule = {
|
||||
meta: {
|
||||
@@ -66,6 +129,8 @@ const queryKeyFactoryRule = {
|
||||
messages: {
|
||||
bareArray:
|
||||
"Bare-array `{{prop}}: [ … ]` bypasses the queryKeys factory. Import from `@/lib/query-keys` and call `queryKeys.<entry>()` so cache-invalidation bundles stay in lockstep.",
|
||||
positionalArray:
|
||||
"Bare-array key passed to `{{call}}( … )` bypasses the queryKeys factory. It seeds or reads the same cache cell as a `queryKey`, so import from `@/lib/query-keys` and call `queryKeys.<entry>()` instead.",
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
@@ -76,7 +141,7 @@ const queryKeyFactoryRule = {
|
||||
return {};
|
||||
}
|
||||
|
||||
function check(node) {
|
||||
function checkProperty(node) {
|
||||
// `node` is a Property like `queryKey: [ … ]`. Only flag the
|
||||
// bare ArrayExpression form — identifiers, call expressions,
|
||||
// conditional expressions, etc. are all acceptable indirection
|
||||
@@ -98,8 +163,22 @@ const queryKeyFactoryRule = {
|
||||
});
|
||||
}
|
||||
|
||||
function checkPositional(node) {
|
||||
const name = calleeName(node.callee);
|
||||
if (!name || !POSITIONAL_KEY_METHODS.has(name)) return;
|
||||
const first = node.arguments[0];
|
||||
if (!first || first.type !== "ArrayExpression") return;
|
||||
|
||||
context.report({
|
||||
node: first,
|
||||
messageId: "positionalArray",
|
||||
data: { call: name },
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
Property: check,
|
||||
Property: checkProperty,
|
||||
CallExpression: checkPositional,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user