Files
HealthLog/scripts/diagnose-noncanonical-measurement-units.ts
Marc-André Bombeck ddc598afcb test(measurements): cover the unit transform, MCP unit hardening, and a display guard
Add affine-correctness tests for the transform registry (°C→°F absolute vs a
delta that must stay offset-free, lb/in factors, inverse round-trips), a
resolver test for the inbound unit aliases, a preferred-unit entry-form label
test, and MCP cases for converting a lb hint to canonical kg and refusing an
unknown unit. Add the structural guard that trips if any dashboard or insight
surface reintroduces a hardcoded transformed-type unit or value scale. Ship the
diagnose/convert script pair for legacy non-canonical measurement rows.
2026-07-25 00:21:36 +02:00

77 lines
2.8 KiB
TypeScript

/**
* Read-only diagnostic for measurement rows stored in a NON-canonical unit.
*
* Canonical storage is SI: every write path stamps `getUnitForType(type)`. The
* one historical leak was the MCP `log_measurement` tool, which persisted a
* caller-supplied `unit` verbatim (closed in v1.32.26). A row whose `unit`
* column differs from its type's canonical unit is such a legacy row, and it
* mislabels every display surface that trusts the column.
*
* This groups measurements by `(type, unit)` and prints every group whose
* `unit` is not the canonical unit for its type, flagging whether the stored
* unit is a RECOGNISED alias (mechanically convertible by the companion
* convert script) or UNKNOWN (needs a manual look). Nothing is written.
*
* NOT auto-fixable and deliberately out of scope: a row whose stored unit IS
* already canonical but whose VALUE was a foreign unit (e.g. a lb reading typed
* into the old kg-labelled entry form). Those are indistinguishable from real
* canonical readings; the reporter edits them via the record list.
*
* Run (inside the app container, which has DATABASE_URL):
* pnpm dlx tsx scripts/diagnose-noncanonical-measurement-units.ts
*/
import "dotenv/config";
import { prisma } from "@/lib/db";
import { getUnitForType } from "@/lib/validations/measurement";
import { resolveToCanonicalUnit } from "@/lib/measurements/unit-aliases";
async function main(): Promise<void> {
const groups = await prisma.measurement.groupBy({
by: ["type", "unit"],
_count: { _all: true },
});
const mismatches = groups.filter((g) => {
if (g.unit === null) return false;
return g.unit !== getUnitForType(g.type);
});
if (mismatches.length === 0) {
console.log("No non-canonical measurement units found. Storage is clean.");
return;
}
let convertible = 0;
let unknown = 0;
console.log(
`Found ${mismatches.length} non-canonical (type, unit) group(s):\n`,
);
for (const g of mismatches.sort((a, b) => b._count._all - a._count._all)) {
const canonical = getUnitForType(g.type);
// Probe convertibility with a neutral value — only the recognition matters.
const recognised = resolveToCanonicalUnit(g.type, 1, g.unit as string);
const status = recognised ? "CONVERTIBLE" : "UNKNOWN — manual review";
if (recognised) convertible += g._count._all;
else unknown += g._count._all;
console.log(
` ${g.type}: stored="${g.unit}" canonical="${canonical}" ` +
`rows=${g._count._all} [${status}]`,
);
}
console.log(
`\nTotals: ${convertible} row(s) convertible, ${unknown} row(s) unknown.`,
);
console.log(
"Convert the recognised aliases with:\n" +
" pnpm dlx tsx scripts/convert-noncanonical-measurement-units.ts --execute",
);
}
main()
.catch((err) => {
console.error(err);
process.exitCode = 1;
})
.finally(() => prisma.$disconnect());