fix(api): read every module a route reaches when listing the error codes

The catalogue guard read the route tree plus a hand-kept list of library
modules. The dose-history import builds its file-level codes in a module
that list never named, so `empty_file`, `missing_required_columns`,
`unreadable_json`, `json_not_an_array` and `json_carries_no_intake_time`
shipped on `POST /api/medications/intake/dose-history-import` while the
envelope's own description promised every code was enumerated below, and
the guard was green because it had not read the file.

The sweep now walks the import graph from every route under `src/app`:
1423 modules, resolved through the same `@/…` alias the app uses, with
the generated client never opened. A code is read where it is written,
however many hops from the route that answers with it, and nothing is
hand-listed any more. Seeding from the whole app tree rather than
`src/app/api` also reached the clinician share-link document route,
whose three codes were missing for the same reason.

A tuple of codes is read as a family, like the object form already was,
which is what makes the import's union visible; the constant holding it
is renamed to the `*_ERROR_CODES` spelling the sweep reads, since a list
named for anything else is as likely to be nutrients as error codes. The
two sync-ledger writers are cut out of the text first — `errorCode` on
`IntegrationStatus` is a different field with the same name.

Proven by breaking it both ways: dropping `empty_file` from the
catalogue turns the guard red, and a code planted in
`src/lib/analytics/compliance.ts`, a module no list ever named, turns it
red naming that file.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 08:31:44 +02:00
1 parent 868efc1c13
commit f9561395c7
4 files changed
+164 -65

No files matched your search

+13 -11
View File
@@ -29575,23 +29575,25 @@ components:
personal_records.invalid_query; practitioner — practitioner.invalid; profile —
profile.update.emailInUse, profile.update.invalidBody, profile.update.nothingSaved; report-selection —
report-selection.body.invalid_json, report-selection.body.invalid_shape; route — route.retired;
share-link — share-link.documents.invalid, share-link.report.notFound, share-link.report.rateLimited,
share-link.selection.forbidden_leaf, share-link.selection.unknown_leaf; sharing —
sharing.accept.expired, sharing.accept.not_found, sharing.accept.not_pending, sharing.accept.revoked,
sharing.access.denied, sharing.invite.duplicate, sharing.invite.invalid, sharing.invite.invalid_scope,
sharing.invite.manage_browser_only, sharing.invite.refused, sharing.invite.self, sharing.not_permitted,
sharing.renounce.already_ended, sharing.renounce.not_found, sharing.revoke.already_ended,
sharing.revoke.not_found, sharing.session.changed, sharing.switch.invalid,
share-link — share-link.document.decryptFailed, share-link.document.notFound,
share-link.document.rateLimited, share-link.documents.invalid, share-link.report.notFound,
share-link.report.rateLimited, share-link.selection.forbidden_leaf, share-link.selection.unknown_leaf;
sharing — sharing.accept.expired, sharing.accept.not_found, sharing.accept.not_pending,
sharing.accept.revoked, sharing.access.denied, sharing.invite.duplicate, sharing.invite.invalid,
sharing.invite.invalid_scope, sharing.invite.manage_browser_only, sharing.invite.refused,
sharing.invite.self, sharing.not_permitted, sharing.renounce.already_ended, sharing.renounce.not_found,
sharing.revoke.already_ended, sharing.revoke.not_found, sharing.session.changed, sharing.switch.invalid,
sharing.switch.wrong_transport; source-priority — source-priority.body.invalid_json,
source-priority.body.invalid_shape; tokens — tokens.measurements.ceiling_reached; vaccination —
vaccination.booster-invalid, vaccination.booster-no-antigen, vaccination.encounter-not-found,
vaccination.identity-required, vaccination.invalid, vaccination.link.invalid, vaccination.not-found,
vaccination.practitioner-not-found; workout — workout.batch.invalid, workout.batch.payload_too_large,
workout.batch.too_large; (unprefixed) — about_me_conflict, ai_response_truncated, coach_prefs_conflict,
dashboard_layout_conflict, health_score_config_conflict, insights_layout_conflict,
invalid_base_updated_at, medication_layout_conflict, modules_conflict, mood_tag_layout_conflict,
not_configured, notification_prefs_conflict, oidc_only, private_origin_not_approved, rate_limited_self,
too_many_rows, upstream_invalid_json, vapid_not_configured"
dashboard_layout_conflict, empty_file, health_score_config_conflict, insights_layout_conflict,
invalid_base_updated_at, json_carries_no_intake_time, json_not_an_array, medication_layout_conflict,
missing_required_columns, modules_conflict, mood_tag_layout_conflict, not_configured,
notification_prefs_conflict, oidc_only, private_origin_not_approved, rate_limited_self, too_many_rows,
unreadable_json, upstream_invalid_json, vapid_not_configured"
type: string
additionalProperties: {}
required:
+132 -50
View File
@@ -15,57 +15,59 @@
* it and wait forever.
*
* What it reads: every `.ts` under `src/app/api` outside a `__tests__`
* directory, plus the named library modules that build a refusal envelope of
* their own. Comment lines are stripped first, so a code quoted in prose is not
* directory, and then every module those files import, transitively, so a code
* built one or five hops away from the route that answers with it is read
* where it is written. Import specifiers are resolved the way the app resolves
* them (`@/…` and relative paths, `.ts` / `.tsx` / `index.*`); anything that
* leaves `src` is not followed, and the generated Prisma client is never
* opened. Comment lines are stripped first, so a code quoted in prose is not
* mistaken for one that ships.
*
* Its limits, stated rather than implied. A new library module that raises a
* coded refusal has to be added to `ENVELOPE_MODULES` below — the route tree is
* swept by rule, that list is not. A code assembled at runtime from a template
* cannot be enumerated at all, and the one family that does so is named in the
* catalogue's own doc comment. And `IntegrationStatus.errorCode` is a different
* field with the same name — it records an upstream HTTP status in the sync
* ledger and never reaches a response — which is why the sweep is scoped to
* the modules that answer requests rather than to `src/lib` wholesale.
* This used to be the route tree plus a hand-kept list of library modules, and
* the hand-kept list is exactly what it sounds like: the dose-history import
* built five codes in a module nobody had thought to add, so the guard read
* neither the codes nor the file and agreed the catalogue was complete. The
* import graph is not hand-kept — a new module reached by a route is swept the
* first time a route imports it.
*
* Its limits, stated rather than implied. A code assembled at runtime from a
* template cannot be enumerated at all, and the one family that does so is
* named in the catalogue's own doc comment. A code that reaches the envelope
* through a variable is found where the values are declared, which means a
* literal in a constant the sweep's shapes do not cover would still be missed:
* a constant that holds wire codes is named `*_CODE` / `*_CODES` for that
* reason. And `IntegrationStatus.errorCode` is a different field with the same
* name — it records an upstream HTTP status in the sync ledger and never
* reaches a response — so the two ledger writers that take it are cut out of
* the text before it is matched.
*/
import { readFileSync, readdirSync } from "node:fs";
import { join, relative, sep } from "node:path";
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import { dirname, join, relative, resolve, sep } from "node:path";
import { describe, expect, it } from "vitest";
import { ERROR_CODE_CATALOGUE } from "@/lib/openapi/error-codes";
const API_ROOT = join(process.cwd(), "src", "app", "api");
const SRC_ROOT = join(process.cwd(), "src");
const APP_ROOT = join(SRC_ROOT, "app");
/** Never opened: the generated Prisma client is megabytes of machine output. */
const GENERATED_ROOT = join(SRC_ROOT, "generated");
/**
* Library modules that build a coded refusal without going through a route
* file. Everything else under `src/lib` is out of scope on purpose — see the
* note on `IntegrationStatus.errorCode` in the file header.
* The sync ledger's writers. `IntegrationStatus.errorCode` records an upstream
* HTTP status and never reaches a response envelope, so their arguments are cut
* out before a code is looked for. A third writer would surface as an
* undocumented code rather than as silence, which is the direction to fail in.
*/
const ENVELOPE_MODULES = [
"src/lib/ai/consent-guard.ts",
"src/lib/api-errors.ts",
"src/lib/api-handler.ts",
"src/lib/auth/profile-update.ts",
"src/lib/clinician-share/report-download.ts",
"src/lib/cycle/gate.ts",
"src/lib/documents/ai-route-support.ts",
"src/lib/documents/attach-validate.ts",
"src/lib/export/backup-blob.ts",
"src/lib/http/retired-routes.ts",
"src/lib/illness/gate.ts",
"src/lib/modules/gate.ts",
"src/lib/optimistic-lock.ts",
"src/lib/sharing/record-session-fence-contract.ts",
];
const LEDGER_WRITERS = ["recordSyncFailure", "parkIntegrationAtReauth"];
function apiFiles(dir: string): string[] {
function tsFiles(dir: string): string[] {
const found: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === "__tests__") continue;
found.push(...apiFiles(full));
found.push(...tsFiles(full));
continue;
}
if (entry.name.endsWith(".ts")) found.push(full);
@@ -73,6 +75,76 @@ function apiFiles(dir: string): string[] {
return found;
}
/** Resolve one import specifier the way the app's path alias resolves it. */
function resolveSpecifier(specifier: string, fromFile: string): string | null {
let base: string;
if (specifier.startsWith("@/")) base = join(SRC_ROOT, specifier.slice(2));
else if (specifier.startsWith("."))
base = resolve(dirname(fromFile), specifier);
else return null;
for (const candidate of [
`${base}.ts`,
`${base}.tsx`,
join(base, "index.ts"),
join(base, "index.tsx"),
]) {
if (existsSync(candidate) && statSync(candidate).isFile()) return candidate;
}
return null;
}
/** Every module a route reaches, however many hops away it is written. */
function modulesUnderTheRoutes(): string[] {
const queue = tsFiles(APP_ROOT);
const seen = new Set(queue);
for (let i = 0; i < queue.length; i++) {
const file = queue[i];
const source = readFileSync(file, "utf8");
const specifiers = [
...source.matchAll(
/(?:^|\n)\s*(?:import|export)[\s\S]{0,400}?from\s*"([^"]+)"/g,
),
...source.matchAll(/import\(\s*"([^"]+)"\s*\)/g),
];
for (const match of specifiers) {
const resolved = resolveSpecifier(match[1], file);
if (!resolved) continue;
if (resolved.startsWith(GENERATED_ROOT)) continue;
if (resolved.includes(`${sep}__tests__${sep}`)) continue;
if (seen.has(resolved)) continue;
seen.add(resolved);
queue.push(resolved);
}
}
return [...seen];
}
/** Cut the sync-ledger writers' arguments out, parenthesis-balanced. */
function withoutLedgerWrites(source: string): string {
let text = source;
for (const writer of LEDGER_WRITERS) {
for (;;) {
const call = text.indexOf(`${writer}(`);
if (call === -1) break;
const open = call + writer.length;
let depth = 0;
let close = text.length;
for (let j = open; j < text.length; j++) {
if (text[j] === "(") depth++;
else if (text[j] === ")") {
depth--;
if (depth === 0) {
close = j + 1;
break;
}
}
}
text = text.slice(0, call) + text.slice(close);
}
}
return text;
}
/** Drop comment lines so a code quoted in prose is not read as one that ships. */
function withoutComments(source: string): string {
return source
@@ -88,7 +160,7 @@ function withoutComments(source: string): string {
.join("\n");
}
/** The three shapes a code is written in across the tree. */
/** The shapes a code is written in across the tree. */
function codesIn(source: string): string[] {
const found: string[] = [];
// `errorCode: "x"`, `errorCode = "x"`, `readonly errorCode = "x"`, and the
@@ -102,14 +174,22 @@ function codesIn(source: string): string[] {
for (const m of source.matchAll(/\b[A-Z][A-Z0-9_]*_CODE\s*=\s*"([^"]+)"/g)) {
found.push(m[1]);
}
// A family in one object: `export const AUTH_ERROR_CODES = { … }`.
for (const m of source.matchAll(/\b[A-Z][A-Z0-9_]*_CODES\s*=\s*\{/g)) {
const start = source.indexOf("{", m.index);
// A family in one object: `export const AUTH_ERROR_CODES = { … }`, or in one
// tuple: `export const AUTO_EXPORT_FATAL_ERROR_CODES = [ … ] as const`. The
// tuple is how a union type of codes is declared, and that union is what an
// envelope relays when it writes `errorCode: parsed.fatal.reason`. Only the
// `_ERROR_CODES` spelling is read in tuple form: plenty of unrelated
// catalogues (nutrients, say) are lists of codes that are not error codes.
for (const m of source.matchAll(/\b([A-Z][A-Z0-9_]*_CODES)\s*=\s*([{[])/g)) {
const [, name, opener] = m;
if (opener === "[" && !name.endsWith("_ERROR_CODES")) continue;
const closer = opener === "{" ? "}" : "]";
const start = source.indexOf(opener, m.index);
let depth = 0;
let end = start;
for (let j = start; j < source.length; j++) {
if (source[j] === "{") depth++;
else if (source[j] === "}") {
if (source[j] === opener) depth++;
else if (source[j] === closer) {
depth--;
if (depth === 0) {
end = j;
@@ -117,9 +197,12 @@ function codesIn(source: string): string[] {
}
}
}
for (const value of source.slice(start, end).matchAll(/:\s*"([^"]+)"/g)) {
found.push(value[1]);
}
const body = source.slice(start, end);
const values =
opener === "{"
? body.matchAll(/:\s*"([^"]+)"/g)
: body.matchAll(/"([^"]+)"/g);
for (const value of values) found.push(value[1]);
}
return found;
}
@@ -127,12 +210,10 @@ function codesIn(source: string): string[] {
/** Every code the tree emits, mapped to the files that emit it. */
function emittedCodes(): Map<string, string[]> {
const emitted = new Map<string, string[]>();
const files = [
...apiFiles(API_ROOT),
...ENVELOPE_MODULES.map((path) => join(process.cwd(), path)),
];
for (const file of files) {
const source = withoutComments(readFileSync(file, "utf8"));
for (const file of modulesUnderTheRoutes()) {
const source = withoutLedgerWrites(
withoutComments(readFileSync(file, "utf8")),
);
for (const code of codesIn(source)) {
const where = relative(process.cwd(), file).split(sep).join("/");
const seen = emitted.get(code);
@@ -152,7 +233,8 @@ const published = new Set(Object.values(ERROR_CODE_CATALOGUE).flat());
describe("the published errorCode catalogue", () => {
it("finds the codes it is meant to judge", () => {
// A matcher that stopped matching would otherwise agree with an empty
// catalogue. Two hundred and forty-two when this was written.
// catalogue. Two hundred and twenty-nine when this was written, read out
// of the fourteen hundred modules the route tree reaches.
expect(emitted.size).toBeGreaterThan(200);
});
@@ -92,8 +92,14 @@ export interface AutoExportRefusal {
reason: AutoExportRefusalReason;
}
/** A file-level problem: nothing can be read at all. */
export const AUTO_EXPORT_FATAL_REASONS = [
/**
* A file-level problem: nothing can be read at all.
*
* These values ship as `meta.errorCode` on the import route's 422, which is
* why the constant is named for what they are on the wire rather than for the
* field that carries them internally.
*/
export const AUTO_EXPORT_FATAL_ERROR_CODES = [
"empty_file",
"missing_required_columns",
"unreadable_json",
@@ -102,10 +108,11 @@ export const AUTO_EXPORT_FATAL_REASONS = [
"json_carries_no_intake_time",
] as const;
export type AutoExportFatalReason = (typeof AUTO_EXPORT_FATAL_REASONS)[number];
export type AutoExportFatalErrorCode =
(typeof AUTO_EXPORT_FATAL_ERROR_CODES)[number];
export interface AutoExportParseOutcome {
fatal?: { reason: AutoExportFatalReason; detail?: string };
fatal?: { reason: AutoExportFatalErrorCode; detail?: string };
doses: AutoExportDose[];
refusals: AutoExportRefusal[];
/** Data rows read, refusals included. `doses + refusals` must equal it. */
+8
View File
@@ -257,6 +257,9 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
],
route: ["route.retired"],
"share-link": [
"share-link.document.decryptFailed",
"share-link.document.notFound",
"share-link.document.rateLimited",
"share-link.documents.invalid",
"share-link.report.notFound",
"share-link.report.rateLimited",
@@ -309,10 +312,14 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"ai_response_truncated",
"coach_prefs_conflict",
"dashboard_layout_conflict",
"empty_file",
"health_score_config_conflict",
"insights_layout_conflict",
"invalid_base_updated_at",
"json_carries_no_intake_time",
"json_not_an_array",
"medication_layout_conflict",
"missing_required_columns",
"modules_conflict",
"mood_tag_layout_conflict",
"not_configured",
@@ -321,6 +328,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"private_origin_not_approved",
"rate_limited_self",
"too_many_rows",
"unreadable_json",
"upstream_invalid_json",
"vapid_not_configured",
],