Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/api/scripts/lint-meta/RULES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
6 changes: 6 additions & 0 deletions apps/api/scripts/lint-meta/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -133,6 +137,8 @@ export {
checkLogicFilesHaveTests,
checkNoDirectProcessEnv,
checkNoRawRoleLiterals,
checkSchemaEnumFieldConsistency,
inconsistentEnumFields,
checkPackageOverrideParity,
checkPrePushParity,
checkPrePushScannerParity,
Expand Down
2 changes: 2 additions & 0 deletions apps/api/scripts/lint-meta/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -66,6 +67,7 @@ export const META_RULES: readonly IMetaRule[] = [
docsNoRetiredCredentialsRule,
externalClientTimeoutRule,
noRawRoleLiteralsRule,
schemaEnumFieldConsistencyRule,
auditLogReadAccountScopedRule,
routesRequireTestSiblingRule,
logicFilesRequireTestSiblingRule,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string> {
const out = new Set<string>();
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<string> {
const out = new Set<string>();
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);
},
};
74 changes: 74 additions & 0 deletions apps/api/tests/lint-meta/lint-meta.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ import {
findWorkflows,
checkGeneratedArtifactContracts,
checkNoRawRoleLiterals,
checkSchemaEnumFieldConsistency,
inconsistentEnumFields,
checkPackageOverrideParity,
checkPrePushParity,
checkSharedToolVersionParity,
Expand Down Expand Up @@ -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-"));
Expand Down
6 changes: 6 additions & 0 deletions apps/docs/src/data/lint-meta-catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading