mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
115 lines
4.1 KiB
JavaScript
115 lines
4.1 KiB
JavaScript
/**
|
|
* @fileoverview ESLint rule — client-side requests must route through the
|
|
* typed API-fetch entry points.
|
|
*
|
|
* A raw `fetch(...)` in a client module can hide a same-origin API request
|
|
* behind a variable, endpoint map, or conditional. Checking only literal
|
|
* `/api/...` arguments therefore leaves the envelope and error contract open
|
|
* to bypass. This rule rejects every bare Fetch API call in the client surface,
|
|
* regardless of argument shape.
|
|
*
|
|
* Use `apiGet` / `apiPost` / `apiPut` / `apiPatch` / `apiDelete` for envelope
|
|
* JSON. Use `apiFetchEnvelope` when success metadata is required, and
|
|
* `apiFetchRaw` for deliberate Response-level work such as external requests,
|
|
* downloads, streams, beacons, or manual status branching.
|
|
*
|
|
* Components and hooks are client-facing by convention. Other `src/` modules
|
|
* are guarded when they declare top-level `"use client"`; `src/lib/queries/`
|
|
* remains an explicit client-transitive root. Server-only modules, tests,
|
|
* mocks, and the wrapper implementation are exempt.
|
|
*
|
|
* @see src/lib/api/api-fetch.ts
|
|
*/
|
|
|
|
"use strict";
|
|
|
|
// Files exempt from the rule — the wrapper internals and the test
|
|
// surface. Posix-style suffix matches against the absolute filename via
|
|
// `String#includes`, mirroring the safe-fetch-required convention.
|
|
const EXEMPT_FILES = ["src/lib/api/api-fetch.ts"];
|
|
|
|
// 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/"];
|
|
|
|
function toPosix(filename) {
|
|
return filename.replace(/\\/g, "/");
|
|
}
|
|
|
|
function isTestFile(posix) {
|
|
return (
|
|
/\.test\.[cm]?[jt]sx?$/.test(posix) ||
|
|
/\.spec\.[cm]?[jt]sx?$/.test(posix) ||
|
|
posix.includes("/__tests__/") ||
|
|
posix.includes("/__mocks__/")
|
|
);
|
|
}
|
|
|
|
function isEnforced(filename, sourceCode) {
|
|
const posix = toPosix(filename);
|
|
if (!posix.startsWith("src/") && !posix.includes("/src/")) return false;
|
|
if (EXEMPT_FILES.some((f) => posix.includes(f))) return false;
|
|
if (isTestFile(posix)) return false;
|
|
if (CLIENT_ROOTS.some((root) => posix.includes(root))) return true;
|
|
return sourceCode.ast.body.some(
|
|
(statement) =>
|
|
statement.type === "ExpressionStatement" &&
|
|
statement.directive === "use client",
|
|
);
|
|
}
|
|
|
|
/** @type {import("eslint").Rule.RuleModule} */
|
|
const apiFetchRequiredRule = {
|
|
meta: {
|
|
type: "problem",
|
|
docs: {
|
|
description:
|
|
"Client-side requests must route through the apiFetch wrapper family (src/lib/api/api-fetch.ts).",
|
|
},
|
|
schema: [],
|
|
messages: {
|
|
rawApiFetch:
|
|
"Raw fetch() bypasses the client request contract. Use an apiFetch envelope helper, or apiFetchRaw for deliberate Response-level/external requests.",
|
|
},
|
|
},
|
|
create(context) {
|
|
const filename = context.filename ?? context.getFilename?.();
|
|
const sourceCode = context.sourceCode ?? context.getSourceCode();
|
|
if (!filename || !isEnforced(filename, sourceCode)) {
|
|
return {};
|
|
}
|
|
|
|
function isFetchCallee(callee) {
|
|
// Bare `fetch(...)`.
|
|
if (callee.type === "Identifier" && callee.name === "fetch") return true;
|
|
// `globalThis.fetch(...)` / `window.fetch(...)` / `self.fetch(...)`,
|
|
// including their static computed forms (`window["fetch"](...)`).
|
|
if (
|
|
callee.type === "MemberExpression" &&
|
|
callee.object.type === "Identifier" &&
|
|
(callee.object.name === "globalThis" ||
|
|
callee.object.name === "window" ||
|
|
callee.object.name === "self")
|
|
) {
|
|
const propertyName =
|
|
!callee.computed && callee.property.type === "Identifier"
|
|
? callee.property.name
|
|
: callee.computed && callee.property.type === "Literal"
|
|
? callee.property.value
|
|
: null;
|
|
return propertyName === "fetch";
|
|
}
|
|
return false;
|
|
}
|
|
|
|
return {
|
|
CallExpression(node) {
|
|
if (!isFetchCallee(node.callee)) return;
|
|
context.report({ node, messageId: "rawApiFetch" });
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
module.exports = apiFetchRequiredRule;
|