Merge branch 'v2/main' into v2/chore/2163-agent-skills

Brings in #2192 (react-hooks lint over `core/react/`) and #2194. All the code,
`eslint.config.js` and the new root devDependency merged cleanly; three
conflicts, all in files this PR refactored.

- `.github/copilot-instructions.md` — modify/delete again. Kept deleted; the
  line v2/main added there restates AGENTS.md rules that arrive in AGENTS.md.
- `AGENTS.md` — two of the three incoming hunks merged cleanly, both of them
  RULES, into the sections that survived the refactor: `exhaustive-deps` is
  `error` in every React scope including `core/react/**`, and
  `set-state-in-effect` now covers `core/react/` with `clients/tui` called out
  as the exception. The third was a `validate:core` bullet inside the pre-push
  gate section this PR moved wholesale to docs/quality-gate.md, so its mechanism
  went there instead — including why the plugin has to be a ROOT devDependency
  (Node resolution walks up, not down, so a client-local copy is unreachable).
- `README.md` — the incoming paragraph was added to the quality-gate section
  this README no longer carries. Moved verbatim to docs/quality-gate.md, beside
  the type-aware-lint paragraph it belongs with.

Ran `npm install` for the new devDependency; `npm run validate` passes.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-30 01:04:58 -04:00
commit d2a5e91e4a
15 changed files with 1042 additions and 85 deletions

View file

@ -315,7 +315,7 @@ This exists because the gate's promise — that passing `npm run local:gate` loc
Two consequences worth stating:
- **Do not silence a finding to satisfy the gate.** A warning is now a defect to fix. If a rule genuinely must be waived on a line, use its inline disable comment **with a one-line justification** — the same standard this document sets for `v8 ignore` and for `void` on a floating promise. Widening a `globalIgnores` or dropping a rule to make `lint` pass is not an acceptable fix.
- **A rule left at `warn` still reads wrong in an editor.** The flag makes severity irrelevant to the *gate*, not to the developer looking at a squiggle. `react-hooks/exhaustive-deps` is therefore set to **`error`** in both React scopes (`clients/web`, `clients/tui`) rather than relying on the CLI flag alone. Prefer `error` for any rule you actually intend to enforce.
- **A rule left at `warn` still reads wrong in an editor.** The flag makes severity irrelevant to the *gate*, not to the developer looking at a squiggle. `react-hooks/exhaustive-deps` is therefore set to **`error`** in every React scope (`clients/web`, `clients/tui`, and the root's `core/react/**` block) rather than relying on the CLI flag alone. Prefer `error` for any rule you actually intend to enforce.
## Typescript instructions
@ -405,7 +405,7 @@ Nothing **enforces** the boundary: no path alias keys off it, and the coverage `
const MessageContainer = Paper.withProps({ variant: "message" });
```
- State and effects
- **NEVER reset or re-sync local state from a prop inside a `useEffect`.** `useEffect(() => setX(prop), [prop])` renders once with the stale value, paints it, and only then corrects itself — the user sees the wrong frame and React renders twice. It is an error under `react-hooks/set-state-in-effect`, which the web client's `eslint-plugin-react-hooks` recommended set enforces.
- **NEVER reset or re-sync local state from a prop inside a `useEffect`.** `useEffect(() => setX(prop), [prop])` renders once with the stale value, paints it, and only then corrects itself — the user sees the wrong frame and React renders twice. It is an error under `react-hooks/set-state-in-effect`, which the `eslint-plugin-react-hooks` recommended set enforces for the web client and — since #2192 — for `core/react/` too. (`clients/tui` registers the plugin but deliberately enables only `rules-of-hooks` and `exhaustive-deps`, for the reason its own config states.)
- Use **`useValueChange(value, onChange)`** (`clients/web/src/hooks/useValueChange.ts`) instead. It is React's documented ["adjusting state during render"](https://react.dev/reference/react/useState#storing-information-from-previous-renders) pattern: it compares `value` against the previous render's with `Object.is` and calls `onChange(next)` during render, so React discards the in-progress output and re-runs the component before anything reaches the DOM. It does **not** fire on the first render — seed the dependent state with `useState` instead. Because the comparison is `Object.is`, the value you pass **must be referentially stable** across renders that mean "no change": prefer a primitive key derived from the data (an id, a name, a URI), and otherwise a memoized value. A fresh object/array literal would compare unequal every render and loop.
- The `onChange` you pass runs **during render**, so it must be pure — `setState` calls and nothing else. No fetches, DOM writes, logging, ref mutation, or parent callbacks: a render can be replayed (StrictMode) or abandoned (concurrent React), so external work would run an unpredictable number of times.
- An effect is still the right tool for genuine synchronization with an external system (DOM measurement, `requestAnimationFrame`, subscriptions, timers). The rule is about deriving React state from React props, not about effects in general. `NetworkEntry` shows the split: the reveal's force-open is a state update and uses `useValueChange`, while its `requestAnimationFrame` scroll stays a `useEffect`.

View file

@ -51,17 +51,31 @@ interface Harness {
* Hands the hook's API back to the test. The hook holds refs, so it has to be
* read from inside a rendered component; `setServers` re-renders it with a new
* list the way `useServers` does after a read.
*
* The returned object *delegates* rather than being the API from the first
* render. `resolve` is keyed on the current `servers` (#2192) instead of on a
* ref, so its identity changes with the list — handing back a snapshot would
* give every test a `resolve` closed over the initial list, and `setServers`
* would appear to do nothing.
*/
function harness(initial: ServerEntry[]): Harness {
let api: LastPersistedSettings | undefined;
let latest: LastPersistedSettings | undefined;
function Probe({ servers }: { servers: ServerEntry[] }) {
api = useLastPersistedSettings(servers);
latest = useLastPersistedSettings(servers);
return null;
}
const { rerender } = renderWithMantine(<Probe servers={initial} />);
if (!api) throw new Error("hook did not render");
if (!latest) throw new Error("hook did not render");
const current = () => {
if (!latest) throw new Error("hook did not render");
return latest;
};
return {
api,
api: {
begin: (serverId) => current().begin(serverId),
resolve: (serverId) => current().resolve(serverId),
lastWriteFailed: (serverId) => current().lastWriteFailed(serverId),
},
setServers: (next) => rerender(<Probe servers={next} />),
};
}
@ -222,4 +236,28 @@ describe("useLastPersistedSettings", () => {
setServers([]);
expect(api.resolve("A")).toBeUndefined();
});
it("answers from the list of the render it is called in", () => {
// `useSettingsDraft` seeds its draft during render (#2192), so `resolve`
// is called there. Reading the effect-mirrored `serversRef` would answer
// from the *previous* commit's list on exactly the render where a
// refreshed list arrives — which is the render that seeds the draft.
const before = entry("A", settings({ paginatedLists: false }));
const after = entry("A", settings({ paginatedLists: true }));
const answers: Array<InspectorServerSettings | undefined> = [];
function Probe({ servers }: { servers: ServerEntry[] }) {
const api = useLastPersistedSettings(servers);
answers.push(api.resolve("A"));
return null;
}
const { rerender } = renderWithMantine(<Probe servers={[before]} />);
expect(answers.at(-1)).toBe(before.settings);
answers.length = 0;
rerender(<Probe servers={[after]} />);
expect(answers.length).toBeGreaterThan(0);
for (const answer of answers) expect(answer).toBe(after.settings);
});
});

View file

@ -145,16 +145,33 @@ export function useLastPersistedSettings(
const outcomesRef = useRef<Map<string, { sequence: number; ok: boolean }>>(
new Map(),
);
// Read the list through a ref so a write pairs with the entry as it stands
// when it finishes, not the one captured when it was issued. Mirrored in an
// effect rather than assigned during render: writing a ref mid-render is an
// error under `react-hooks/refs`, and every reader runs from a settled
// promise or an event handler, long after the commit.
// `landed` reads the list through a ref so a write pairs with the entry as
// it stands when it *finishes*, not the one captured when it was issued.
// Mirrored in an effect rather than assigned during render: writing a ref
// mid-render is an error under `react-hooks/refs`, and this reader runs from
// a settled promise, long after the commit. `resolve` deliberately does not
// use it — see there.
const serversRef = useRef(servers);
useEffect(() => {
serversRef.current = servers;
}, [servers]);
// Drop records the list has moved past. This used to happen lazily inside
// `resolve`, which is no longer allowed: `useSettingsDraft` seeds its draft
// during render (#2192) and so calls `resolve` there, where a ref mutation
// would run an unpredictable number of times — React may replay a render
// under StrictMode or abandon it altogether. Nothing depends on the eviction
// happening at any particular moment; it only keeps a dead record from being
// re-checked on every later write for that server, so an effect is the right
// home for it.
useEffect(() => {
for (const [serverId, record] of recordsRef.current) {
if (record.entry !== servers.find((s) => s.id === serverId)) {
recordsRef.current.delete(serverId);
}
}
}, [servers]);
const begin = useCallback((serverId: string): SettingsWrite => {
const sequence = ++nextSequenceRef.current;
const pending = pendingRef.current.get(serverId) ?? new Set<number>();
@ -194,22 +211,25 @@ export function useLastPersistedSettings(
[],
);
// Pure, and over `servers` rather than `serversRef`. Both properties are
// required because `useSettingsDraft` calls this during render to seed its
// draft (#2192): a render must not mutate shared state, and the ref trails
// the value it mirrors by one passive-effect flush, so reading it here would
// answer from the previous commit's list on exactly the render where a
// refreshed list arrives. Reading `servers` also makes the supersession test
// fire a render earlier, which is the direction that matters — a list that
// has genuinely been re-read is what the record is being checked against.
const resolve = useCallback(
(serverId: string): InspectorServerSettings | undefined => {
const entry = serversRef.current.find((s) => s.id === serverId);
const entry = servers.find((s) => s.id === serverId);
const current = recordsRef.current.get(serverId);
if (!current) return entry?.settings;
// Identity, not deep equality: the question is whether the list has been
// re-read since the write, not whether the values happen to match.
if (current.entry !== entry) {
// Superseded — drop it rather than re-checking a dead record on every
// later write for this server.
recordsRef.current.delete(serverId);
return entry?.settings;
}
if (current.entry !== entry) return entry?.settings;
return current.written;
},
[],
[servers],
);
return { begin, resolve, lastWriteFailed };

View file

@ -6,7 +6,8 @@
* persist, synchronous flush-on-close, and reset-on-close behaviors.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { act, render, renderHook } from "@testing-library/react";
import { useEffect } from "react";
import { useClientSettingsDraft } from "@inspector/core/react/useClientSettingsDraft";
interface SettingsShape {
@ -422,4 +423,43 @@ describe("useClientSettingsDraft", () => {
});
expect(result.current.flush).toBe(flushAfterMount);
});
it("never commits the previous session's draft after reopening", () => {
// Same reasoning as the `useSettingsDraft` counterpart: the regression is
// a committed frame, not an end state. `rerender()` flushes the old
// seeding effect before any assertion on `result.current`, so an
// effect-seeded implementation passes those too. Record every commit from
// inside the tree instead, and assert the previous session's draft is
// never committed while the new one is open.
const commits: Array<{ opened: boolean; text: string | null }> = [];
let nextInitial: SettingsShape = { text: "first-session", rows: [] };
function Harness({ opened }: { opened: boolean }) {
const { draft } = useClientSettingsDraft<SettingsShape>({
opened,
resolveInitial: () => nextInitial,
onPersist: vi.fn(),
onError: vi.fn(),
});
useEffect(() => {
commits.push({ opened, text: draft?.text ?? null });
});
return null;
}
const { rerender } = render(<Harness opened={true} />);
expect(commits).toContainEqual({ opened: true, text: "first-session" });
rerender(<Harness opened={false} />);
// Reopen against different saved config — the modal must never paint the
// values from the session that just closed.
nextInitial = { text: "second-session", rows: [] };
commits.length = 0;
rerender(<Harness opened={true} />);
expect(commits.length).toBeGreaterThan(0);
for (const commit of commits) {
expect(commit).toEqual({ opened: true, text: "second-session" });
}
});
});

View file

@ -189,4 +189,155 @@ describe("useEmaIdpLoginState", () => {
).not.toHaveBeenCalled();
expect(result.current.loginState).toBe("none");
});
it("a failed read for a new issuer reports none, not the old issuer's state", async () => {
const exp = Math.floor(Date.now() / 1000) + 3600;
const payload = btoa(JSON.stringify({ exp }))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
vi.mocked(storage.getIdpSession).mockResolvedValue({
idToken: `h.${payload}.s`,
});
const { result, rerender } = renderHook(
({ issuer }: { issuer: string }) =>
useEmaIdpLoginState(storage, issuer, true),
{ initialProps: { issuer: "https://idp.test" } },
);
await waitFor(() => {
expect(result.current.loginState).toBe("logged_in");
});
// The storage backend goes away *and* the issuer changes. The rejection
// must not surface as an unhandled promise, and the new issuer must not
// inherit the previous one's "logged_in" — nobody has authenticated
// against it, and a read that never succeeded cannot say otherwise.
const unhandled = vi.fn();
process.on("unhandledRejection", unhandled);
try {
vi.mocked(storage.getIdpSession).mockRejectedValue(
new Error("storage unreachable"),
);
await act(async () => {
rerender({ issuer: "https://other-idp.test" });
await new Promise((resolve) => setTimeout(resolve, 0));
});
} finally {
process.off("unhandledRejection", unhandled);
}
expect(storage.getIdpSession).toHaveBeenCalledWith(
"https://other-idp.test",
);
expect(unhandled).not.toHaveBeenCalled();
expect(result.current.loginState).toBe("none");
});
it("keeps the last answer when a re-read of the same issuer fails", async () => {
const exp = Math.floor(Date.now() / 1000) + 3600;
const payload = btoa(JSON.stringify({ exp }))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
vi.mocked(storage.getIdpSession).mockResolvedValue({
idToken: `h.${payload}.s`,
});
const { result } = renderHook(() =>
useEmaIdpLoginState(storage, "https://idp.test", true),
);
await waitFor(() => {
expect(result.current.loginState).toBe("logged_in");
});
// A transient storage outage is not evidence that the session ended, so
// the issuer we are still looking at keeps the answer we actually got.
vi.mocked(storage.getIdpSession).mockRejectedValue(
new Error("storage unreachable"),
);
await act(async () => {
await result.current.refresh().catch(() => {});
});
expect(result.current.loginState).toBe("logged_in");
});
it("ignores an overtaken read that resolves last for the same issuer", async () => {
const jwt = (secondsFromNow: number) => {
const payload = btoa(
JSON.stringify({ exp: Math.floor(Date.now() / 1000) + secondsFromNow }),
)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
return `h.${payload}.s`;
};
// Two reads for the *same* issuer overlap, and the older one resolves
// last. Issuer keying cannot tell them apart — both carry this issuer —
// so only the read token stops the superseded answer from committing.
let releaseFirst: () => void = () => {};
const first = new Promise<{ idToken: string }>((resolve) => {
releaseFirst = () => resolve({ idToken: jwt(3600) });
});
vi.mocked(storage.getIdpSession)
.mockReturnValueOnce(first)
.mockResolvedValue({ idToken: jwt(-3600) });
const { result } = renderHook(() =>
useEmaIdpLoginState(storage, "https://idp.test", true),
);
// Second read starts and finishes while the first is still pending.
await act(async () => {
await result.current.refresh();
});
expect(result.current.loginState).toBe("expired");
await act(async () => {
releaseFirst();
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(result.current.loginState).toBe("expired");
});
it("a logout supersedes a read that was already in flight", async () => {
const exp = Math.floor(Date.now() / 1000) + 3600;
const payload = btoa(JSON.stringify({ exp }))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
let releaseRead: () => void = () => {};
const pending = new Promise<{ idToken: string }>((resolve) => {
releaseRead = () => resolve({ idToken: `h.${payload}.s` });
});
vi.mocked(storage.getIdpSession).mockReturnValue(pending);
const { result } = renderHook(() =>
useEmaIdpLoginState(storage, "https://idp.test", true),
);
act(() => {
result.current.logout();
});
// Let the clear settle on its own before releasing the read, so the read
// is unambiguously the *last* writer — without the token it would land on
// top of the clear and report the cleared session as logged in.
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(result.current.loginState).toBe("none");
await act(async () => {
releaseRead();
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(storage.clearIdpSession).toHaveBeenCalledWith("https://idp.test");
expect(result.current.loginState).toBe("none");
});
});

View file

@ -6,7 +6,8 @@
* appear to eat keystrokes. The PUT itself debounces.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { act, render, renderHook } from "@testing-library/react";
import { useEffect } from "react";
import { useSettingsDraft } from "@inspector/core/react/useSettingsDraft";
interface SettingsShape {
@ -375,4 +376,46 @@ describe("useSettingsDraft", () => {
});
expect(result.current.flush).toBe(flushAfterMount);
});
it("never commits the previous target's draft for a new target", () => {
// The regression this pins is a *frame*, not an end state, so it cannot
// be observed through `result.current`: RTL's `act` flushes passive
// effects before the assertion runs, so an effect-seeded implementation
// reaches the same final value and passes any check made after the
// rerender settles. What distinguishes them is what was *committed* on
// the way there — an effect renders once with the previous target's draft
// and paints it before correcting itself.
//
// So record every commit from inside the tree and assert no committed
// frame ever paired "beta" with alpha's draft.
const commits: Array<{ targetId: string; text: string | null }> = [];
const SETTINGS: Record<string, SettingsShape> = {
alpha: { text: "alpha-value", rows: [] },
beta: { text: "beta-value", rows: [] },
};
function Harness({ targetId }: { targetId: string }) {
const { draft } = useSettingsDraft<SettingsShape>({
targetId,
resolveInitial: (id) => SETTINGS[id] ?? EMPTY,
onPersist: vi.fn(),
onError: vi.fn(),
});
useEffect(() => {
commits.push({ targetId, text: draft?.text ?? null });
});
return null;
}
const { rerender } = render(<Harness targetId="alpha" />);
expect(commits).toContainEqual({ targetId: "alpha", text: "alpha-value" });
commits.length = 0;
rerender(<Harness targetId="beta" />);
expect(commits.length).toBeGreaterThan(0);
for (const commit of commits) {
expect(commit).toEqual({ targetId: "beta", text: "beta-value" });
}
});
});

View file

@ -33,25 +33,33 @@ export function useClientSettingsDraft<T>({
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const latestValuesRef = useRef<T | null>(null);
const resolveInitialRef = useRef(resolveInitial);
// Seeded during render rather than in an effect, for the reason spelled
// out on {@link useSettingsDraft}: an effect would paint the previous
// session's draft for one frame before correcting itself. `seeded` is
// tracked separately from `draft` so a `resolveInitial` that legitimately
// returns `null` is not re-seeded on every render.
const [seeded, setSeeded] = useState(false);
if (seeded !== opened) {
setSeeded(opened);
setDraft(opened ? resolveInitial() : null);
}
const onPersistRef = useRef(onPersist);
const onErrorRef = useRef(onError);
const openedRef = useRef(opened);
resolveInitialRef.current = resolveInitial;
onPersistRef.current = onPersist;
onErrorRef.current = onError;
openedRef.current = opened;
// Written in an effect rather than during render (`react-hooks/refs`).
// Every reader — the debounce timer, `flush`, and `onChange` — runs after
// a commit, so a post-commit write is what they need. `latestValuesRef`
// mirrors `draft` here so it is re-seeded (and cleared on close) without a
// second copy of the seeding rule; `onChange` still writes it
// synchronously, because a functional update in the same tick has to see
// the value the previous call produced.
useEffect(() => {
if (!opened) {
setDraft(null);
latestValuesRef.current = null;
return;
}
const initial = resolveInitialRef.current();
setDraft(initial);
latestValuesRef.current = initial;
}, [opened]);
onPersistRef.current = onPersist;
onErrorRef.current = onError;
openedRef.current = opened;
latestValuesRef.current = draft;
});
useEffect(() => {
return () => {

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import type { OAuthStorage } from "../auth/storage.js";
import {
clearEmaIdpSession,
@ -13,6 +13,21 @@ export interface UseEmaIdpLoginStateResult {
logout: () => void;
}
/**
* What a completed read told us, and *which issuer* it was told about.
*
* The pair is stored together rather than as a bare `EmaIdpLoginState`
* because a login state is only ever meaningful for the issuer it was read
* for. With a bare value, changing issuer carries the previous issuer's
* answer over to the new one until a fresh read lands — and if that read
* fails, indefinitely, so the settings surface reports "logged in" for an
* IdP nobody has authenticated against.
*/
interface ReadResult {
issuer: string;
value: EmaIdpLoginState;
}
/**
* Read and clear EMA IdP session state for Client Settings UX.
* Pass `active: true` while the settings surface is open to refresh on open.
@ -23,31 +38,67 @@ export function useEmaIdpLoginState(
active: boolean,
): UseEmaIdpLoginStateResult {
const normalizedIssuer = issuer ? normalizeIdpIssuer(issuer) : "";
const [loginState, setLoginState] = useState<EmaIdpLoginState>("none");
const [read, setRead] = useState<ReadResult>({ issuer: "", value: "none" });
/**
* Monotonic read token — the same device `useInitialConfig` uses. Reads are
* unordered, so an issuer flipped A → B → A can have the first A read
* resolve last; a completion may commit only while it still holds the
* current token. `logout` claims one too, so a read already in flight when
* the session is cleared cannot land on top of the clear.
*/
const generation = useRef(0);
// Derived rather than stored: with no issuer there is nothing to be logged
// in to, and a result read for some *other* issuer says nothing about this
// one. A mismatch therefore reports "none" instead of the previous issuer's
// answer, which is also what makes the failure path below safe.
const loginState: EmaIdpLoginState =
normalizedIssuer && read.issuer === normalizedIssuer ? read.value : "none";
const refresh = useCallback(async () => {
if (!normalizedIssuer) {
setLoginState("none");
return;
}
setLoginState(await getEmaIdpLoginState(storage, normalizedIssuer));
const mine = ++generation.current;
if (!normalizedIssuer) return;
const value = await getEmaIdpLoginState(storage, normalizedIssuer);
if (generation.current !== mine) return;
setRead({ issuer: normalizedIssuer, value });
}, [storage, normalizedIssuer]);
useEffect(() => {
if (!active) return;
void refresh();
// Reading the persisted IdP session *is* the external system this effect
// exists to synchronize with, and no commit happens here: `refresh`
// returns before its first `await` when there is no issuer, and otherwise
// commits in a continuation. That is also why this needs no
// `set-state-in-effect` suppression, where `useServers` and
// `useInitialConfig` do — deriving `loginState` from an issuer-keyed read
// took the last synchronous setState out of the path.
refresh().catch(() => {
// `refresh` does not own its failures: `getEmaIdpLoginState` awaits
// `storage.getIdpSession`, which rejects when the backend is
// unreachable. Swallow it here so it does not surface as an unhandled
// rejection, and commit nothing — the issuer keying above is what makes
// that safe. A failed read for a *new* issuer reports "none", because
// no result for that issuer exists; a failed re-read of the issuer
// already on screen keeps the last answer we actually got, which is the
// right reading of a transient storage outage.
});
}, [active, refresh]);
const logout = useCallback(() => {
if (!normalizedIssuer) return;
// Supersede any read in flight: it was started before the session was
// cleared, so its answer is already wrong by the time it resolves.
const mine = ++generation.current;
void clearEmaIdpSession(storage, normalizedIssuer)
.then(() => {
setLoginState("none");
if (generation.current !== mine) return;
setRead({ issuer: normalizedIssuer, value: "none" });
})
.catch(() => {
// Clearing the persisted IdP session failed (e.g. the storage backend
// is unreachable). Swallow the rejection so it does not surface as an
// unhandled promise, and leave loginState unchanged so the UI keeps
// unhandled promise, and leave the last read in place so the UI keeps
// reflecting the still-present session rather than falsely showing
// signed-out.
});

View file

@ -245,11 +245,25 @@ export function useInitialConfig(
}, [base, authToken, doFetch]);
useEffect(() => {
// `load` commits nothing before its first `await`: it claims a generation
// token, builds headers, and only then fetches, so every setState it makes
// runs in a continuation. `set-state-in-effect` follows the call into an
// async function without modelling the `await` boundary, so it reports
// this even though there is no synchronous commit to cascade from.
// eslint-disable-next-line react-hooks/set-state-in-effect -- no commit before the first await
void load();
return () => {
// Invalidate whatever is in flight: this fires on unmount and on a
// `baseUrl`/`authToken` change, and in both cases a response for the
// old inputs must not commit.
//
// `generation` is a monotonic request token, not a ref to a rendered
// node. `exhaustive-deps`' advice — copy `generation.current` into a
// variable inside the effect and use the copy here — is exactly wrong:
// the cleanup must bump whatever the counter holds *at teardown*, since
// that is what invalidates the load currently in flight. A value
// captured at effect setup would let a superseded response commit.
// eslint-disable-next-line react-hooks/exhaustive-deps -- must read the token at teardown
generation.current++;
};
}, [load]);

View file

@ -231,6 +231,14 @@ export function useServers(opts: UseServersOptions): UseServersResult {
);
useEffect(() => {
// Loading the server list from the backend is the external-system
// synchronization this effect is for, and every list/error commit happens
// after `loadServers()` resolves. `set-state-in-effect` follows the call
// without modelling the `await`, so it reports the whole shape. The
// synchronous part is the pair that *opens* a load — `setLoading(true)`
// and clearing a stale error — which settles in one extra render, and is
// the point of having a loading flag at all.
// eslint-disable-next-line react-hooks/set-state-in-effect -- async load of an external list
void refresh();
}, [refresh]);

View file

@ -28,11 +28,12 @@ export interface UseSettingsDraftOptions<T> {
targetId: string | undefined;
/**
* Resolves the initial draft for a given server id at the moment the
* modal opens. Called once per target — must be stable enough that
* the caller doesn't accidentally provoke re-initialization while
* the user is editing. (We do not include this function in the
* effect deps; it's read through a ref so callers don't have to
* `useCallback` it.)
* modal opens. Called once per target, **during render** — so it must
* be pure: a render can be replayed under StrictMode or abandoned by
* concurrent React, which would run any side effect an unpredictable
* number of times. Its identity is never a dependency of anything, so
* callers do not have to `useCallback` it, and re-creating it on every
* render cannot re-seed an in-progress edit.
*/
resolveInitial: (id: string) => T;
/** Persist the draft. Called from the debounced flush and from `flush`. */
@ -73,31 +74,41 @@ export function useSettingsDraft<T>({
const [draft, setDraft] = useState<T | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
// Seeding happens during render, not in an effect. An effect keyed on
// `targetId` renders once with the *previous* target's draft, paints
// it, and only then corrects itself — so opening the modal on a second
// server briefly shows the first one's values. React's documented
// "adjusting state when a prop changes" pattern discards the
// in-progress render instead, so nothing stale reaches the DOM.
// (`clients/web` spells the same thing `useValueChange`; `core/` cannot
// import from a client.)
//
// `seededFor` is compared rather than `draft`, because "already seeded
// for this target" and "the draft happens to be null" are different
// states: a caller whose `resolveInitial` legitimately yields `null`
// would otherwise be re-seeded on every render.
const [seededFor, setSeededFor] = useState<string | undefined>(undefined);
if (seededFor !== targetId) {
setSeededFor(targetId);
setDraft(targetId ? resolveInitial(targetId) : null);
}
// Read callbacks (and the current draft) through refs so the consumer
// doesn't have to `useCallback` them, and so the returned `flush`
// identity is stable across keystrokes. The effect that
// re-initializes the draft must depend on `targetId` only — anything
// else in its deps risks resetting an in-progress edit when the
// parent re-renders for unrelated reasons (e.g. a background
// server-list refresh).
const resolveInitialRef = useRef(resolveInitial);
// identity is stable across keystrokes. Written in an effect rather
// than during render (`react-hooks/refs`): a render can be replayed or
// abandoned, and every reader here — the debounce timer and `flush` —
// runs after a commit, so a post-commit write is what they need.
const onPersistRef = useRef(onPersist);
const onErrorRef = useRef(onError);
const draftRef = useRef<T | null>(null);
const targetIdRef = useRef(targetId);
resolveInitialRef.current = resolveInitial;
onPersistRef.current = onPersist;
onErrorRef.current = onError;
draftRef.current = draft;
targetIdRef.current = targetId;
useEffect(() => {
if (!targetId) {
setDraft(null);
return;
}
setDraft(resolveInitialRef.current(targetId));
}, [targetId]);
onPersistRef.current = onPersist;
onErrorRef.current = onError;
draftRef.current = draft;
targetIdRef.current = targetId;
});
// Unmount cleanup — a stale timer firing after unmount is harmless
// today (the persist call still goes through to the backend) but

View file

@ -29,7 +29,7 @@ That is the readable half, and prose rots. The enforced half is `scripts/lib/wor
| Script | What it does |
| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `npm run validate` | Runs the four durable guards first — `verify:format-coverage` (every tracked source file is format-gated), `verify:skills` (every `.claude/skills` manifest parses and declares its invocation mode; the **model-invoked** ones also carry eval cases — the name-only skills intentionally have none), `verify:typecheck-coverage` (every tracked TS file lands in a tsconfig project), `verify:dep-lockstep` (no dependency reaching one `tsc` program from two installs skews across them) — then `test:scripts` (the guards' own parser unit tests), then `validate:core` (the shared `core/` `format:check` + `lint` gate), then per client: `format:check` + `lint` + **`typecheck`** (cli/tui/launcher; web typechecks via `tsc -b` inside its `build`) + `build` + fast unit tests. The quick inner-loop check. |
| `npm run validate` | Runs the four durable guards first — `verify:format-coverage` (every tracked source file is format-gated), `verify:skills` (every `.claude/skills` manifest parses and declares its invocation mode; the **model-invoked** ones also carry eval cases — the name-only skills intentionally have none), `verify:typecheck-coverage` (every tracked TS file lands in a tsconfig project), `verify:dep-lockstep` (no dependency reaching one `tsc` program from two installs skews across them) — then `test:scripts` (the guards' own parser unit tests), then `validate:core` (the shared `core/` `format:check` + `lint` gate — including the `core/react/**` React-hooks block added by #2192, whose plugin is a **root** devDependency because a client-local copy is unreachable from the root: Node resolution walks up, not down), then per client: `format:check` + `lint` + **`typecheck`** (cli/tui/launcher; web typechecks via `tsc -b` inside its `build`) + `build` + fast unit tests. The quick inner-loop check. |
| `npm run coverage` | The **per-file ≥90% gate** (lines/statements/functions/branches) under v8 instrumentation, per client. CI-enforced. For web this also runs the integration project and covers the shared `core/` runtime (including `core/json` and `core/client`). |
| `npm run smoke` | End-to-end smokes through the built launcher (`--help` dispatch + prod cli/tui/web — the TUI one runs under a **pseudoterminal** and asserts the Ink app is still running two seconds after its first frame, not merely that it painted one, [#2147](https://github.com/modelcontextprotocol/inspector/issues/2147)), plus three headless-browser smokes: a boot smoke that runs the prod web bundle and asserts a clean first render (no uncaught error — sync exception or unhandled rejection, how a Node built-in reaching the browser bundle manifests), and an **MCP Apps** smoke (`smoke:web:app`) that drives connect → open app → `data-app-status="ready"` against a composable App server, covering the sandbox proxy and UI-protocol bridge, and an **app-rendered elicitation** smoke (`smoke:web:elicit`) that drives one end to end — call the tool, answer inside the sandboxed app, see the app's `ElicitResult` reach the server — and then the same tool against a server that never advertised the capability, which must fall back to the native elicitation form. Those three take their engine from `SMOKE_BROWSER` — see [Supported browsers](#supported-browsers). A fourth, **`smoke:web:tabs`**, drives the core tabs against one server on one browser launch — Tools runs a tool and asserts its `structuredContent` section, Resources reads a resource with templates listed, Prompts fetches one ([#2148](https://github.com/modelcontextprotocol/inspector/issues/2148)) — asserting the `data-*` contract in [the web README](../clients/web/README.md#core-tab-automation-contract) rather than visible copy. It is **Chromium-only**: those tabs are ordinary React and Mantine, so unlike the sandbox they are not engine-sensitive. |
| `npm run verify:build-gate` | Runs a real `vite build` with a Node built-in forced into the browser graph and asserts the build **fails** via the #1769 gate (which turns Vite's browser-externalization warning into a hard error). Guards against the warning phrasing drifting in a Vite bump and silently disabling the gate. Part of `npm run local:gate` and of GitHub CI. |
@ -47,6 +47,8 @@ Per-client scripts exist too (`validate:web`, `coverage:cli`, `smoke:tui`, …),
**Linting is type-aware.** All five ESLint scopes (`clients/{web,cli,tui,launcher}` plus the root `core/` + shared gate) enable `@typescript-eslint/no-floating-promises` at `error`, so a promise that is neither awaited, returned, `.catch(…)`-terminated, nor explicitly discarded with `void` fails `lint` — and therefore `validate` ([#1959](https://github.com/modelcontextprotocol/inspector/issues/1959)). The rule needs type information, so each scope's config names a parser project; the root scope's is **`tsconfig.lint.json`**, a lint-only project covering `core/**`, `test-servers/src/**`, and `vitest.shared.mts`, which have no tsconfig of their own. It emits nothing and changes no typecheck — but a new first-party TS location added to the root lint scope must be added to its `include`. See **TypeScript instructions** in [`AGENTS.md`](../AGENTS.md) for when `void` is acceptable.
**The React hook rules cover `core/react/` too.** Every copy of `eslint-plugin-react-hooks` used to be client-local — a devDependency of `clients/web` and of `clients/tui`, each reachable only from that client's own `eslint .` — so the whole hook rule set, `set-state-in-effect` included, had never looked at `core/react/`, a directory of nothing but React hooks ([#2192](https://github.com/modelcontextprotocol/inspector/issues/2192)). "Neither surface has JSX" was the stated reason and it was never a good one: the rules judge hooks, not JSX. The plugin is now a root devDependency as well, applied by a `core/react/**` block in the root `eslint.config.js` — scoped there rather than to all of `core/**`, since the rules key off the `use`-prefix convention and a non-React `useFoo` helper elsewhere would be judged as a hook it is not.
**And lint has no warning tier.** Every `lint` script runs with `--max-warnings 0`, so a warning fails `validate` exactly as an error does ([#2085](https://github.com/modelcontextprotocol/inspector/issues/2085)) — a `warn`-level `react-hooks/exhaustive-deps` finding had otherwise let a stale-closure bug pass the mandatory pre-push gate and reach review. Fix the finding rather than silencing it; if a rule genuinely must be waived, use its inline disable comment with a one-line justification.
For the full testing rules — the ≥90% per-file gate, where test files live, the unit vs. integration vs. storybook projects, and the `v8 ignore` policy — see [`AGENTS.md`](../AGENTS.md).

View file

@ -1,5 +1,6 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import tseslint from "typescript-eslint";
import { defineConfig, globalIgnores } from "eslint/config";
@ -10,22 +11,26 @@ import { defineConfig, globalIgnores } from "eslint/config";
// isomorphic TypeScript (browser-side OAuth + Node backends + shared runtime);
// the shared surface is Node-only tooling/tests.
//
// ⚠️ There is no React plugin here, and "neither surface has JSX" is NOT a
// sufficient reason for that — `core/react/` is a directory of React hooks,
// which the hook rules judge whether or not any JSX is present. So the whole
// `eslint-plugin-react-hooks` set, `set-state-in-effect` included, has never
// looked at them: it is a devDependency of `clients/web` only, and Node
// resolution walks up rather than down, so the root cannot even load it.
// That is why the prop-into-effect re-sync #1955 fixed sat here unreported
// while the identical shape is an error over in `clients/web/src`. Closing the
// gap means adding the plugin at the root and taking what it then reports —
// measured during #1955 as 15 findings, every one of them in a hook that issue
// did not convert (the two settings-draft hooks and the three async fetch
// hooks) and none in the 16 it did. Tracked on its own rather than folded in.
// `core/react/` gets the React hook rules, and "neither surface has JSX" was
// never a reason to withhold them — the hook rules judge hooks, not JSX, and
// that directory is nothing but hooks. Until #2192 the whole
// `eslint-plugin-react-hooks` set, `set-state-in-effect` included, had never
// looked at them: every copy of the plugin was *client-local* (a devDependency
// of `clients/web` and of `clients/tui`), and Node resolution walks up rather
// than down, so the root could reach neither. That is how the prop-into-effect
// re-sync #1955 fixed sat here unreported while the identical shape was an
// error over in `clients/web/src`. The plugin is now a root devDependency too,
// and the block below applies it.
//
// Type-aware linting for the two blocks below. `no-floating-promises` needs
// type information, which `tseslint.configs.recommended` (the non-type-aware
// set) does not provide — so each block adds a parser project (#1959).
// Scoped to `core/react/**` rather than all of `core/**`: everything else
// there is plain isomorphic TypeScript, and the rules key off the `use`-prefix
// naming convention, so a non-React `useFoo` helper elsewhere in `core/` would
// be judged as a hook it is not.
//
// Type-aware linting for the `core/` and shared surfaces below.
// `no-floating-promises` needs type information, which
// `tseslint.configs.recommended` (the non-type-aware set) does not provide —
// so a block of its own adds a parser project (#1959).
//
// `tsconfig.lint.json` exists for this and nothing else: the root has no
// tsconfig of its own (`core/` is typechecked through `clients/web`'s
@ -81,6 +86,17 @@ export default defineConfig([
},
rules: sharedRules,
},
{
// #2192. `exhaustive-deps` is promoted from the recommended set's `warn`
// for the same reason `clients/web` promotes it (#2085): `--max-warnings 0`
// already fails the gate on it, and matching the severity keeps an editor
// squiggle honest rather than depending on a CLI flag.
files: ["core/react/**/*.{ts,tsx}"],
extends: [reactHooks.configs.flat.recommended],
rules: {
"react-hooks/exhaustive-deps": "error",
},
},
{
files: [
"test-servers/src/**/*.{ts,tsx,mts,cts}",

554
package-lock.json generated
View file

@ -40,6 +40,7 @@
"@eslint/js": "^10.0.1",
"@types/proper-lockfile": "^4.1.4",
"eslint": "^10.8.0",
"eslint-plugin-react-hooks": "^7.1.1",
"express": "^5.2.1",
"globals": "^17.7.0",
"prettier": "3.8.4",
@ -63,6 +64,266 @@
"node": ">=18"
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.29.7",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/compat-data": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/core": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
"@babel/helper-compilation-targets": "^7.29.7",
"@babel/helper-module-transforms": "^7.29.7",
"@babel/helpers": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/template": "^7.29.7",
"@babel/traverse": "^7.29.7",
"@babel/types": "^7.29.7",
"@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
"gensync": "^1.0.0-beta.2",
"json5": "^2.2.3",
"semver": "^6.3.1"
},
"engines": {
"node": ">=6.9.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/babel"
}
},
"node_modules/@babel/core/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
}
},
"node_modules/@babel/generator": {
"version": "7.29.8",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz",
"integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.8",
"@babel/types": "^7.29.8",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-compilation-targets": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/compat-data": "^7.29.7",
"@babel/helper-validator-option": "^7.29.7",
"browserslist": "^4.24.0",
"lru-cache": "^5.1.1",
"semver": "^6.3.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-compilation-targets/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
}
},
"node_modules/@babel/helper-globals": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
"integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-module-imports": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
"integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/traverse": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-module-transforms": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-module-imports": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7",
"@babel/traverse": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0"
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-option": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helpers": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/template": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.29.8",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
"integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.8"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/template": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/traverse": {
"version": "7.29.8",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz",
"integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.8",
"@babel/helper-globals": "^7.29.7",
"@babel/parser": "^7.29.8",
"@babel/template": "^7.29.7",
"@babel/types": "^7.29.8",
"debug": "^4.3.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/types": {
"version": "7.29.8",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
"integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@emnapi/core": {
"version": "2.0.0-alpha.3",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz",
@ -300,6 +561,56 @@
"url": "https://github.com/sponsors/nzakas"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/remapping": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@modelcontextprotocol/client": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0.tgz",
@ -1417,6 +1728,19 @@
"node": "18 || 20 || >=22"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.11.20",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz",
"integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.cjs"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/body-parser": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
@ -1467,6 +1791,40 @@
"node": "20 || >=22"
}
},
"node_modules/browserslist": {
"version": "4.28.8",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
"integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/browserslist"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"baseline-browser-mapping": "^2.11.12",
"caniuse-lite": "^1.0.30001809",
"electron-to-chromium": "^1.5.402",
"node-releases": "^2.0.53",
"update-browserslist-db": "^1.3.0"
},
"bin": {
"browserslist": "cli.js"
},
"engines": {
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/bundle-name": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
@ -1520,6 +1878,27 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001810",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz",
"integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "CC-BY-4.0"
},
"node_modules/chalk": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
@ -1633,6 +2012,13 @@
"node": ">= 0.6"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true,
"license": "MIT"
},
"node_modules/convert-to-spaces": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz",
@ -1793,6 +2179,13 @@
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/electron-to-chromium": {
"version": "1.5.416",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.416.tgz",
"integrity": "sha512-K6bvB2BjnNrugtIih6ewlbBI9DXa976jIdiIlRLHhBoEI9a4JaQjjHyF+A1IQI543aQYR4LnmOrT/K5fZj0aPA==",
"dev": true,
"license": "ISC"
},
"node_modules/emoji-regex": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
@ -1861,6 +2254,16 @@
"tests/types"
]
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@ -1935,6 +2338,26 @@
}
}
},
"node_modules/eslint-plugin-react-hooks": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz",
"integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "^7.24.4",
"@babel/parser": "^7.24.4",
"hermes-parser": "^0.25.1",
"zod": "^3.25.0 || ^4.0.0",
"zod-validation-error": "^3.5.0 || ^4.0.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0"
}
},
"node_modules/eslint-scope": {
"version": "9.1.2",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
@ -2326,6 +2749,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
"integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/get-east-asian-width": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
@ -2443,6 +2876,23 @@
"node": ">= 0.4"
}
},
"node_modules/hermes-estree": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
"integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==",
"dev": true,
"license": "MIT"
},
"node_modules/hermes-parser": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz",
"integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"hermes-estree": "0.25.1"
}
},
"node_modules/hono": {
"version": "4.13.1",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.1.tgz",
@ -2715,6 +3165,26 @@
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
"dev": true,
"license": "MIT",
"bin": {
"jsesc": "bin/jsesc"
},
"engines": {
"node": ">=6"
}
},
"node_modules/json-buffer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
@ -2742,6 +3212,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
"dev": true,
"license": "MIT",
"bin": {
"json5": "lib/cli.js"
},
"engines": {
"node": ">=6"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@ -3043,6 +3526,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
"dev": true,
"license": "ISC",
"dependencies": {
"yallist": "^3.0.2"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -3163,6 +3656,16 @@
"node": ">= 0.6"
}
},
"node_modules/node-releases": {
"version": "2.0.54",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz",
"integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@ -4186,6 +4689,37 @@
"node": ">= 0.8"
}
},
"node_modules/update-browserslist-db": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz",
"integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/browserslist"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"escalade": "^3.2.0",
"picocolors": "^1.1.1"
},
"bin": {
"update-browserslist-db": "cli.js"
},
"peerDependencies": {
"browserslist": ">= 4.21.0"
}
},
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@ -4404,6 +4938,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true,
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
@ -4456,6 +4997,19 @@
"peerDependencies": {
"zod": "^3.25.28 || ^4"
}
},
"node_modules/zod-validation-error": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz",
"integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
}
}
}

View file

@ -117,6 +117,7 @@
"@eslint/js": "^10.0.1",
"@types/proper-lockfile": "^4.1.4",
"eslint": "^10.8.0",
"eslint-plugin-react-hooks": "^7.1.1",
"express": "^5.2.1",
"globals": "^17.7.0",
"prettier": "3.8.4",