mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Add src/lib/queries/ to the api-fetch-required ENFORCED_ROOTS so the rule matches its own header comment, then route the two flagged query hooks through apiGet instead of hand-rolling the envelope unwrap.
142 lines
5.0 KiB
JavaScript
142 lines
5.0 KiB
JavaScript
/**
|
|
* @fileoverview ESLint rule — client-side `/api/...` calls must route
|
|
* through the typed `apiFetch` wrapper.
|
|
*
|
|
* `src/lib/api/api-fetch.ts` owns the `.ok` check, the
|
|
* `{ data, error, meta? }` envelope unwrap, and the `ApiError` shape
|
|
* (message via `readError`, `status`, error-side `meta`). A raw
|
|
* `fetch("/api/…")` in a component re-implements that contract by hand
|
|
* — the exact drift this wrapper retired — so this rule flags it at
|
|
* authoring time and in CI.
|
|
*
|
|
* Complements `healthlog/safe-fetch-required`: that rule covers
|
|
* OUTBOUND egress (absolute / variable-sourced URLs) and exempts
|
|
* same-origin relative paths; this rule covers exactly those same-origin
|
|
* `/api/...` paths inside the client surface.
|
|
*
|
|
* Allowed:
|
|
* - `apiFetch` / verb helpers / `apiFetchEnvelope` / `apiFetchRaw` —
|
|
* distinct identifiers, never match.
|
|
* - calls inside `src/lib/api/api-fetch.ts` — the wrapper's own `fetch`.
|
|
* - test files (`*.test.ts(x)`, `__tests__/`, `__mocks__/`) — they
|
|
* mock or assert against `fetch` directly.
|
|
*
|
|
* The check is a syntactic `CallExpression` match against a bare
|
|
* `fetch(` callee (Identifier `fetch`) and `globalThis.fetch(` /
|
|
* `window.fetch(` / `self.fetch(` member forms, where the first
|
|
* argument is a string literal or template literal whose head starts
|
|
* with `/api/`. Variable-sourced or absolute URLs are the
|
|
* safe-fetch-required rule's territory and stay out of scope here.
|
|
*
|
|
* @see src/lib/api/api-fetch.ts
|
|
* @see eslint-plugins/healthlog/safe-fetch-required.js
|
|
*/
|
|
|
|
"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"];
|
|
|
|
// Enforce across the client-facing source roots. `src/lib/` stays out:
|
|
// server-side lib code never self-calls `/api/` routes, and the
|
|
// safe-fetch rule already covers its outbound calls. `src/hooks/` is
|
|
// client-only by construction (v1.16.4) — its `/api/` reads route
|
|
// through the wrapper like every component. `src/lib/queries/` is the
|
|
// one `src/lib/` exception: it holds client-only TanStack-Query hooks
|
|
// (every file is `"use client"`), so its `/api/` reads route through the
|
|
// wrapper too.
|
|
const ENFORCED_ROOTS = [
|
|
"src/components/",
|
|
"src/app/",
|
|
"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) {
|
|
const posix = toPosix(filename);
|
|
if (!ENFORCED_ROOTS.some((root) => posix.includes(root))) return false;
|
|
if (EXEMPT_FILES.some((f) => posix.includes(f))) return false;
|
|
if (isTestFile(posix)) return false;
|
|
return true;
|
|
}
|
|
|
|
/** @type {import("eslint").Rule.RuleModule} */
|
|
const apiFetchRequiredRule = {
|
|
meta: {
|
|
type: "problem",
|
|
docs: {
|
|
description:
|
|
"Same-origin /api/ calls must route through the apiFetch wrapper (src/lib/api/api-fetch.ts).",
|
|
},
|
|
schema: [],
|
|
messages: {
|
|
rawApiFetch:
|
|
'Raw fetch("/api/…") re-implements the envelope unwrap + error contract by hand. Import { apiFetch } (or a verb helper / apiFetchRaw) from "@/lib/api/api-fetch" and call it instead.',
|
|
},
|
|
},
|
|
create(context) {
|
|
const filename = context.filename ?? context.getFilename?.();
|
|
if (!filename || !isEnforced(filename)) {
|
|
return {};
|
|
}
|
|
|
|
function isFetchCallee(callee) {
|
|
// Bare `fetch(...)`.
|
|
if (callee.type === "Identifier" && callee.name === "fetch") return true;
|
|
// `globalThis.fetch(...)` / `window.fetch(...)` / `self.fetch(...)`.
|
|
if (
|
|
callee.type === "MemberExpression" &&
|
|
!callee.computed &&
|
|
callee.property.type === "Identifier" &&
|
|
callee.property.name === "fetch" &&
|
|
callee.object.type === "Identifier" &&
|
|
(callee.object.name === "globalThis" ||
|
|
callee.object.name === "window" ||
|
|
callee.object.name === "self")
|
|
) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function isApiPath(arg) {
|
|
if (!arg) return false;
|
|
const isApiHead = (head) => head.startsWith("/api/");
|
|
// `fetch("/api/…")`
|
|
if (arg.type === "Literal" && typeof arg.value === "string") {
|
|
return isApiHead(arg.value);
|
|
}
|
|
// `fetch(`/api/${id}`)` — inspect the template's first cooked chunk.
|
|
if (arg.type === "TemplateLiteral" && arg.quasis.length > 0) {
|
|
return isApiHead(arg.quasis[0].value.cooked ?? "");
|
|
}
|
|
return false;
|
|
}
|
|
|
|
return {
|
|
CallExpression(node) {
|
|
if (!isFetchCallee(node.callee)) return;
|
|
if (!isApiPath(node.arguments[0])) return;
|
|
context.report({ node, messageId: "rawApiFetch" });
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
module.exports = apiFetchRequiredRule;
|