mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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.
191 lines
6.7 KiB
JavaScript
191 lines
6.7 KiB
JavaScript
/**
|
|
* @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 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
|
|
*/
|
|
|
|
"use strict";
|
|
|
|
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.
|
|
// `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, "/");
|
|
}
|
|
|
|
function isTestFile(posix) {
|
|
return (
|
|
/\.(?:test|spec)\.[cm]?[jt]sx?$/.test(posix) ||
|
|
posix.includes("/__tests__/") ||
|
|
posix.includes("/__mocks__/")
|
|
);
|
|
}
|
|
|
|
function isGuarded(filename, sourceCode) {
|
|
const posix = toPosix(filename);
|
|
if (!posix.startsWith("src/") && !posix.includes("/src/")) return false;
|
|
if (
|
|
posix.startsWith(`${FACTORY_HOME_DIRECTORY}/`) ||
|
|
posix.includes(`/${FACTORY_HOME_DIRECTORY}/`)
|
|
) {
|
|
return false;
|
|
}
|
|
if (isTestFile(posix)) return false;
|
|
if (GUARDED_ROOTS.some((root) => posix.includes(root))) return true;
|
|
return sourceCode.ast.body.some(
|
|
(statement) =>
|
|
statement.type === "ExpressionStatement" &&
|
|
statement.directive === "use client",
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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: {
|
|
type: "problem",
|
|
docs: {
|
|
description:
|
|
"Disallow literal-array `queryKey` / `mutationKey` declarations in client modules. Use `queryKeys.<entry>()` from `src/lib/query-keys/` instead.",
|
|
recommended: false,
|
|
},
|
|
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: [],
|
|
},
|
|
create(context) {
|
|
const filename = context.filename ?? context.getFilename?.();
|
|
const sourceCode = context.sourceCode ?? context.getSourceCode();
|
|
if (!filename || !isGuarded(filename, sourceCode)) {
|
|
return {};
|
|
}
|
|
|
|
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
|
|
// through the factory.
|
|
if (!node.value || node.value.type !== "ArrayExpression") return;
|
|
|
|
const keyName =
|
|
node.key.type === "Identifier"
|
|
? node.key.name
|
|
: node.key.type === "Literal"
|
|
? node.key.value
|
|
: null;
|
|
if (keyName !== "queryKey" && keyName !== "mutationKey") return;
|
|
|
|
context.report({
|
|
node,
|
|
messageId: "bareArray",
|
|
data: { prop: keyName },
|
|
});
|
|
}
|
|
|
|
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: checkProperty,
|
|
CallExpression: checkPositional,
|
|
};
|
|
},
|
|
};
|
|
|
|
module.exports = {
|
|
rules: {
|
|
"queryKey-factory": queryKeyFactoryRule,
|
|
},
|
|
};
|