From 14e40eb067bc66b84a946d0505d851829c7945d9 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Fri, 17 Jul 2026 12:56:09 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(lint-meta):=20schema-enum-field-consis?= =?UTF-8?q?tency=20=E2=80=94=20keep=20the=20generated=20API=20client=20pre?= =?UTF-8?q?cise?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fixed-value TypeBox field (typed t.Union([t.Literal(...)]) in any schema in a *.schemas.ts file) must be typed the SAME way in every schema in that file — never widened to t.String(), typically in the response schema. Why it's a gate error: the response schema becomes the OpenAPI spec, which generate:api turns into the UI's typed client — the single source of truth for the UI's API types. A status field that's an enum on input but t.String() on the response makes the generated client type it `string`; the UI (expecting the enum) then can't reconcile the two, and a weak model "fixes" the clash by reverting/stubbing the feature instead of tightening the schema. Observed live: a TaskFlow build oscillated near-green for 82 cycles and parked, because task status/priority were t.Union([t.Literal…]) on create/update but t.String() on TaskResponse. This rule fails the build on that drift so the model must fix the schema (define the enum once, reuse it everywhere), not delete its UI. Text-based (whitespace-collapsed), matching the other source-text rules; scoped to src/**/*.schemas.ts so fixtures/tests aren't linted. Pure analyzer (inconsistentEnumFields) unit-tested; RULES.md regenerated; no violations on the current API source. --- apps/api/scripts/lint-meta/RULES.md | 1 + apps/api/scripts/lint-meta/cli.ts | 6 + apps/api/scripts/lint-meta/registry.ts | 2 + .../schema-enum-field-consistency.ts | 117 ++++++++++++++++++ apps/api/tests/lint-meta/lint-meta.test.ts | 74 +++++++++++ 5 files changed, 200 insertions(+) create mode 100644 apps/api/scripts/lint-meta/rules/source-text/schema-enum-field-consistency.ts diff --git a/apps/api/scripts/lint-meta/RULES.md b/apps/api/scripts/lint-meta/RULES.md index fba5b3da..7e1f64ff 100644 --- a/apps/api/scripts/lint-meta/RULES.md +++ b/apps/api/scripts/lint-meta/RULES.md @@ -44,6 +44,7 @@ Run `bun run lint:meta --list-rules` for the machine-readable list from the regi | `docs-no-retired-credentials` | source-text | no | Documentation prose must not reference retired default credentials. | | `external-client-timeout` | source-text | no | SDK clients (Stripe/OpenAI/Anthropic) need a timeout option; email transports (Resend/SendGrid/nodemailer) must be bounded; fetch() in src needs an AbortSignal. | | `no-raw-role-literal` | source-text | no | Use ROLE.* from acl.constants.ts instead of raw owner/admin/member/viewer string literals. | +| `schema-enum-field-consistency` | source-text | no | A fixed-value field typed as t.Union([t.Literal(...)]) in one schema must not be t.String() in another schema in the same file — keep the generated API client precise. | | `audit-log-read-account-scoped` | source-text | no | Queries filtering auditLog by userId must also reference auditLog.targetAccountId — userId-only reads bleed a multi-account user's events across tenant boundaries. | | `routes-require-test-sibling` | testing | no | Route modules must ship with a matching HTTP-level test under tests/api/. | | `logic-files-require-test-sibling` | testing | no | Logic modules must ship with a matching tests/**/*.test.ts sibling. | diff --git a/apps/api/scripts/lint-meta/cli.ts b/apps/api/scripts/lint-meta/cli.ts index c3762427..4076fc7e 100644 --- a/apps/api/scripts/lint-meta/cli.ts +++ b/apps/api/scripts/lint-meta/cli.ts @@ -54,6 +54,10 @@ import { checkDocsNoRetiredCredentials } from "./rules/source-text/docs-no-retir import { checkExternalClientTimeouts } from "./rules/source-text/external-client-timeout"; import { checkForbiddenText } from "./rules/source-text/forbidden-text"; import { checkNoRawRoleLiterals } from "./rules/source-text/no-raw-role-literals"; +import { + checkSchemaEnumFieldConsistency, + inconsistentEnumFields, +} from "./rules/source-text/schema-enum-field-consistency"; import { checkLintMetaRulesSelfCovered } from "./rules/testing/lint-meta-rules-self-covered"; import { checkLogicFilesHaveTests } from "./rules/testing/logic-files-require-test-sibling"; import { checkRouteFilesHaveTests } from "./rules/testing/routes-require-test-sibling"; @@ -133,6 +137,8 @@ export { checkLogicFilesHaveTests, checkNoDirectProcessEnv, checkNoRawRoleLiterals, + checkSchemaEnumFieldConsistency, + inconsistentEnumFields, checkPackageOverrideParity, checkPrePushParity, checkPrePushScannerParity, diff --git a/apps/api/scripts/lint-meta/registry.ts b/apps/api/scripts/lint-meta/registry.ts index 62b29583..9192e99c 100644 --- a/apps/api/scripts/lint-meta/registry.ts +++ b/apps/api/scripts/lint-meta/registry.ts @@ -27,6 +27,7 @@ import { docsNoRetiredCredentialsRule } from "./rules/source-text/docs-no-retire import { externalClientTimeoutRule } from "./rules/source-text/external-client-timeout"; import { forbiddenTextRule } from "./rules/source-text/forbidden-text"; import { noRawRoleLiteralsRule } from "./rules/source-text/no-raw-role-literals"; +import { schemaEnumFieldConsistencyRule } from "./rules/source-text/schema-enum-field-consistency"; import { noOverlappingLibsRule } from "./rules/supply-chain/no-overlapping-libs"; import { packageJsonExactDepsRule } from "./rules/supply-chain/package-json-exact-deps"; import { packageOverrideParityRule } from "./rules/supply-chain/package-override-parity"; @@ -66,6 +67,7 @@ export const META_RULES: readonly IMetaRule[] = [ docsNoRetiredCredentialsRule, externalClientTimeoutRule, noRawRoleLiteralsRule, + schemaEnumFieldConsistencyRule, auditLogReadAccountScopedRule, routesRequireTestSiblingRule, logicFilesRequireTestSiblingRule, diff --git a/apps/api/scripts/lint-meta/rules/source-text/schema-enum-field-consistency.ts b/apps/api/scripts/lint-meta/rules/source-text/schema-enum-field-consistency.ts new file mode 100644 index 00000000..12e37ffc --- /dev/null +++ b/apps/api/scripts/lint-meta/rules/source-text/schema-enum-field-consistency.ts @@ -0,0 +1,117 @@ +import { readFileSync } from "node:fs"; + +import type { IMetaRule, IViolation } from "../../types"; + +/** + * A fixed-value field (one typed as a `t.Union([t.Literal(...)])` in ANY schema in a + * `*.schemas.ts` file) must be typed the SAME way in EVERY schema in that file — never + * widened to `t.String()` (e.g. in the response schema). + * + * Why this is a gate error, not a style nit: the response schema becomes the OpenAPI + * spec, which `generate:api` turns into the UI's typed client — the single source of + * truth for the UI's API types. If a `status` field is `t.Union([t.Literal("todo"),…])` + * on input but `t.String()` on the response, the generated client types the response + * `status` as `string`. The UI (which expects the enum) then can't reconcile `string` + * with `"todo"|"doing"|"done"`, and a weak model "fixes" the clash by reverting/stubbing + * the feature instead of tightening the schema — the exact near-green oscillation this + * rule prevents. Define the enum once and reuse it in create/update/response/query. + */ + +/** + * Field names given a `t.Union([t.Literal(...)])` type anywhere in the (whitespace- + * collapsed) schema source. + */ +function literalUnionFields(collapsed: string): Set { + const out = new Set(); + const re = + /(\w+)\s*:\s*(?:t\.Optional\(\s*)?t\.Union\(\s*\[\s*t\.Literal\b/gu; + + for (const m of collapsed.matchAll(re)) { + if (m[1] !== undefined) { + out.add(m[1]); + } + } + + return out; +} + +/** + * Field names given a bare `t.String(...)` type (optionally wrapped in `t.Optional`). + * Deliberately does NOT match `t.Union([t.String(), t.Null()])` (a nullable string is + * not an enum field) — the `t.String` must sit directly under the field or `t.Optional`. + */ +function plainStringFields(collapsed: string): Set { + const out = new Set(); + const re = /(\w+)\s*:\s*(?:t\.Optional\(\s*)?t\.String\s*\(/gu; + + for (const m of collapsed.matchAll(re)) { + if (m[1] !== undefined) { + out.add(m[1]); + } + } + + return out; +} + +/** + * Pure analyzer (no filesystem): the field names that are BOTH a literal-union enum and + * a bare t.String() somewhere in one schema source — i.e. inconsistently typed. Sorted + * for deterministic output. Exported for direct unit testing. + */ +export function inconsistentEnumFields(src: string): string[] { + if (!src.includes("t.Literal(")) { + return []; + } + + const collapsed = src.replace(/\s+/gu, " "); + const enums = literalUnionFields(collapsed); + const strings = plainStringFields(collapsed); + + return [...enums].filter((field) => strings.has(field)).sort(); +} + +export function checkSchemaEnumFieldConsistency( + root: string, + files: readonly string[] +): IViolation[] { + const violations: IViolation[] = []; + + for (const file of files) { + const relative = file.startsWith(root) ? file.slice(root.length + 1) : file; + + // Only real TypeBox schema files (never test fixtures): under src/, `*.schemas.ts`. + if (!/(?:^|[/\\])src[/\\].*\.schemas\.ts$/u.test(relative)) { + continue; + } + + for (const field of inconsistentEnumFields(readFileSync(file, "utf8"))) { + violations.push({ + file, + rule: "schema-enum-field-consistency", + message: + `Field '${field}' is a t.Union([t.Literal(...)]) enum in one schema in this ` + + `file but t.String() in another. Widening it to t.String() (typically in the ` + + `response schema) makes the generated API client type '${field}' as a plain ` + + `string, which the UI can't reconcile with the enum. Define the enum ONCE and ` + + `reuse it in every schema (create/update/response/query) — never t.String().`, + }); + } + } + + return violations; +} + +/** + * Enum-like TypeBox fields must be typed identically across every schema in a file + * (never widened to t.String() in the response), so the generated client stays precise. + */ +export const schemaEnumFieldConsistencyRule: IMetaRule = { + id: "schema-enum-field-consistency", + category: "source-text", + description: + "A fixed-value field typed as t.Union([t.Literal(...)]) in one schema must not be " + + "t.String() in another schema in the same file — keep the generated API client precise.", + run({ root, sourceFiles }) { + return checkSchemaEnumFieldConsistency(root, sourceFiles); + }, +}; diff --git a/apps/api/tests/lint-meta/lint-meta.test.ts b/apps/api/tests/lint-meta/lint-meta.test.ts index 62fb035e..21fce545 100644 --- a/apps/api/tests/lint-meta/lint-meta.test.ts +++ b/apps/api/tests/lint-meta/lint-meta.test.ts @@ -48,6 +48,8 @@ import { findWorkflows, checkGeneratedArtifactContracts, checkNoRawRoleLiterals, + checkSchemaEnumFieldConsistency, + inconsistentEnumFields, checkPackageOverrideParity, checkPrePushParity, checkSharedToolVersionParity, @@ -1151,6 +1153,78 @@ describe("checkWorkflowExpressionSyntax", () => { const CONTRACT_MD = "AGENT_CONTRACT.md"; const PKG_JSON = "package.json"; +describe("schema-enum-field-consistency", () => { + /* + * The exact shape of the real regression: status/priority are literal-union enums on + * input but t.String() on the response — the generated client widens them to `string` + * and the UI enum can't reconcile. + */ + const DRIFT = `import { t } from "elysia"; +export const CreateTaskSchema = t.Object({ + status: t.Optional( + t.Union([t.Literal("todo"), t.Literal("doing"), t.Literal("done")]) + ), + priority: t.Optional(t.Union([t.Literal("low"), t.Literal("high")])), +}); +export const TaskResponse = t.Object({ + id: t.String(), + description: t.Optional(t.Union([t.String(), t.Null()])), + status: t.String(), + priority: t.String(), +});`; + + const CLEAN = `import { t } from "elysia"; +const TaskStatus = t.Union([t.Literal("todo"), t.Literal("doing"), t.Literal("done")]); +export const CreateTaskSchema = t.Object({ + status: t.Optional(TaskStatus), +}); +export const TaskResponse = t.Object({ + id: t.String(), + description: t.Optional(t.Union([t.String(), t.Null()])), + status: TaskStatus, +});`; + + test("flags every enum field widened to t.String() elsewhere in the file", () => { + expect(inconsistentEnumFields(DRIFT)).toEqual(["priority", "status"]); + }); + + test("passes when the enum is defined once and reused (never t.String())", () => { + expect(inconsistentEnumFields(CLEAN)).toEqual([]); + }); + + test("a nullable string (t.Union([t.String(), t.Null()])) is not treated as an enum", () => { + expect(inconsistentEnumFields(DRIFT)).not.toContain("description"); + }); + + test("checkSchemaEnumFieldConsistency reports drift only for src/*.schemas.ts", () => { + const root = mkdtempSync(join(tmpdir(), "lint-meta-schema-enum-")); + + try { + mkdirSync(join(root, "src", "api", "task"), { recursive: true }); + const schema = join(root, "src", "api", "task", "task.schemas.ts"); + + writeFileSync(schema, DRIFT); + + const rules = checkSchemaEnumFieldConsistency(root, [schema]).map( + (row) => row.rule + ); + + expect(rules).toEqual([ + "schema-enum-field-consistency", + "schema-enum-field-consistency", + ]); + + // A non-schema file with the same content is ignored (path filter). + const other = join(root, "src", "api", "task", "task.ts"); + + writeFileSync(other, DRIFT); + expect(checkSchemaEnumFieldConsistency(root, [other])).toEqual([]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + describe("checkAuditLogReadAccountScoped", () => { test("flags userId-only auditLog reads; passes account-scoped and write-path files", () => { const root = mkdtempSync(join(tmpdir(), "lint-meta-audit-scope-")); From 68edc4909b5732c8d622e70f46d1b7b8087af3d0 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Fri, 17 Jul 2026 13:00:46 +0200 Subject: [PATCH 2/2] docs: regenerate lint-meta catalog for schema-enum-field-consistency (CI linkcheck) --- apps/docs/src/data/lint-meta-catalog.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/docs/src/data/lint-meta-catalog.json b/apps/docs/src/data/lint-meta-catalog.json index e2ccee47..a3cf6a16 100644 --- a/apps/docs/src/data/lint-meta-catalog.json +++ b/apps/docs/src/data/lint-meta-catalog.json @@ -434,6 +434,12 @@ "ciCritical": false, "description": "Use ROLE.* from acl.constants.ts instead of raw owner/admin/member/viewer string literals." }, + { + "id": "schema-enum-field-consistency", + "category": "source-text", + "ciCritical": false, + "description": "A fixed-value field typed as t.Union([t.Literal(...)]) in one schema must not be t.String() in another schema in the same file — keep the generated API client precise." + }, { "id": "audit-log-read-account-scoped", "category": "source-text",