diff --git a/coverage/summary.json b/coverage/summary.json index 38b78de..154b395 100644 --- a/coverage/summary.json +++ b/coverage/summary.json @@ -1,6 +1,6 @@ { - "generatedAt": "2026-05-20T08:30:19.042Z", - "commit": "e2a5278", + "generatedAt": "2026-05-20T08:43:15.868Z", + "commit": "a478a8e", "repo": { "statements": 97.41, "branches": 92.31, diff --git a/packages/core-eslint/base.js b/packages/core-eslint/base.js index 7dc61c4..6508ef3 100644 --- a/packages/core-eslint/base.js +++ b/packages/core-eslint/base.js @@ -54,6 +54,7 @@ export default [ "conformance/atomic-tier-import-direction": "warn", "conformance/pii-declaration-must-be-complete": "warn", "conformance/no-undeclared-consent-check": ["warn", { repoRoot }], + "conformance/no-undeclared-rate-limit": ["warn", { repoRoot }], }, }, { diff --git a/packages/core-eslint/plugin.js b/packages/core-eslint/plugin.js index b10d88b..4675574 100644 --- a/packages/core-eslint/plugin.js +++ b/packages/core-eslint/plugin.js @@ -10,6 +10,7 @@ import componentMustHaveTest from "./rules/component-must-have-test.js"; import atomicTierImportDirection from "./rules/atomic-tier-import-direction.js"; import piiDeclarationMustBeComplete from "./rules/pii-declaration-must-be-complete.js"; import noUndeclaredConsentCheck from "./rules/no-undeclared-consent-check.js"; +import noUndeclaredRateLimit from "./rules/no-undeclared-rate-limit.js"; /** * The `@repo/core-eslint` conformance plugin. Aggregates custom rules that @@ -39,6 +40,7 @@ const plugin = { "atomic-tier-import-direction": atomicTierImportDirection, "pii-declaration-must-be-complete": piiDeclarationMustBeComplete, "no-undeclared-consent-check": noUndeclaredConsentCheck, + "no-undeclared-rate-limit": noUndeclaredRateLimit, }, }; diff --git a/packages/core-eslint/rules/_manifest-ast.js b/packages/core-eslint/rules/_manifest-ast.js index fb15039..07f96ca 100644 --- a/packages/core-eslint/rules/_manifest-ast.js +++ b/packages/core-eslint/rules/_manifest-ast.js @@ -87,6 +87,7 @@ function extractUseCaseEntry(objExpr) { publishes: [], consumes: [], analyticsEvents: [], + rateLimit: [], }; for (const prop of objExpr.properties) { if (prop.type !== "Property" || prop.key.type !== "Identifier") continue; @@ -97,7 +98,8 @@ function extractUseCaseEntry(objExpr) { (key === "audits" || key === "publishes" || key === "consumes" || - key === "analyticsEvents") && + key === "analyticsEvents" || + key === "rateLimit") && prop.value.type === "ArrayExpression" ) { entry[key] = extractStringLiterals(prop.value); @@ -219,6 +221,7 @@ function extractUseCaseEntryFromObj(objExpr) { publishes: [], consumes: [], analyticsEvents: [], + rateLimit: [], }; for (const prop of objExpr.properties) { if (prop.type !== "Property" || prop.key.type !== "Identifier") continue; @@ -229,7 +232,8 @@ function extractUseCaseEntryFromObj(objExpr) { (key === "audits" || key === "publishes" || key === "consumes" || - key === "analyticsEvents") && + key === "analyticsEvents" || + key === "rateLimit") && prop.value.type === "ArrayExpression" ) { entry[key] = extractStringLiterals(prop.value); diff --git a/packages/core-eslint/rules/_manifest-ast.test.js b/packages/core-eslint/rules/_manifest-ast.test.js index c06fbb9..feff64d 100644 --- a/packages/core-eslint/rules/_manifest-ast.test.js +++ b/packages/core-eslint/rules/_manifest-ast.test.js @@ -42,6 +42,7 @@ describe("parseManifestUseCases", () => { publishes: [], consumes: [], analyticsEvents: [], + rateLimit: [], }, signUp: { mutates: true, @@ -49,6 +50,7 @@ describe("parseManifestUseCases", () => { publishes: ["auth.signed-up"], consumes: [], analyticsEvents: [], + rateLimit: [], }, signOut: { mutates: true, @@ -56,6 +58,7 @@ describe("parseManifestUseCases", () => { publishes: [], consumes: [], analyticsEvents: [], + rateLimit: [], }, }); }); @@ -94,6 +97,7 @@ describe("parseManifestFully", () => { publishes: [], consumes: [], analyticsEvents: [], + rateLimit: [], }, signUp: { mutates: true, @@ -101,6 +105,7 @@ describe("parseManifestFully", () => { publishes: [], consumes: [], analyticsEvents: [], + rateLimit: [], }, }, }); diff --git a/packages/core-eslint/rules/no-undeclared-rate-limit.js b/packages/core-eslint/rules/no-undeclared-rate-limit.js new file mode 100644 index 0000000..70a282a --- /dev/null +++ b/packages/core-eslint/rules/no-undeclared-rate-limit.js @@ -0,0 +1,67 @@ +import { parseManifestUseCases } from "./_manifest-ast.js"; +import { manifestPathForFeature } from "./_manifest-source.js"; +import { repoRootSchema } from "./_rule-schema.js"; +import { resolveRuleContext } from "./_rule-context.js"; + +/** @type {import("eslint").Rule.RuleModule} */ +export default { + meta: { + type: "suggestion", + docs: { + description: + 'rateLimit.consume("X", _) inside a use-case file must match a budget declared in manifest.useCases[name].rateLimit.', + }, + schema: repoRootSchema, + messages: { + undeclared: + '{{useCase}} calls rateLimit.consume("{{budget}}") but "{{budget}}" is not declared in manifest.useCases.{{useCase}}.rateLimit. Add it to the manifest or remove the call.', + unusedDeclaration: + '{{useCase}} declares rateLimit budget "{{budget}}" in the manifest but rateLimit.consume("{{budget}}") is never called in this file. Add the consume call or remove the manifest declaration.', + }, + }, + create(context) { + const rc = resolveRuleContext(context); + if (!rc) return {}; + const { useCaseName, featureRoot } = rc; + const manifest = parseManifestUseCases(manifestPathForFeature(featureRoot)); + if (!manifest || !manifest[useCaseName]) return {}; + const declared = new Set(manifest[useCaseName].rateLimit ?? []); + const consumed = new Set(); + + return { + CallExpression(node) { + if ( + node.callee.type === "MemberExpression" && + node.callee.object.type === "Identifier" && + node.callee.object.name === "rateLimit" && + node.callee.property.type === "Identifier" && + node.callee.property.name === "consume" && + node.arguments.length > 0 && + node.arguments[0].type === "Literal" && + typeof node.arguments[0].value === "string" + ) { + const budget = node.arguments[0].value; + consumed.add(budget); + if (!declared.has(budget)) { + context.report({ + node, + messageId: "undeclared", + data: { budget, useCase: useCaseName }, + }); + } + } + }, + "Program:exit"(node) { + for (const budget of declared) { + if (!consumed.has(budget)) { + context.report({ + node, + messageId: "unusedDeclaration", + data: { budget, useCase: useCaseName }, + }); + } + } + }, + }; + }, +}; diff --git a/packages/core-eslint/rules/no-undeclared-rate-limit.test.js b/packages/core-eslint/rules/no-undeclared-rate-limit.test.js new file mode 100644 index 0000000..7f7cd30 --- /dev/null +++ b/packages/core-eslint/rules/no-undeclared-rate-limit.test.js @@ -0,0 +1,143 @@ +import { describe, it } from "vitest"; +import { RuleTester } from "eslint"; +import path from "node:path"; +import os from "node:os"; +import fs from "node:fs"; +import rule from "./no-undeclared-rate-limit.js"; + +function makeFixture({ manifestRateLimit, useCaseBody }) { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nurl-")); + const featureDir = path.join(repoRoot, "packages", "demo"); + fs.mkdirSync(path.join(featureDir, "src", "application", "use-cases"), { + recursive: true, + }); + const rateLimitField = + manifestRateLimit.length > 0 + ? `, rateLimit: [${manifestRateLimit.map((b) => `"${b}"`).join(", ")}]` + : ""; + fs.writeFileSync( + path.join(featureDir, "src", "feature.manifest.ts"), + `export const demoManifest = defineFeature({ + name: "demo", + requiredCores: [], + useCases: { + signUp: { mutates: true, audits: [], publishes: [], consumes: []${rateLimitField} }, + }, + realtimeChannels: [], + jobs: [], +} as const);`, + ); + const useCaseFile = path.join( + featureDir, + "src", + "application", + "use-cases", + "sign-up.use-case.ts", + ); + fs.writeFileSync(useCaseFile, useCaseBody); + return { repoRoot, useCaseFile }; +} + +const tester = new RuleTester({ + languageOptions: { + parser: await import("@typescript-eslint/parser"), + ecmaVersion: "latest", + sourceType: "module", + }, +}); + +describe("no-undeclared-rate-limit", () => { + it("passes when rateLimit.consume budget matches manifest rateLimit[]", () => { + const { repoRoot, useCaseFile } = makeFixture({ + manifestRateLimit: ["signUp.ip"], + useCaseBody: `export const signUpUseCase = (rateLimit) => async (input) => { await rateLimit.consume("signUp.ip", input.ip); };`, + }); + tester.run("no-undeclared-rate-limit", rule, { + valid: [ + { + filename: useCaseFile, + code: fs.readFileSync(useCaseFile, "utf8"), + options: [{ repoRoot }], + }, + ], + invalid: [], + }); + }); + + it("fires when rateLimit.consume budget is not declared in manifest", () => { + const { repoRoot, useCaseFile } = makeFixture({ + manifestRateLimit: [], + useCaseBody: `export const signUpUseCase = (rateLimit) => async (input) => { await rateLimit.consume("signUp.ip", input.ip); };`, + }); + tester.run("no-undeclared-rate-limit", rule, { + valid: [], + invalid: [ + { + filename: useCaseFile, + code: fs.readFileSync(useCaseFile, "utf8"), + options: [{ repoRoot }], + errors: [ + { + messageId: "undeclared", + data: { budget: "signUp.ip", useCase: "signUp" }, + }, + ], + }, + ], + }); + }); + + it("fires when a declared budget is never consumed in the use-case body", () => { + const { repoRoot, useCaseFile } = makeFixture({ + manifestRateLimit: ["signUp.ip"], + useCaseBody: `export const signUpUseCase = () => async () => { return { ok: true }; };`, + }); + tester.run("no-undeclared-rate-limit", rule, { + valid: [], + invalid: [ + { + filename: useCaseFile, + code: fs.readFileSync(useCaseFile, "utf8"), + options: [{ repoRoot }], + errors: [ + { + messageId: "unusedDeclaration", + data: { budget: "signUp.ip", useCase: "signUp" }, + }, + ], + }, + ], + }); + }); + + it("is a no-op for non-use-case files", () => { + const { repoRoot } = makeFixture({ + manifestRateLimit: ["signUp.ip"], + useCaseBody: `export const signUpUseCase = (rateLimit) => async (input) => { await rateLimit.consume("signUp.ip", input.ip); };`, + }); + const serviceFile = path.join( + repoRoot, + "packages", + "demo", + "src", + "application", + "services", + "sign-up.service.ts", + ); + fs.mkdirSync(path.dirname(serviceFile), { recursive: true }); + fs.writeFileSync( + serviceFile, + `export function doWork(rateLimit) { rateLimit.consume("undeclared", "x"); }`, + ); + tester.run("no-undeclared-rate-limit", rule, { + valid: [ + { + filename: serviceFile, + code: fs.readFileSync(serviceFile, "utf8"), + options: [{ repoRoot }], + }, + ], + invalid: [], + }); + }); +});