diff --git a/src/__tests__/apple-health-unit-audit-guard.test.ts b/src/__tests__/apple-health-unit-audit-guard.test.ts index b8799aeaf..070472f32 100644 --- a/src/__tests__/apple-health-unit-audit-guard.test.ts +++ b/src/__tests__/apple-health-unit-audit-guard.test.ts @@ -48,6 +48,7 @@ import { convertHkValue, isConvertibleHkUnit, } from "@/lib/measurements/hk-units"; +import { readSource } from "@/__tests__/helpers/source-files"; const ENTRIES = Object.values(APPLE_HEALTH_TYPE_MAP); @@ -193,10 +194,10 @@ describe("Apple Health mapping — the record's own unit (issue #944)", () => { "utf8", ); expect(archive).toContain(optIn); - const batch = readFileSync( - join(process.cwd(), "src/app/api/measurements/batch/route.ts"), - "utf8", - ); + const batch = readSource("src/app/api/measurements/batch/route.ts", [ + "export const POST", + "hkIdentifier", + ]); expect( batch, "the batch route opted into record-unit conversion", diff --git a/src/__tests__/helpers/prisma-schema-names.ts b/src/__tests__/helpers/prisma-schema-names.ts index b9e187e12..f45171d02 100644 --- a/src/__tests__/helpers/prisma-schema-names.ts +++ b/src/__tests__/helpers/prisma-schema-names.ts @@ -171,5 +171,16 @@ export function readDeclaredSchema( }; }); + // Every consumer compares this against an allowlist or a "none of these" + // rule, which an empty parse satisfies. For the real schema, this little is + // a broken read (a moved file, a block regex that stopped matching), not a + // schema. A synthetic schema a test writes itself is exempt. + if (schemaPath === SCHEMA_PATH && (models.length < 50 || enums.length < 10)) { + throw new Error( + `readDeclaredSchema(${schemaPath}): parsed ${models.length} model(s) and ` + + `${enums.length} enum(s), below the floor of 50 and 10. Fix the parser ` + + `before trusting a guard built on it.`, + ); + } return { enums, models }; } diff --git a/src/__tests__/helpers/source-files.ts b/src/__tests__/helpers/source-files.ts index 15a5da508..70e92385a 100644 --- a/src/__tests__/helpers/source-files.ts +++ b/src/__tests__/helpers/source-files.ts @@ -50,7 +50,7 @@ * worktrees, each a full second copy of the tree that would double every * match. Every caller roots at `src/`, `e2e/` or a subtree of one. */ -import { readdirSync, readFileSync } from "node:fs"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; import { join, sep } from "node:path"; export function walkSourceFiles( @@ -229,3 +229,82 @@ export function scanSourceMatches( options.matchFloor, ) as SourceMatch[]; } + +/** Something a file must still contain for a read of it to mean anything. */ +export type SourceAnchor = string | RegExp; + +function anchorCount(text: string, anchor: SourceAnchor): number { + if (typeof anchor === "string") return text.split(anchor).length - 1; + const flags = anchor.flags.includes("g") ? anchor.flags : `${anchor.flags}g`; + return [...text.matchAll(new RegExp(anchor.source, flags))].length; +} + +/** + * Read one source file by path, and prove it is still the file the guard is + * about. + * + * A guard that reads a fixed path and asserts that something is ABSENT + * (`expect(source).not.toMatch(…)`) is green on a file that has been split: + * the code it watched moved next door, the old path holds whatever is left, + * and the absence is true for the wrong reason. A missing file at least + * throws; a file that kept its name but lost its contents does not. + * + * So the caller names at least one anchor: a string or pattern the watched + * code itself carries (the function the guard is about, the call it pins). + * Every anchor must match at least once, or this throws and names the file, + * which is the moment to point the guard at the new home (or at a folder + * with `readSourceFolder`). `rel` is relative to the repository root. + */ +export function readSource( + rel: string, + anchors: SourceAnchor | readonly SourceAnchor[], +): string { + const list = (Array.isArray(anchors) ? anchors : [anchors]) as SourceAnchor[]; + if (list.length === 0) { + throw new Error( + `readSource(${rel}): name at least one anchor the watched code carries.`, + ); + } + const path = join(process.cwd(), rel); + if (!existsSync(path)) { + throw new Error( + `readSource(${rel}): the file does not exist. If it was split or moved, ` + + `point the guard at the new location or scan the folder with ` + + `readSourceFolder and a floor.`, + ); + } + const text = readFileSync(path, "utf8"); + const lost = list.filter((anchor) => anchorCount(text, anchor) === 0); + if (lost.length > 0) { + throw new Error( + `readSource(${rel}): the file no longer contains ${lost + .map(String) + .join(", ")}. The code this guard watches has moved; a negative ` + + `assertion on this file would now pass for the wrong reason.`, + ); + } + return text; +} + +/** + * Read every source file under a folder (relative to the repository root), + * with a floor on how many it found: the folder-scan form of `readSource` + * for a guard whose subject was split into several files. Returns + * `[path relative to the folder, text]` pairs, sorted. + */ +export function readSourceFolder( + relDir: string, + options: { + floor: number; + include?: (rel: string) => boolean; + extensions?: readonly string[]; + }, +): Array<[string, string]> { + const root = join(process.cwd(), relDir); + const files = walkSourceFiles(root, { + floor: options.floor, + extensions: options.extensions, + }).filter(options.include ?? (() => true)); + requireFloor(`readSourceFolder(${relDir})`, files, options.floor); + return files.map((rel) => [rel, readFileSync(join(root, rel), "utf8")]); +} diff --git a/src/__tests__/helpers/wipe-schema-shape.ts b/src/__tests__/helpers/wipe-schema-shape.ts index 1e6f915f2..51a80a736 100644 --- a/src/__tests__/helpers/wipe-schema-shape.ts +++ b/src/__tests__/helpers/wipe-schema-shape.ts @@ -38,6 +38,12 @@ export function parseWipeSchema( const blocks = [...schema.matchAll(/^model\s+(\w+)\s*\{([\s\S]*?)^\}/gm)].map( (m) => ({ name: m[1], body: m[2] }), ); + if (schemaPath === WIPE_SCHEMA_PATH && blocks.length < 50) { + throw new Error( + `parseWipeSchema(${schemaPath}): parsed ${blocks.length} model(s), below ` + + `the floor of 50. A wipe guard over an empty parse passes on nothing.`, + ); + } const modelNames = new Set(blocks.map((b) => b.name)); const out = new Map(); diff --git a/src/__tests__/moodlog-removal-guard.test.ts b/src/__tests__/moodlog-removal-guard.test.ts index 6bd82e6a3..6eccca904 100644 --- a/src/__tests__/moodlog-removal-guard.test.ts +++ b/src/__tests__/moodlog-removal-guard.test.ts @@ -22,6 +22,7 @@ import { describe, expect, it } from "vitest"; import { INTEGRATION_CADENCE } from "@/lib/integrations/sync-verdict"; import { INTEGRATION_DISPLAY_NAMES } from "@/components/settings/integrations/integration-fallback-row"; +import { readSource } from "@/__tests__/helpers/source-files"; const ROOT = path.resolve(__dirname, "../.."); @@ -65,9 +66,9 @@ describe("moodLog integration stays removed", () => { }); it("registers no moodlog queue, cron or handler binding", () => { - const registrar = readFileSync( - path.join(ROOT, "src/lib/jobs/reminder/register-integration-sync.ts"), - "utf8", + const registrar = readSource( + "src/lib/jobs/reminder/register-integration-sync.ts", + "export async function registerIntegrationSyncQueues", ); expect(registrar).not.toContain("moodlog"); expect(registrar).not.toContain("MoodLog"); diff --git a/src/__tests__/source-read-helpers.test.ts b/src/__tests__/source-read-helpers.test.ts new file mode 100644 index 000000000..05fb28e7a --- /dev/null +++ b/src/__tests__/source-read-helpers.test.ts @@ -0,0 +1,55 @@ +/** + * `readSource` and `readSourceFolder`: the reads a path-pinned guard goes + * through, so a split file fails the guard instead of leaving it green. + */ +import { describe, expect, it } from "vitest"; + +import { readSource, readSourceFolder } from "./helpers/source-files"; + +const HERE = "src/__tests__/helpers/source-files.ts"; + +describe("readSource", () => { + it("returns the text when every anchor is present", () => { + const text = readSource(HERE, [ + "export function readSource(", + /export function walkSourceFiles\(/, + ]); + expect(text.length).toBeGreaterThan(1000); + }); + + it("throws on a file that does not exist", () => { + expect(() => readSource("src/lib/does-not-exist.ts", "anything")).toThrow( + /does not exist/, + ); + }); + + it("throws when an anchor no longer matches", () => { + expect(() => + readSource(HERE, ["export function readSource(", "noSuchAnchorHere"]), + ).toThrow(/no longer contains noSuchAnchorHere/); + expect(() => readSource(HERE, /export function neverDeclared\(/)).toThrow( + /no longer contains/, + ); + }); + + it("refuses a read without an anchor", () => { + expect(() => readSource(HERE, [])).toThrow(/at least one anchor/); + }); +}); + +describe("readSourceFolder", () => { + it("returns the files with their text, above the floor", () => { + const files = readSourceFolder("src/__tests__/helpers", { floor: 5 }); + expect(files.length).toBeGreaterThanOrEqual(5); + expect(files.map(([rel]) => rel)).toContain("source-files.ts"); + }); + + it("throws when the folder holds fewer files than the floor", () => { + expect(() => + readSourceFolder("src/__tests__/helpers", { + floor: 1, + include: (rel) => rel === "nothing-matches.ts", + }), + ).toThrow(/below the stated floor/); + }); +}); diff --git a/src/app/__tests__/dashboard-hrv-rmssd-fallback.test.tsx b/src/app/__tests__/dashboard-hrv-rmssd-fallback.test.tsx index c063d13c1..6ed76d946 100644 --- a/src/app/__tests__/dashboard-hrv-rmssd-fallback.test.tsx +++ b/src/app/__tests__/dashboard-hrv-rmssd-fallback.test.tsx @@ -19,6 +19,7 @@ import { import { moduleForMeasurementType } from "@/lib/modules/measurement-scope"; import { locales } from "@/lib/i18n/config"; import type { DataSummary } from "@/lib/analytics/trends"; +import { readSource } from "@/__tests__/helpers/source-files"; /** * The dashboard HRV tile must accept either HRV type. @@ -250,10 +251,10 @@ describe("the tile the user actually sees", () => { // current formatting of the replacement, so reformatting cannot break // it. Freshness is per-type and captioning a live RMSSD tile from the // SDNN lookup is the failure it guards. - const page = readFileSync( - join(process.cwd(), "src/app/page-client.tsx"), - "utf8", - ); + const page = readSource("src/app/page-client.tsx", [ + "const tileStaleDays", + "HEART_RATE_VARIABILITY", + ]); expect(page).not.toContain('tileStaleDays("HEART_RATE_VARIABILITY")'); expect(page).not.toContain( "const hrvSummary = data?.summaries?.HEART_RATE_VARIABILITY", diff --git a/src/app/api/tokens/__tests__/route.test.ts b/src/app/api/tokens/__tests__/route.test.ts index 2c757c67d..e273d971d 100644 --- a/src/app/api/tokens/__tests__/route.test.ts +++ b/src/app/api/tokens/__tests__/route.test.ts @@ -40,6 +40,7 @@ import { DELETE } from "../[id]/route"; import { requireAuth } from "@/lib/api-handler"; import { prisma } from "@/lib/db"; import { isApiGloballyEnabled } from "@/lib/app-settings"; +import { readSource } from "@/__tests__/helpers/source-files"; const USER = { id: "user-1", username: "u" }; @@ -168,11 +169,9 @@ describe("the switch and the authentication path", () => { // a token would NOT be live during the outage and hiding the list would // stop being wrong. This reads the source because the behaviour is an // absence, and an absence has no call to spy on. - const { readFileSync } = await import("node:fs"); - const { join } = await import("node:path"); - const source = readFileSync( - join(process.cwd(), "src/lib/api-handler.ts"), - "utf8", + const source = readSource( + "src/lib/api-handler.ts", + "export async function requireAuth(", ); expect(source).not.toContain("isApiGloballyEnabled"); expect(source).not.toContain("apiGlobal"); diff --git a/src/app/insights/__tests__/header-add-pattern.test.ts b/src/app/insights/__tests__/header-add-pattern.test.ts index 72f9616a8..5dc8179ee 100644 --- a/src/app/insights/__tests__/header-add-pattern.test.ts +++ b/src/app/insights/__tests__/header-add-pattern.test.ts @@ -14,6 +14,7 @@ import { readdirSync, readFileSync, statSync } from "node:fs"; import { join, relative } from "node:path"; import { describe, expect, it } from "vitest"; +import { readSource } from "@/__tests__/helpers/source-files"; const ROOT = process.cwd(); const INSIGHTS = join(ROOT, "src/app/insights"); @@ -52,7 +53,10 @@ describe("Insights add actions", () => { }); it("the mood page has no text links into logging or the check-in", () => { - const source = readFileSync(join(INSIGHTS, "mood/page-client.tsx"), "utf8"); + const source = readSource( + "src/app/insights/mood/page-client.tsx", + " { it("links to /settings/integrations (a real settings slug)", () => { @@ -29,9 +30,9 @@ describe("sleep empty-state CTA href", () => { }); it("never links to the removed /settings/data-sources slug", () => { - const source = readFileSync( - join(process.cwd(), "src/app/insights/sleep/page.tsx"), - "utf8", + const source = readSource( + "src/app/insights/sleep/page.tsx", + " { it("links to /settings/integrations (a real settings slug)", () => { @@ -30,9 +31,9 @@ describe("sleep-overview empty-state CTA href", () => { }); it("never links to the non-existent /settings/devices slug", () => { - const source = readFileSync( - join(process.cwd(), "src/components/insights/sleep-overview.tsx"), - "utf8", + const source = readSource( + "src/components/insights/sleep-overview.tsx", + " carries no rules for a viewport it never shows on", () => // A touch screen at most 520 px tall gets the phone shell (the // `shell-mobile` variant), so the rail is hidden there and a class keyed // on that query can never apply. - const source = readFileSync( - join(__dirname, "..", "sidebar-nav.tsx"), - "utf8", + const source = readSource( + "src/components/layout/sidebar-nav.tsx", + "export function SidebarNav", ); expect(source).not.toMatch(/pointer:coarse\)_and_\(height<=520px\)/); }); diff --git a/src/components/onboarding/__tests__/confirm-failure-recovery.test.ts b/src/components/onboarding/__tests__/confirm-failure-recovery.test.ts index 2df83c41e..dc1e036b7 100644 --- a/src/components/onboarding/__tests__/confirm-failure-recovery.test.ts +++ b/src/components/onboarding/__tests__/confirm-failure-recovery.test.ts @@ -16,8 +16,6 @@ * component tests render server-side and cannot click. */ import { describe, expect, it, vi } from "vitest"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; import { defaultOnboardingSteps, @@ -27,6 +25,7 @@ import { import { runBaselineAttempt } from "../baseline-form"; import { runConfirmFinish } from "../confirm-screen"; +import { readSource } from "@/__tests__/helpers/source-files"; type PassableStep = Parameters< Parameters[0]["passStep"] @@ -92,10 +91,10 @@ describe("runBaselineAttempt", () => { it("is the form's only way to move the pending flag", () => { // A second, inline `setSaving(false)` is how the flag stopped being // reset on one of the three exits in the first place. - const source = readFileSync( - join(process.cwd(), "src/components/onboarding/baseline-form.tsx"), - "utf8", - ); + const source = readSource("src/components/onboarding/baseline-form.tsx", [ + "export function BaselineForm", + "setSaving", + ]); expect(source).not.toContain("setSaving(false)"); expect(source).not.toContain("setSaving(true)"); }); diff --git a/src/components/settings/__tests__/anamnesis-about-me-relocation.test.tsx b/src/components/settings/__tests__/anamnesis-about-me-relocation.test.tsx index 0383d285b..4eedaf5cb 100644 --- a/src/components/settings/__tests__/anamnesis-about-me-relocation.test.tsx +++ b/src/components/settings/__tests__/anamnesis-about-me-relocation.test.tsx @@ -1,11 +1,10 @@ import type React from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; import { I18nProvider } from "@/lib/i18n/context"; import { AnamnesisSection } from "../anamnesis-section"; +import { readSource } from "@/__tests__/helpers/source-files"; /** * #159 — the "About me" note and the free-text allergies line moved from the @@ -144,9 +143,9 @@ describe("Anamnese — the About-me note card (#159)", () => { describe("Account settings — the About-me card is gone (#159)", () => { it("the account section carries no about-me mount any more", () => { - const source = readFileSync( - join(process.cwd(), "src/components/settings/account-section/index.tsx"), - "utf8", + const source = readSource( + "src/components/settings/account-section/index.tsx", + "export function AccountSection", ); expect(source).not.toContain("AboutMeSection"); expect(source).not.toContain("about-me-section"); diff --git a/src/components/settings/__tests__/password-card-placement.test.tsx b/src/components/settings/__tests__/password-card-placement.test.tsx index 57618e9ff..d902a055a 100644 --- a/src/components/settings/__tests__/password-card-placement.test.tsx +++ b/src/components/settings/__tests__/password-card-placement.test.tsx @@ -41,6 +41,7 @@ vi.mock("@/hooks/use-auth", () => ({ import { PasswordCard } from "../password-card"; import { SecuritySection } from "../security-section"; +import { readSource } from "@/__tests__/helpers/source-files"; const SLOT = 'data-slot="settings-password-card"'; @@ -108,9 +109,9 @@ describe("change-password card placement", () => { }); it("left no password form behind in the Account section", () => { - const account = readFileSync( - join(ROOT, "src/components/settings/account-section/index.tsx"), - "utf8", + const account = readSource( + "src/components/settings/account-section/index.tsx", + "export function AccountSection", ); for (const marker of [ "settings.passwordTitle", diff --git a/src/lib/jobs/reminder/__tests__/integration-cohort-outcomes.test.ts b/src/lib/jobs/reminder/__tests__/integration-cohort-outcomes.test.ts index 644c43133..1916b6511 100644 --- a/src/lib/jobs/reminder/__tests__/integration-cohort-outcomes.test.ts +++ b/src/lib/jobs/reminder/__tests__/integration-cohort-outcomes.test.ts @@ -1,6 +1,7 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; +import { readSource } from "@/__tests__/helpers/source-files"; type Provider = "google_health" | "withings"; type UserStatus = "complete" | "partial" | "failed" | "parked" | "skipped"; @@ -45,9 +46,9 @@ const googleHandlerSource = readFileSync( join(process.cwd(), "src/lib/jobs/reminder/google-health-sync.ts"), "utf8", ); -const googleSyncSource = readFileSync( - join(process.cwd(), "src/lib/google-health/sync.ts"), - "utf8", +const googleSyncSource = readSource( + "src/lib/google-health/sync.ts", + "export async function syncUserGoogleHealth(", ); const withingsHandlerSource = readFileSync( join(process.cwd(), "src/lib/jobs/reminder/withings-sync.ts"),