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-")); 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",