mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
walkSourceFiles now lists each indexed folder (src, e2e, prisma) once per worker and filters it by prefix, readSource and readSourceFolder read through the same memoised file cache, and readSourceText is the cached read for guards that read files themselves. The guards project shares a module graph per worker, so a file is read once however many guards ask for it. sourcesContaining adds the substring pre-filter for sweeps that look for a few markers. Every guard over 1.5 s now reads through it; anything outside the indexed folders is read from disk as before.
222 lines
8.2 KiB
TypeScript
222 lines
8.2 KiB
TypeScript
/**
|
|
* Every web request to an AI route is behind the `ai` capability map, and no
|
|
* web code reads the retired switch projection.
|
|
*
|
|
* Since v1.39 the web renders every AI surface from one answer, the `ai`
|
|
* block on `GET /api/auth/me`, read through `useAiCapability`. Two ways back
|
|
* to the old behaviour are frozen here:
|
|
*
|
|
* 1. Reading `GET /api/feature-flags` (or the deleted `useFeatureFlags`
|
|
* hook). The projection knew only the operator's switches, not the
|
|
* person's opt-out, the provider or the consent, and it failed open, so
|
|
* AI chrome flashed and fired refusals before the switch set arrived.
|
|
* 2. A request to an AI route from a module nobody gates. Each such module
|
|
* is on `AI_REQUEST_SITES` with the capability that decides it and the
|
|
* file that reads that capability; the guard proves the file does read
|
|
* it. A new module that requests an AI route without an entry fails by
|
|
* name.
|
|
*
|
|
* Matchers are whitespace-tolerant (`useAiCapability ( "coach" )` counts) and
|
|
* run on comment-stripped source, so prose naming a route or the hook is not
|
|
* a hit. Each discovery asserts a non-zero count: a sweep that finds nothing
|
|
* would agree with every allowlist, so an empty match set fails instead.
|
|
*
|
|
* Its honest limit: it recognises a request by the route literal. A route
|
|
* built from pieces (`"/api/insights/" + name`) or read from a constant in
|
|
* another module would slip it; `AI_ROUTE_PATTERNS` lists the literal shapes
|
|
* the tree uses today.
|
|
*/
|
|
import path from "node:path";
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import type { AiCapabilityKey } from "@/lib/ai/capabilities/types";
|
|
|
|
import {
|
|
readSourceText,
|
|
stripComments,
|
|
walkSourceFiles,
|
|
} from "./helpers/source-files";
|
|
|
|
const SRC = path.resolve(__dirname, "..");
|
|
|
|
const NON_TEST_FILES = walkSourceFiles(SRC, { floor: 1500 }).filter(
|
|
(rel) =>
|
|
!rel.startsWith("app/api/") &&
|
|
!rel.startsWith("generated/") &&
|
|
!rel.includes("__tests__/") &&
|
|
!/\.test\.tsx?$/.test(rel),
|
|
);
|
|
|
|
/**
|
|
* Web code for the request sweep: pages, components, hooks. `lib/` is left
|
|
* out of that sweep because it mixes server modules (the jobs and route
|
|
* helpers name the same routes) with client ones; the retired-projection
|
|
* check below walks it too, minus the switch loader and the API contract,
|
|
* which describe the route rather than read it.
|
|
*/
|
|
const CLIENT_FILES = NON_TEST_FILES.filter((rel) => !rel.startsWith("lib/"));
|
|
|
|
const FEATURE_FLAG_SWEEP = NON_TEST_FILES.filter(
|
|
(rel) =>
|
|
!rel.startsWith("lib/feature-flags/") &&
|
|
!rel.startsWith("lib/openapi/") &&
|
|
// The deprecation registry names the route to date its retirement; it
|
|
// reads nothing from it.
|
|
rel !== "lib/http/deprecation.ts",
|
|
);
|
|
|
|
const read = (rel: string) =>
|
|
stripComments(readSourceText(path.join(SRC, rel)));
|
|
|
|
/** Request literals of routes that call a model or serve model text. */
|
|
const AI_ROUTE_PATTERNS: readonly RegExp[] = [
|
|
/["'`]\/api\/insights\/generate["'`?]/,
|
|
/["'`]\/api\/insights\/\$\{\s*metric\s*\}-status/,
|
|
/["'`]\/api\/insights\/metric-status\?/,
|
|
/["'`]\/api\/insights\/biomarker-assessment\?/,
|
|
/["'`]\/api\/insights\/medication-compliance-status\?/,
|
|
/["'`]\/api\/insights\/coach\/nudge-status["'`]/,
|
|
/["'`]\/api\/insights\/chat["'`]/,
|
|
/["'`]\/api\/insights\/chat\/fenced["'`]/,
|
|
/["'`]\/api\/medications\/extract["'`]/,
|
|
/["'`]\/api\/labs\/ocr\/capability["'`]/,
|
|
/["'`]\/api\/documents\/inbound\/capability["'`]/,
|
|
/["'`]\/api\/ai\/test["'`]/,
|
|
];
|
|
|
|
interface RequestSite {
|
|
capability: AiCapabilityKey | null;
|
|
/** The file that reads the capability and keeps the request from firing. */
|
|
gate: string;
|
|
/** Why, when `capability` is null. */
|
|
reason?: string;
|
|
}
|
|
|
|
/**
|
|
* Every client module that requests an AI route, keyed by path under src/.
|
|
* Adding a request site means adding it here with the capability that decides
|
|
* it; the guard then checks the gate file really reads that capability.
|
|
*/
|
|
const AI_REQUEST_SITES: Record<string, RequestSite> = {
|
|
"components/insights/use-insights-advisor.ts": {
|
|
capability: "briefing",
|
|
gate: "components/insights/use-insights-advisor.ts",
|
|
},
|
|
"components/settings/ai/runtime-actions-row.tsx": {
|
|
capability: "briefing",
|
|
gate: "components/settings/ai/runtime-actions-row.tsx",
|
|
},
|
|
"hooks/use-insight-status.ts": {
|
|
capability: "statusText",
|
|
gate: "hooks/use-insight-status.ts",
|
|
},
|
|
"app/insights/medications/page.tsx": {
|
|
capability: "statusText",
|
|
gate: "app/insights/medications/page.tsx",
|
|
},
|
|
"components/insights/layout-coach-fab.tsx": {
|
|
capability: "coach",
|
|
gate: "components/insights/layout-coach-fab.tsx",
|
|
},
|
|
"app/coach/page-client.tsx": {
|
|
capability: "coach",
|
|
gate: "app/coach/page-client.tsx",
|
|
},
|
|
// The chat hooks run only inside the drawer, which mounts only while the
|
|
// Coach is available, and on /coach, which leaves while it is not.
|
|
"components/insights/coach-panel/use-coach.ts": {
|
|
capability: "coach",
|
|
gate: "components/insights/layout-coach-mount.tsx",
|
|
},
|
|
"components/medications/scheduling/natural-language-extractor.tsx": {
|
|
capability: "medicationExtract",
|
|
gate: "components/medications/wizard/medication-wizard-dialog.tsx",
|
|
},
|
|
"components/labs/use-ocr-extract.ts": {
|
|
capability: "labsOcr",
|
|
gate: "app/labs/page.tsx",
|
|
},
|
|
"components/documents/use-document-assist.ts": {
|
|
capability: "documentAi",
|
|
gate: "components/documents/document-detail-sheet.tsx",
|
|
},
|
|
};
|
|
|
|
/** Requests to AI routes that no capability decides, with the reason. */
|
|
const UNGATED_BY_DESIGN: Record<string, string> = {
|
|
// `POST /api/ai/test` checks a provider before anything can be on; the
|
|
// route answers the operator's master switch itself with a typed refusal.
|
|
"components/settings/ai/runtime-actions-row.tsx:/api/ai/test":
|
|
"the connection test is how a provider gets checked",
|
|
};
|
|
|
|
function readsCapability(source: string, key: AiCapabilityKey): boolean {
|
|
return new RegExp(
|
|
`useAiCapability(?:Answer)?\\s*\\(\\s*["']${key}["']\\s*\\)`,
|
|
).test(source);
|
|
}
|
|
|
|
describe("AI surfaces read the capability map", () => {
|
|
it("walks the web tree", () => {
|
|
expect(CLIENT_FILES.length).toBeGreaterThan(800);
|
|
});
|
|
|
|
it("no web code reads GET /api/feature-flags or the retired hook", () => {
|
|
expect(FEATURE_FLAG_SWEEP.length).toBeGreaterThan(1500);
|
|
const offenders = FEATURE_FLAG_SWEEP.filter((rel) => {
|
|
const src = read(rel);
|
|
return (
|
|
/["'`]\/api\/feature-flags/.test(src) ||
|
|
/["']@\/hooks\/use-feature-flags["']/.test(src) ||
|
|
/\buseFeatureFlags\s*\(/.test(src)
|
|
);
|
|
});
|
|
expect(offenders).toEqual([]);
|
|
});
|
|
|
|
it("every request to an AI route comes from a registered, gated module", () => {
|
|
const discovered = CLIENT_FILES.filter((rel) => {
|
|
const src = read(rel);
|
|
return AI_ROUTE_PATTERNS.some((pattern) => pattern.test(src));
|
|
});
|
|
// A sweep that finds nothing agrees with every registry.
|
|
expect(discovered.length).toBeGreaterThanOrEqual(8);
|
|
|
|
const unregistered = discovered.filter((rel) => !(rel in AI_REQUEST_SITES));
|
|
expect(
|
|
unregistered,
|
|
"Module(s) request an AI route but are not on AI_REQUEST_SITES",
|
|
).toEqual([]);
|
|
|
|
const stale = Object.keys(AI_REQUEST_SITES).filter(
|
|
(rel) => !discovered.includes(rel),
|
|
);
|
|
expect(stale, "AI_REQUEST_SITES entries that no longer request").toEqual(
|
|
[],
|
|
);
|
|
});
|
|
|
|
it("each registered gate reads the capability it names", () => {
|
|
const missing = Object.entries(AI_REQUEST_SITES)
|
|
.filter(([, site]) => site.capability !== null)
|
|
.filter(([, site]) => !readsCapability(read(site.gate), site.capability!))
|
|
.map(([rel, site]) => `${rel} -> ${site.gate} (${site.capability})`);
|
|
expect(missing).toEqual([]);
|
|
});
|
|
|
|
it("the ungated exceptions are real", () => {
|
|
for (const key of Object.keys(UNGATED_BY_DESIGN)) {
|
|
const [rel, route] = key.split(":");
|
|
expect(read(rel)).toContain(route);
|
|
}
|
|
});
|
|
|
|
it("the account payload's ai block has web readers beyond the hook", () => {
|
|
const readers = CLIENT_FILES.filter((rel) =>
|
|
/useAiCapability(?:Answer|Map)?\s*\(/.test(read(rel)),
|
|
).filter((rel) => rel !== "hooks/use-ai-capability.ts");
|
|
expect(readers.length).toBeGreaterThanOrEqual(20);
|
|
});
|
|
});
|