Files
HealthLog/scripts/audit-column-readers.ts
Marc-André Bombeck 252b09c00c fix(audit): teach the column sweep to tell a filter from a write
The column-reader sweep read every `field:` key as a write, so
`where: { ticketHash }` counted as writing `ticketHash`. All twelve columns it
reported were a lookup key or a cron's discovery predicate being filtered on,
and a review tool that has cried wolf twelve times is not consulted on the
thirteenth. It also reported only columns that already had a write, which made
a column with no write at all the one thing it could not see.

Mask each file, bracket-match every Prisma argument, and tag its contents as
write, projection or query so nesting resolves innermost-wins. Follow a payload
assembled before the call: a const handed to `update:`, a spread merged into a
`create:`, a `rows.push({...})` builder filling a typed array. Credit raw SQL
against the `@map` column name. Report a column nothing touches as its own
category, and split the output so a write with no reader reads differently from
a query predicate, a relation's foreign key and an annotated column.

Twelve false alarms become nineteen findings that hold up. Four are worth
acting on and are left for a decision rather than annotated away: the glucose
unit preference and an import job's export date have readers and no writer, and
the provider-health status code and the known-device first-seen stamp have the
opposite.

The matcher underneath is a test now, checked by breaking the fix three ways.
2026-09-03 05:09:00 +02:00

857 lines
29 KiB
TypeScript

/**
* Release-battery sweep: which Prisma columns have no consumer, and which have
* no writer at all?
*
* Run it as part of the deep-audit step, not in CI:
*
* pnpm dlx tsx scripts/audit-column-readers.ts
* pnpm dlx tsx scripts/audit-column-readers.ts --model Measurement
* pnpm dlx tsx scripts/audit-column-readers.ts --all
*
* ## Why this is a script and not a test
*
* The question "does this column have a consumer anywhere in the tree" is a
* whole-program property, and the only cheap way to approximate it is to read
* the source without type information. That approximation is unsound in both
* directions. Generic field names (`value`, `type`, `state`, `source`) match
* unrelated identifiers and read as consumed when they are not. Consumption
* through a select-less `findUnique` plus whole-row serialization reads as
* unconsumed when it is not. Dynamic selects fall outside the matcher.
*
* A gate built on that matcher would drift towards green and stop meaning
* anything, which is exactly the failure this whole family of guards exists to
* prevent — a pair guard that quietly degrades into a one-end guard is worse
* than no guard, because it also removes the suspicion. So this stays a review
* tool with a human reading the output. The two questions that CAN be answered
* exactly — is every account-payload field consumed, is every Measurement
* column backed up — are tests instead:
* `src/__tests__/account-payload-consumer-guard.test.ts` and
* `src/__tests__/measurement-backup-completeness.test.ts`.
*
* ## What the matcher does, and where it stops
*
* Each file is masked once (comments, string bodies and regex literals blanked
* in place, `${…}` substitutions kept as code because `` `${row.field}` `` is a
* real read). Every `<key>: {` / `<key>: [` block is then bracket-matched and
* the characters inside it are tagged with the role that key gives them:
*
* - write — `data` `create` `createMany` `update` `updateMany` `upsert`
* `set` `connectOrCreate`
* - project — `select` `include` (a projection IS a read of the column)
* - query — `where` `orderBy` `cursor` `distinct` `having` `by` `omit`
*
* Nesting resolves innermost-wins, so the `where` inside an `upsert` is a
* query and the `data` beside it is a write. That distinction is the whole
* point of the rewrite: the previous matcher counted every `<field>:` key
* anywhere as a write, so a column that is only ever FILTERED ON looked
* written-but-unread. All twelve findings it reported were that one mistake.
*
* Known limits, stated plainly so nobody mistakes a clean run for a proof:
*
* - Counts are per identifier, not per model. Two models with a `lastSeenAt`
* share one tally, so a read of either satisfies both. This is the same
* imprecision `GENERIC_NAMES` exists for, and it applies to every name.
* - `obj.field = …` counts as a write even when `obj` never reaches the
* database. Without it the settings layer, which assembles its update
* objects field by field, reads as columns nothing writes.
* - A non-Prisma object literal under a `data:` key (a chart series, a
* react-query result) is counted as a write.
* - A payload returned by a helper rather than declared beside its Prisma
* call is still invisible; the dataflow pass is one hop and one file.
* - Raw SQL is classified by keyword only: a literal shaped like INSERT or
* UPDATE…SET donates writes, one shaped like SELECT…FROM donates reads,
* for both the field name and its `@map` column name.
* - Relation attributes are read one line at a time; a `@relation(…)` split
* across lines would not register its `fields: […]`.
*
* ## The annotation convention
*
* A pull request that adds a column, payload field, or preference contains its
* consumer. When it genuinely cannot, the schema comment says so and this
* sweep treats the marker as an expiring allowlist entry:
*
* /// @pending-consumer(#123) — iOS reads this from v1.33; server-side unread until then.
* /// @internal: cooldown bookkeeping for the reminder cron; never surfaced.
*
* `@internal` is permanent and silences the field. `@pending-consumer` is
* temporary: the field stays out of the findings list but is reported
* separately every run, so an issue that closed without the consumer landing
* becomes visible at the next release audit instead of never.
*/
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = join(import.meta.dirname, "..");
const SCHEMA = join(ROOT, "prisma", "schema.prisma");
/**
* `src` is the application; `scripts` holds the one-shot maintenance tools,
* which are the other place a column legitimately gets written or read. Tests
* are excluded on purpose — a fixture is not a consumer.
*/
const SCAN_DIRS = ["src", "scripts"];
/** `generated` is the Prisma client: megabytes of noise, and never a consumer. */
const SKIP_DIRS = new Set([
"generated",
"node_modules",
".next",
"__tests__",
"__mocks__",
]);
/**
* Names too generic for the matcher to say anything useful about. Reported in
* their own section rather than dropped, so the sweep never claims a clean
* bill it cannot back up. The suppression covers the consumer question only:
* a name that appears NOWHERE in the tree is still unwired, and that verdict
* does not get less true for a column called `state`.
*/
const GENERIC_NAMES = new Set([
"id",
"name",
"type",
"value",
"unit",
"source",
"state",
"status",
"label",
"kind",
"data",
"key",
"code",
"url",
"title",
"notes",
"userId",
"createdAt",
"updatedAt",
"deletedAt",
]);
export interface Field {
model: string;
name: string;
/** `@map("…")` target, or the field name when it maps to itself. */
column: string;
annotation: "internal" | "pending-consumer" | null;
annotationText: string | null;
/** `@default(…)`, `@updatedAt` or `@id`: the database fills it unaided. */
dbManaged: boolean;
/** Relation properties that carry this scalar in their `fields: […]`. */
relationBacked: string[];
}
// ---------------------------------------------------------------------------
// Schema
// ---------------------------------------------------------------------------
export function parseSchema(schema: string): Field[] {
const models = new Set(
[...schema.matchAll(/^model\s+([A-Za-z_]\w*)\s*\{/gm)].map((m) => m[1]),
);
const fields: Field[] = [];
for (const model of models) {
const start = schema.indexOf(`model ${model} {`);
if (start === -1) continue;
const end = schema.indexOf("\n}", start);
const lines = schema.slice(start, end).split("\n").slice(1);
const scalars: Field[] = [];
/** scalar field name → relation properties that carry it. */
const carriedBy = new Map<string, string[]>();
let pending: string[] = [];
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line) continue;
if (line.startsWith("//")) {
pending.push(line);
continue;
}
if (line.startsWith("@@")) {
pending = [];
continue;
}
const m = /^([a-zA-Z_]\w*)\s+([A-Za-z_]\w*)(\[\])?(\?)?/.exec(line);
if (!m) {
pending = [];
continue;
}
const [, name, type, isList] = m;
if (isList || models.has(type) || line.includes("@relation")) {
const carrier = /@relation\([^)]*\bfields:\s*\[([^\]]*)\]/.exec(line);
if (carrier) {
for (const raw of carrier[1].split(",")) {
const scalar = raw.trim();
if (!scalar) continue;
carriedBy.set(scalar, [...(carriedBy.get(scalar) ?? []), name]);
}
}
pending = [];
continue;
}
// Doc comments come in as `/// …` lines above the field, plus any
// trailing `// …` on the field line itself. Strip the markers and the
// declaration before reading an annotation out of them, or the reported
// reason comes back with the column's own Prisma type glued to its end.
const trailing = line.includes("//")
? line.slice(line.indexOf("//"))
: "";
const comment = [...pending, trailing]
.map((l) => l.replace(/^\/{2,3}\s?/, "").trim())
.filter(Boolean)
.join(" ");
let annotation: Field["annotation"] = null;
let annotationText: string | null = null;
const internal = /@internal:?\s*(.*)$/.exec(comment);
const pendingConsumer = /@pending-consumer\(([^)]*)\)\s*(.*)$/.exec(
comment,
);
if (pendingConsumer) {
annotation = "pending-consumer";
annotationText = `${pendingConsumer[1]} ${pendingConsumer[2]}`.trim();
} else if (internal) {
annotation = "internal";
annotationText = internal[1].trim();
}
const mapped = /@map\(\s*"([^"]*)"\s*\)/.exec(line);
scalars.push({
model,
name,
column: mapped ? mapped[1] : name,
annotation,
annotationText,
dbManaged:
line.includes("@default(") ||
line.includes("@updatedAt") ||
/(?<![\w@])@id\b/.test(line),
relationBacked: [],
});
pending = [];
}
for (const field of scalars) {
field.relationBacked = carriedBy.get(field.name) ?? [];
fields.push(field);
}
}
return fields;
}
// ---------------------------------------------------------------------------
// Masking
// ---------------------------------------------------------------------------
/** Characters after which a `/` opens a regex literal rather than dividing. */
const REGEX_PRECEDERS = new Set([
"(",
",",
"=",
":",
"[",
"!",
"&",
"|",
"?",
"{",
"}",
";",
"+",
"-",
"*",
"%",
"^",
"~",
"<",
">",
"\n",
]);
export interface Masked {
/** Same length as the input; comments, string bodies and regexes blanked. */
masked: string;
/** Every string and template chunk body, for the raw-SQL pass. */
literals: string[];
}
/**
* Blank everything that is not code, preserving offsets so the later
* bracket-match lines up with the original text. A template substitution stays
* code: its `${` and matching `}` are blanked as a pair so brackets still
* balance, and the expression between them is left alone.
*/
export function maskLiterals(src: string): Masked {
const out = src.split("");
const literals: string[] = [];
const n = src.length;
const blank = (from: number, to: number) => {
for (let k = from; k < to; k++) if (out[k] !== "\n") out[k] = " ";
};
/** Bottom frame is the file itself; each `` ` `` and `${` pushes one. */
const stack: Array<{ tpl: boolean; depth: number }> = [
{ tpl: false, depth: 0 },
];
let i = 0;
let lastSig = "\n";
while (i < n) {
const frame = stack[stack.length - 1];
const c = src[i];
if (frame.tpl) {
if (c === "\\") {
blank(i, Math.min(i + 2, n));
i += 2;
continue;
}
if (c === "`") {
out[i] = " ";
stack.pop();
i += 1;
lastSig = '"';
continue;
}
if (c === "$" && src[i + 1] === "{") {
blank(i, i + 2);
stack.push({ tpl: false, depth: 0 });
i += 2;
lastSig = "(";
continue;
}
let end = i;
while (
end < n &&
src[end] !== "\\" &&
src[end] !== "`" &&
!(src[end] === "$" && src[end + 1] === "{")
)
end++;
literals.push(src.slice(i, end));
blank(i, end);
i = end;
continue;
}
if (c === "/" && src[i + 1] === "/") {
const nl = src.indexOf("\n", i);
const end = nl === -1 ? n : nl;
blank(i, end);
i = end;
continue;
}
if (c === "/" && src[i + 1] === "*") {
const close = src.indexOf("*/", i + 2);
const end = close === -1 ? n : close + 2;
blank(i, end);
i = end;
continue;
}
if (c === "'" || c === '"') {
let end = i + 1;
while (end < n && src[end] !== c && src[end] !== "\n") {
if (src[end] === "\\") end++;
end++;
}
literals.push(src.slice(i + 1, end));
blank(i, Math.min(end + 1, n));
i = Math.min(end + 1, n);
lastSig = '"';
continue;
}
if (c === "`") {
out[i] = " ";
stack.push({ tpl: true, depth: 0 });
i += 1;
continue;
}
if (c === "/" && REGEX_PRECEDERS.has(lastSig)) {
let end = i + 1;
let inClass = false;
let closed = false;
while (end < n && src[end] !== "\n") {
const d = src[end];
if (d === "\\") {
end += 2;
continue;
}
if (d === "[") inClass = true;
else if (d === "]") inClass = false;
else if (d === "/" && !inClass) {
end++;
closed = true;
break;
}
end++;
}
if (closed) {
while (end < n && /[a-z]/.test(src[end])) end++;
blank(i, end);
i = end;
lastSig = '"';
continue;
}
// Never closed before the newline: it was a division after all.
}
if (c === "{") frame.depth++;
else if (c === "}") {
if (frame.depth === 0 && stack.length > 1) {
out[i] = " ";
stack.pop();
i += 1;
lastSig = '"';
continue;
}
frame.depth--;
}
if (!/\s/.test(c)) lastSig = c;
i += 1;
}
return { masked: out.join(""), literals };
}
// ---------------------------------------------------------------------------
// Roles
// ---------------------------------------------------------------------------
export const ROLE_NONE = 0;
export const ROLE_WRITE = 1;
export const ROLE_PROJECT = 2;
export const ROLE_QUERY = 3;
const ROLE_KEYS: Record<string, number> = {
data: ROLE_WRITE,
create: ROLE_WRITE,
createMany: ROLE_WRITE,
update: ROLE_WRITE,
updateMany: ROLE_WRITE,
upsert: ROLE_WRITE,
set: ROLE_WRITE,
connectOrCreate: ROLE_WRITE,
select: ROLE_PROJECT,
include: ROLE_PROJECT,
where: ROLE_QUERY,
orderBy: ROLE_QUERY,
cursor: ROLE_QUERY,
distinct: ROLE_QUERY,
having: ROLE_QUERY,
by: ROLE_QUERY,
omit: ROLE_QUERY,
};
const ROLE_KEY_RE = new RegExp(
`(?<![\\w$.])(${Object.keys(ROLE_KEYS).join("|")})\\s*:`,
"g",
);
/** A `data:` payload is rarely longer than this; a runaway scan is a bug. */
const EXTENT_CAP = 20_000;
/**
* The end of the expression that starts at `from`: the next `,` or closing
* bracket at nesting depth zero. Scanning the expression rather than
* bracket-matching a leading `{` is what lets
* `createMany({ data: ids.map((id) => ({ … })) })` register as a write.
*/
function expressionExtent(text: string, from: number): number {
let depth = 0;
const limit = Math.min(text.length, from + EXTENT_CAP);
for (let i = from; i < limit; i++) {
const c = text[i];
if (c === "(" || c === "[" || c === "{") depth++;
else if (c === ")" || c === "]" || c === "}") {
if (depth === 0) return i;
depth--;
} else if ((c === "," || c === ";") && depth === 0) return i;
}
return limit;
}
/** The extent of a call's argument list, given the index of its `(`. */
function callArguments(text: string, open: number): [number, number] {
let depth = 0;
for (let i = open; i < Math.min(text.length, open + EXTENT_CAP); i++) {
const c = text[i];
if (c === "(" || c === "[" || c === "{") depth++;
else if (c === ")" || c === "]" || c === "}") {
depth--;
if (depth === 0) return [open, i];
}
}
return [open, Math.min(text.length - 1, open + EXTENT_CAP)];
}
const WRITE_KEY_NAMES = Object.keys(ROLE_KEYS).filter(
(k) => ROLE_KEYS[k] === ROLE_WRITE,
);
/** `update: data` / `data: rows` — a payload handed over by name. */
const WRITE_VALUE_RE = new RegExp(
`(?<![\\w$.])(?:${WRITE_KEY_NAMES.join("|")})\\s*:\\s*([A-Za-z_$][\\w$]*)(?![\\w$(])`,
"g",
);
/** `create: { ...data }` — a payload merged in. */
const SPREAD_RE = /\.\.\.\s*([A-Za-z_$][\w$]*)/g;
/** `const rows: Prisma.WorkoutCreateManyInput[] = []` — a payload by its type. */
const TYPED_PAYLOAD_RE =
/(?<![\w$])(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*:\s*Prisma\.\w*(?:Create|Update|Upsert)\w*Input/g;
function paint(masked: string, spans: Array<[number, number, number]>) {
const roles = new Uint8Array(masked.length);
spans.sort((a, b) => a[0] - b[0] || b[1] - a[1]);
for (const [from, to, role] of spans) roles.fill(role, from, to);
return roles;
}
/**
* Tag every character with the role of the innermost `<key>: …` value that
* encloses it. Outer values are painted first and inner ones paint over them,
* which is what makes `upsert: { where: {…}, create: {…} }` come out right.
*
* A second pass then follows payloads assembled before the call: a
* `const data = {…}` handed to `update:`, a `{ …spread }` merged into a
* `create:`, and the `rows.push({…})` builders that fill an array typed
* `Prisma.…Input[]`. All three are house style here — the no-mass-assignment
* rule makes field-by-field builders the norm — and without this pass most of
* the settings and import layer reads as columns nothing ever writes.
*/
export function roleMap(masked: string): Uint8Array {
const spans: Array<[number, number, number]> = [];
for (const m of masked.matchAll(ROLE_KEY_RE)) {
let from = m.index + m[0].length;
while (from < masked.length && /\s/.test(masked[from])) from++;
spans.push([from, expressionExtent(masked, from), ROLE_KEYS[m[1]]]);
}
const roles = paint(masked, spans);
const sinks = new Set<string>();
for (const m of masked.matchAll(WRITE_VALUE_RE)) sinks.add(m[1]);
for (const m of masked.matchAll(TYPED_PAYLOAD_RE)) sinks.add(m[1]);
for (const m of masked.matchAll(SPREAD_RE))
// The identifier ends the match, so its offset is the match's end minus
// its own length. The `d` flag would say this directly, but it is above
// the target this project compiles to.
if (roles[m.index + m[0].length - m[1].length] === ROLE_WRITE)
sinks.add(m[1]);
for (const ident of sinks) {
const decl = new RegExp(
`(?<![\\w$])(?:const|let|var)\\s+${ident}(?![\\w$])\\s*(?::[^=;]*)?=\\s*`,
"g",
);
for (const m of masked.matchAll(decl)) {
const from = m.index + m[0].length;
spans.push([from, expressionExtent(masked, from), ROLE_WRITE]);
}
const push = new RegExp(`(?<![\\w$])${ident}\\s*\\.\\s*push\\s*\\(`, "g");
for (const m of masked.matchAll(push)) {
const [open, close] = callArguments(masked, m.index + m[0].length - 1);
spans.push([open, close, ROLE_WRITE]);
}
}
return paint(masked, spans);
}
// ---------------------------------------------------------------------------
// Usage index
// ---------------------------------------------------------------------------
type Tally = Map<string, number>;
const bump = (t: Tally, k: string) => t.set(k, (t.get(k) ?? 0) + 1);
/**
* Per-identifier counts, gathered in four passes over the whole tree rather
* than one pass per column: the schema has ~1500 scalars and re-scanning
* megabytes for each of them turns a review tool into a coffee break.
*/
export interface SourceIndex {
/** Keys and shorthand inside write payloads, plus `payload.field =` assignment. */
writeKeys: Tally;
/** Keys inside `select` / `include` projections. */
projectKeys: Tally;
/** Keys and shorthand inside filters and orderings. */
queryKeys: Tally;
/** Shorthand destructuring that no Prisma argument claims. */
plainShorthand: Tally;
/** `.field` property access, wherever it happens. */
access: Tally;
/** Every identifier in code, plus every word in a string literal. */
mentions: Tally;
/** Words inside INSERT / UPDATE…SET literals. */
sqlWrite: Tally;
/** Words inside SELECT…FROM literals. */
sqlRead: Tally;
}
export function emptyIndex(): SourceIndex {
return {
writeKeys: new Map(),
projectKeys: new Map(),
queryKeys: new Map(),
plainShorthand: new Map(),
access: new Map(),
mentions: new Map(),
sqlWrite: new Map(),
sqlRead: new Map(),
};
}
const KEY_RE = /(?<![\w$.])([A-Za-z_$][\w$]*)\s*:/g;
const SHORTHAND_RE = /[{,]\s*([A-Za-z_$][\w$]*)\s*(?=[,}])/g;
const ACCESS_RE = /\.\s*([A-Za-z_$][\w$]*)(?![\w$])(?!\s*=(?!=))/g;
/**
* `updates.aiBaseUrl = null` — a payload assembled into a variable before the
* Prisma call. Without this the whole settings layer, which builds its update
* objects field by field precisely so it cannot mass-assign, reads as a tree
* full of columns nothing ever writes.
*/
const ASSIGN_RE = /\.\s*([A-Za-z_$][\w$]*)\s*=(?!=)/g;
const IDENT_RE = /(?<![\w$])([A-Za-z_$][\w$]*)(?![\w$])/g;
const WORD_RE = /[A-Za-z_][\w$]*/g;
/** A literal that writes columns, one that reads them, or neither. */
function sqlShape(literal: string): "write" | "read" | null {
if (/\binsert\s+into\b/i.test(literal)) return "write";
if (/\bupdate\b[\s\S]{0,200}?\bset\b/i.test(literal)) return "write";
if (/\bselect\b[\s\S]{0,400}?\bfrom\b/i.test(literal)) return "read";
return null;
}
export function indexSource(src: string, into = emptyIndex()): SourceIndex {
const { masked, literals } = maskLiterals(src);
const roles = roleMap(masked);
for (const m of masked.matchAll(KEY_RE)) {
// The lookbehind is zero-width, so the key starts the match.
const at = m.index;
if (roles[at] === ROLE_WRITE) bump(into.writeKeys, m[1]);
else if (roles[at] === ROLE_PROJECT) bump(into.projectKeys, m[1]);
else if (roles[at] === ROLE_QUERY) bump(into.queryKeys, m[1]);
}
for (const m of masked.matchAll(SHORTHAND_RE)) {
// Only a brace or comma and whitespace precede the identifier.
const at = m.index + m[0].indexOf(m[1], 1);
if (roles[at] === ROLE_WRITE) bump(into.writeKeys, m[1]);
else if (roles[at] === ROLE_QUERY) bump(into.queryKeys, m[1]);
else if (roles[at] === ROLE_NONE) bump(into.plainShorthand, m[1]);
}
for (const m of masked.matchAll(ACCESS_RE)) bump(into.access, m[1]);
for (const m of masked.matchAll(ASSIGN_RE)) bump(into.writeKeys, m[1]);
for (const m of masked.matchAll(IDENT_RE)) bump(into.mentions, m[1]);
for (const literal of literals) {
const shape = sqlShape(literal);
for (const word of literal.match(WORD_RE) ?? []) {
// A column named in ANY literal counts as mentioned, so the
// "appears nowhere" verdict stays the strictest thing this tool says.
bump(into.mentions, word);
if (shape === "write") bump(into.sqlWrite, word);
else if (shape === "read") bump(into.sqlRead, word);
}
}
return into;
}
export interface Usage {
writes: number;
/** `select: { field: true }`, `.field`, plain destructuring, SELECT column. */
reads: number;
/** Filtered or ordered on: the column drives a query but never lands in JS. */
queryUses: number;
/** Any appearance at all, in code or in a literal — the unwired test. */
mentions: number;
}
export function countUsage(index: SourceIndex, field: Field): Usage {
const get = (t: Tally, k: string) => t.get(k) ?? 0;
const both = (t: Tally) =>
get(t, field.name) +
(field.column === field.name ? 0 : get(t, field.column));
return {
writes: get(index.writeKeys, field.name) + both(index.sqlWrite),
reads:
get(index.projectKeys, field.name) +
get(index.access, field.name) +
get(index.plainShorthand, field.name) +
both(index.sqlRead),
queryUses: get(index.queryKeys, field.name),
mentions: both(index.mentions),
};
}
export type Category =
| "annotated-internal"
| "annotated-pending"
| "relation-backed"
| "unwired"
| "write-no-reader"
| "query-only"
| "read-no-writer"
| "generic"
| "consumed";
/**
* One column, one verdict. The order matters. An annotation is the maintainer
* speaking and outranks the matcher. `unwired` comes next because it is the
* only verdict here that does not depend on how the source is shaped — the
* identifier is simply absent — so neither a generic name nor a relation can
* excuse it.
*/
export function categorise(field: Field, usage: Usage): Category {
if (field.annotation === "internal") return "annotated-internal";
if (field.annotation === "pending-consumer") return "annotated-pending";
if (usage.mentions === 0) return "unwired";
// Prisma writes the FK when the caller connects the relation, and reads it
// back when the caller selects the relation. Neither shows up under the
// scalar's own name, so the matcher cannot judge these at all.
if (field.relationBacked.length > 0) return "relation-backed";
if (GENERIC_NAMES.has(field.name)) return "generic";
if (usage.writes === 0) {
// `@default(…)` / `@updatedAt` / `@id`: the database is the writer.
if (field.dbManaged) return "consumed";
return usage.reads > 0 || usage.queryUses > 0
? "read-no-writer"
: "unwired";
}
if (usage.reads > 0) return "consumed";
return usage.queryUses > 0 ? "query-only" : "write-no-reader";
}
// ---------------------------------------------------------------------------
// Driver
// ---------------------------------------------------------------------------
function walk(dir: string, out: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
if (SKIP_DIRS.has(entry)) continue;
const p = join(dir, entry);
if (statSync(p).isDirectory()) walk(p, out);
else if (/\.(ts|tsx|mts|mjs)$/.test(entry) && !/\.test\.tsx?$/.test(entry))
out.push(p);
}
return out;
}
function main() {
const args = process.argv.slice(2);
const modelFilter = args.includes("--model")
? args[args.indexOf("--model") + 1]
: null;
const showAll = args.includes("--all");
const fields = parseSchema(readFileSync(SCHEMA, "utf8")).filter(
(f) => !modelFilter || f.model === modelFilter,
);
const index = emptyIndex();
for (const dir of SCAN_DIRS)
for (const file of walk(join(ROOT, dir)))
indexSource(readFileSync(file, "utf8"), index);
const buckets = new Map<Category, Array<Field & Usage>>();
for (const field of fields) {
const usage = countUsage(index, field);
const category = categorise(field, usage);
buckets.set(category, [
...(buckets.get(category) ?? []),
{ ...field, ...usage },
]);
}
const bucket = (c: Category) => buckets.get(c) ?? [];
const scope = modelFilter ? `model ${modelFilter}` : "every model";
console.log(
`Column-reader sweep over ${scope} — ${fields.length} scalar column(s) ` +
`against ${SCAN_DIRS.join(" + ")}.\n`,
);
const section = (
title: string,
rows: Array<Field & Usage>,
line: (f: Field & Usage) => string,
) => {
if (rows.length === 0) return;
console.log(`## ${title} — ${rows.length}\n`);
for (const f of rows) console.log(` ${f.model}.${f.name} ${line(f)}`);
console.log("");
};
section(
"Written, and nothing anywhere consumes it",
bucket("write-no-reader"),
(f) => `(writes: ${f.writes})`,
);
section(
"Neither written nor read — nothing in the tree wires this column",
bucket("unwired"),
(f) => `(column "${f.column}", mentions: ${f.mentions})`,
);
section(
"Consumed only by a query clause — filtered or ordered on, never carried into JavaScript",
bucket("query-only"),
(f) => `(writes: ${f.writes}, query uses: ${f.queryUses})`,
);
section(
"Read but never written by application code",
bucket("read-no-writer"),
(f) => `(reads: ${f.reads}, query uses: ${f.queryUses})`,
);
section(
"Marked @pending-consumer — check whether the issue closed without the consumer landing",
bucket("annotated-pending"),
(f) => f.annotationText ?? "",
);
if (showAll) {
section(
"Marked @internal — permanently silenced, listed for review",
bucket("annotated-internal"),
(f) => f.annotationText ?? "",
);
section(
"Suppressed: name too generic for the matcher to judge",
bucket("generic"),
(f) => `(writes: ${f.writes}, reads: ${f.reads})`,
);
section(
"Carried by a relation — Prisma writes and reads these under the relation's name",
bucket("relation-backed"),
(f) => `(via ${f.relationBacked.join(", ")})`,
);
}
const quiet: Category[] = [
"consumed",
"relation-backed",
"annotated-internal",
"generic",
];
console.log(
"Not listed: " +
quiet.map((c) => `${bucket(c).length} ${c}`).join(", ") +
(showAll ? "" : " (re-run with --all to see the last three)"),
);
console.log(
"\nEvery finding above needs a human. Counts are per identifier rather than\n" +
"per model, whole-row serialization reads as unconsumed, and a non-Prisma\n" +
"`data:` literal reads as a write.",
);
}
if (
process.argv[1] &&
resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))
) {
main();
}