mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
test(labs): check the labs gate per read, and only the labs module's
The guard asked whether a file that reads the lab table mentions the gate anywhere, so one ungated read in a file with a gated one passed, and whenModuleOn for any module counted as the labs gate. It now walks the syntax tree: each read must sit in a function that asks the labs module, or in a private helper whose every caller in the file does, or be listed by file and function with its reason. whenModuleOn counts only with "labs".
This commit is contained in:
1 parent
70df2009d9
commit
698312863d
1 file changed
+228
-45
@@ -14,49 +14,197 @@
|
||||
*
|
||||
* ## What it proves and what it does not
|
||||
*
|
||||
* Every module under the paths that build model input (`src/lib/ai`,
|
||||
* `src/lib/insights`, `src/lib/jobs`, `src/lib/mcp`) that reads the lab
|
||||
* table either asks the labs module itself (`isModuleEnabled(…, "labs")`,
|
||||
* the `correlation:LAB_DRAWS` surface, or the MCP `whenModuleOn` wrapper) or
|
||||
* is listed below with the reason its read cannot reach a prompt. A new
|
||||
* reader fails here until it does one or the other. The data routes are not
|
||||
* in scope: a module decides what a model is given, not what the owner can
|
||||
* read (`CLAUDE.md`, "An AI capability gates model work").
|
||||
* Every lab read under the paths that build model input (`src/lib/ai`,
|
||||
* `src/lib/insights`, `src/lib/jobs`, `src/lib/mcp`) either sits in a
|
||||
* function that asks the labs module itself (`isModuleEnabled(…, "labs")`,
|
||||
* the `correlation:LAB_DRAWS` surface, or the MCP `whenModuleOn(…, "labs")`
|
||||
* wrapper), or sits in a private helper of its file whose every caller in
|
||||
* that file does, or is listed below by file and function with the reason
|
||||
* its rows cannot reach a prompt. A new reader fails here until it does one
|
||||
* of those. The data routes are not in scope: a module decides what a model
|
||||
* is given, not what the owner can read (`CLAUDE.md`, "An AI capability
|
||||
* gates model work").
|
||||
*
|
||||
* It is grep-shaped: it sees a read, not where the rows go, and a reader in
|
||||
* another directory is outside it. The behaviour is pinned per path by the
|
||||
* module-off cases in the suites of the readers themselves.
|
||||
* The check is per read, not per file. It used to ask whether a file that
|
||||
* reads the table mentions the gate anywhere, so one ungated read in a file
|
||||
* with a gated one passed, and `whenModuleOn` for any module counted as the
|
||||
* labs gate. It still sees a read and the function around it, not where the
|
||||
* rows go: a gate in the function is taken to cover the read, and a reader
|
||||
* in another directory is outside it. The behaviour is pinned per path by
|
||||
* the module-off cases in the suites of the readers themselves.
|
||||
*/
|
||||
import ts from "typescript";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { readSourceFolder, stripComments } from "./helpers/source-files";
|
||||
|
||||
const ROOTS = ["src/lib/ai", "src/lib/insights", "src/lib/jobs", "src/lib/mcp"];
|
||||
|
||||
/** A read of the lab table: the Prisma delegate or raw SQL naming it. */
|
||||
const LAB_READ =
|
||||
/\.labResult\s*\.\s*(?:findMany|findFirst|findUnique|count|aggregate|groupBy)\s*\(|\blab_results\b/;
|
||||
const READ_METHODS = new Set([
|
||||
"findMany",
|
||||
"findFirst",
|
||||
"findUnique",
|
||||
"count",
|
||||
"aggregate",
|
||||
"groupBy",
|
||||
]);
|
||||
|
||||
/** Raw SQL naming the lab table. */
|
||||
const LAB_TABLE = /\blab_results\b/;
|
||||
|
||||
/** What counts as asking the labs module. */
|
||||
const LABS_GATE =
|
||||
/isModuleEnabled\(\s*[\w.]+\s*,\s*"labs"\s*\)|"correlation:LAB_DRAWS"|whenModuleOn\(/;
|
||||
/isModuleEnabled\(\s*[\w.]+\s*,\s*"labs"\s*\)|"correlation:LAB_DRAWS"|whenModuleOn\(\s*[\w.]+\s*,\s*"labs"/;
|
||||
|
||||
/** Readers whose rows never reach a model, each with the reason. */
|
||||
/**
|
||||
* Reads whose rows never reach a model, by `file#top-level name`, each with
|
||||
* the reason.
|
||||
*/
|
||||
const NOT_MODEL_INPUT: Record<string, string> = {
|
||||
"src/lib/jobs/lab-biomarker-backfill.ts":
|
||||
"src/lib/jobs/lab-biomarker-backfill.ts#runLabBiomarkerBackfillForUser":
|
||||
"links stored rows to their biomarker; no model is involved",
|
||||
"src/lib/jobs/soft-delete-retention.ts":
|
||||
"src/lib/jobs/lab-biomarker-backfill.ts#enqueueBootTimeLabBiomarkerBackfill":
|
||||
"finds the accounts whose rows need linking; no model is involved",
|
||||
"src/lib/jobs/soft-delete-retention.ts#SOFT_DELETE_RETENTION":
|
||||
"purges rows past the trash window; no model is involved",
|
||||
"src/lib/insights/correlation-channel-series.ts":
|
||||
"`fetchLabDraws`: both callers gate it, the Coach on correlation:LAB_DRAWS and the correlations data route on the same surface",
|
||||
"src/lib/mcp/rich-reads.ts":
|
||||
"`getLabHistory`: reached only through the MCP get_labs tool, which runs behind whenModuleOn",
|
||||
"src/lib/ai/coach/tools/availability.ts":
|
||||
"src/lib/insights/correlation-channel-series.ts#fetchLabDraws":
|
||||
"both callers gate it, the Coach on correlation:LAB_DRAWS and the correlations data route on the same surface",
|
||||
"src/lib/mcp/rich-reads.ts#getLabHistory":
|
||||
'reached only through the MCP get_labs tool, which runs behind whenModuleOn(ctx, "labs")',
|
||||
"src/lib/ai/coach/tools/availability.ts#readTableBounds":
|
||||
"counts lab rows for the Coach's empty-read answer; the only lab subject comes from get_labs, which answers module_disabled first",
|
||||
"src/lib/ai/coach/tools/availability.ts#holdsRowsBefore":
|
||||
"whether lab rows predate the lookback, for the same empty-read answer and the same get_labs subject",
|
||||
};
|
||||
|
||||
function labReaders(): Array<{ path: string; gated: boolean }> {
|
||||
const out: Array<{ path: string; gated: boolean }> = [];
|
||||
type FunctionNode = ts.SignatureDeclaration & { body?: ts.Node };
|
||||
|
||||
function isReadCall(node: ts.Node): boolean {
|
||||
if (!ts.isCallExpression(node)) return false;
|
||||
const callee = node.expression;
|
||||
if (!ts.isPropertyAccessExpression(callee)) return false;
|
||||
if (!READ_METHODS.has(callee.name.text)) return false;
|
||||
const target = callee.expression;
|
||||
return (
|
||||
ts.isPropertyAccessExpression(target) && target.name.text === "labResult"
|
||||
);
|
||||
}
|
||||
|
||||
function isTableLiteral(node: ts.Node): boolean {
|
||||
return (
|
||||
(ts.isStringLiteral(node) ||
|
||||
ts.isNoSubstitutionTemplateLiteral(node) ||
|
||||
ts.isTemplateHead(node) ||
|
||||
ts.isTemplateMiddle(node) ||
|
||||
ts.isTemplateTail(node)) &&
|
||||
LAB_TABLE.test(node.text)
|
||||
);
|
||||
}
|
||||
|
||||
function enclosingFunctions(node: ts.Node): FunctionNode[] {
|
||||
const out: FunctionNode[] = [];
|
||||
for (let at = node.parent; at; at = at.parent) {
|
||||
if (ts.isFunctionLike(at)) out.push(at as FunctionNode);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The statement directly under the file that holds `node`. */
|
||||
function topLevelStatement(node: ts.Node): ts.Statement | null {
|
||||
let at: ts.Node = node;
|
||||
while (at.parent && !ts.isSourceFile(at.parent)) at = at.parent;
|
||||
return at.parent ? (at as ts.Statement) : null;
|
||||
}
|
||||
|
||||
function declaredName(statement: ts.Statement): string | null {
|
||||
if (ts.isFunctionDeclaration(statement)) return statement.name?.text ?? null;
|
||||
if (ts.isVariableStatement(statement)) {
|
||||
const [first] = statement.declarationList.declarations;
|
||||
return first && ts.isIdentifier(first.name) ? first.name.text : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isExported(statement: ts.Statement): boolean {
|
||||
return (
|
||||
ts.canHaveModifiers(statement) &&
|
||||
(ts.getModifiers(statement) ?? []).some(
|
||||
(m) => m.kind === ts.SyntaxKind.ExportKeyword,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the read at `node` is behind the labs gate: a function around it
|
||||
* asks, or it sits in a private top-level helper every one of whose
|
||||
* references in the file is itself behind the gate.
|
||||
*/
|
||||
function gated(
|
||||
file: ts.SourceFile,
|
||||
node: ts.Node,
|
||||
seen: Set<string> = new Set(),
|
||||
): boolean {
|
||||
for (const fn of enclosingFunctions(node)) {
|
||||
if (LABS_GATE.test(stripComments(fn.getText(file)))) return true;
|
||||
}
|
||||
const statement = topLevelStatement(node);
|
||||
const name = statement ? declaredName(statement) : null;
|
||||
if (!statement || !name || isExported(statement) || seen.has(name)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(name);
|
||||
const references: ts.Node[] = [];
|
||||
const visit = (at: ts.Node) => {
|
||||
if (
|
||||
ts.isIdentifier(at) &&
|
||||
at.text === name &&
|
||||
topLevelStatement(at) !== statement
|
||||
) {
|
||||
references.push(at);
|
||||
}
|
||||
ts.forEachChild(at, visit);
|
||||
};
|
||||
visit(file);
|
||||
return (
|
||||
references.length > 0 &&
|
||||
references.every((ref) => gated(file, ref, new Set(seen)))
|
||||
);
|
||||
}
|
||||
|
||||
interface LabRead {
|
||||
/** `file#top-level name`. */
|
||||
site: string;
|
||||
line: number;
|
||||
gated: boolean;
|
||||
}
|
||||
|
||||
function readsIn(path: string, source: string): LabRead[] {
|
||||
const file = ts.createSourceFile(
|
||||
path,
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
path.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
|
||||
);
|
||||
const out: LabRead[] = [];
|
||||
const visit = (node: ts.Node) => {
|
||||
if (isReadCall(node) || isTableLiteral(node)) {
|
||||
const statement = topLevelStatement(node);
|
||||
const name = (statement && declaredName(statement)) ?? "(top level)";
|
||||
out.push({
|
||||
site: `${path}#${name}`,
|
||||
line: file.getLineAndCharacterOfPosition(node.getStart(file)).line + 1,
|
||||
gated: gated(file, node),
|
||||
});
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(file);
|
||||
return out;
|
||||
}
|
||||
|
||||
function labReads(): LabRead[] {
|
||||
const out: LabRead[] = [];
|
||||
for (const root of ROOTS) {
|
||||
for (const [rel, source] of readSourceFolder(root, {
|
||||
floor: 20,
|
||||
@@ -64,49 +212,84 @@ function labReaders(): Array<{ path: string; gated: boolean }> {
|
||||
!r.split("/").includes("__tests__") &&
|
||||
!/\.(?:test|spec)\.tsx?$/.test(r),
|
||||
})) {
|
||||
const code = stripComments(source);
|
||||
if (!LAB_READ.test(code)) continue;
|
||||
out.push({ path: `${root}/${rel}`, gated: LABS_GATE.test(code) });
|
||||
out.push(...readsIn(`${root}/${rel}`, source));
|
||||
}
|
||||
}
|
||||
return out.sort((a, b) => a.path.localeCompare(b.path));
|
||||
return out.sort((a, b) => a.site.localeCompare(b.site) || a.line - b.line);
|
||||
}
|
||||
|
||||
describe("labs reach a model only with the labs module on", () => {
|
||||
const readers = labReaders();
|
||||
const reads = labReads();
|
||||
|
||||
it("finds the readers it exists to check", () => {
|
||||
it("finds the reads it exists to check", () => {
|
||||
// An empty match set would pass every assertion below vacuously.
|
||||
expect(readers.length).toBeGreaterThanOrEqual(8);
|
||||
expect(readers.filter((r) => r.gated).length).toBeGreaterThanOrEqual(6);
|
||||
expect(reads.length).toBeGreaterThanOrEqual(14);
|
||||
expect(reads.filter((r) => r.gated).length).toBeGreaterThanOrEqual(8);
|
||||
});
|
||||
|
||||
it("every reader asks the labs module or is listed as not model input", () => {
|
||||
const open = readers
|
||||
.filter((r) => !r.gated && !(r.path in NOT_MODEL_INPUT))
|
||||
.map((r) => r.path);
|
||||
it("every read asks the labs module or is listed as not model input", () => {
|
||||
const open = reads
|
||||
.filter((r) => !r.gated && !(r.site in NOT_MODEL_INPUT))
|
||||
.map((r) => `${r.site}:${r.line}`);
|
||||
expect(open).toEqual([]);
|
||||
});
|
||||
|
||||
it("lists no reader that has since gained the gate or gone", () => {
|
||||
const paths = new Map(readers.map((r) => [r.path, r.gated]));
|
||||
it("lists no site that has since gained the gate or gone", () => {
|
||||
const stale = Object.keys(NOT_MODEL_INPUT).filter(
|
||||
(path) => paths.get(path) !== false,
|
||||
(site) => !reads.some((r) => r.site === site && !r.gated),
|
||||
);
|
||||
expect(stale).toEqual([]);
|
||||
});
|
||||
|
||||
it("the matchers catch what they exist to catch", () => {
|
||||
expect(LAB_READ.test("await prisma.labResult.findMany({")).toBe(true);
|
||||
expect(LAB_READ.test("tx.labResult\n .count({")).toBe(true);
|
||||
expect(LAB_READ.test("SELECT 1 FROM lab_results")).toBe(true);
|
||||
expect(LAB_READ.test("prisma.labResultDocumentLink.findMany(")).toBe(false);
|
||||
it("judges each read on its own, not the file it is in", () => {
|
||||
const source = `
|
||||
export async function gatedRead(userId: string) {
|
||||
if (!(await isModuleEnabled(userId, "labs"))) return null;
|
||||
return prisma.labResult.findMany({ where: { userId } });
|
||||
}
|
||||
export async function openRead(userId: string) {
|
||||
return prisma.labResult.count({ where: { userId } });
|
||||
}
|
||||
async function helper(userId: string) {
|
||||
return prisma.labResult.findFirst({ where: { userId } });
|
||||
}
|
||||
export async function callsHelperGated(userId: string) {
|
||||
if (!(await isModuleEnabled(userId, "labs"))) return null;
|
||||
return helper(userId);
|
||||
}
|
||||
async function helperTwo(userId: string) {
|
||||
return prisma.$queryRaw\`SELECT 1 FROM lab_results\`;
|
||||
}
|
||||
export async function callsHelperTwo(userId: string) {
|
||||
return helperTwo(userId);
|
||||
}
|
||||
export function medsOnly(ctx: Ctx) {
|
||||
return whenModuleOn(ctx, "medications", "t", () =>
|
||||
prisma.labResult.findMany({}),
|
||||
);
|
||||
}
|
||||
`;
|
||||
const verdicts = readsIn("fixture.ts", source).map(
|
||||
(r) => `${r.site}:${r.gated}`,
|
||||
);
|
||||
expect(verdicts).toEqual([
|
||||
"fixture.ts#gatedRead:true",
|
||||
"fixture.ts#openRead:false",
|
||||
"fixture.ts#helper:true",
|
||||
"fixture.ts#helperTwo:false",
|
||||
"fixture.ts#medsOnly:false",
|
||||
]);
|
||||
});
|
||||
|
||||
it("the gate matcher takes the labs module only", () => {
|
||||
expect(LABS_GATE.test('await isModuleEnabled(userId, "labs")')).toBe(true);
|
||||
expect(LABS_GATE.test('await isModuleEnabled(ctx.userId, "labs")')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(LABS_GATE.test('whenModuleOn(ctx, "labs", "get_labs"')).toBe(true);
|
||||
expect(LABS_GATE.test('isModuleEnabled(userId, "medications")')).toBe(
|
||||
false,
|
||||
);
|
||||
expect(LABS_GATE.test('whenModuleOn(ctx, "environment", "x"')).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user