diff --git a/packages/core-eslint/rules/no-undeclared-audit.js b/packages/core-eslint/rules/no-undeclared-audit.js new file mode 100644 index 0000000..a136b7f --- /dev/null +++ b/packages/core-eslint/rules/no-undeclared-audit.js @@ -0,0 +1,67 @@ +import { parseManifestUseCases } from "./_manifest-ast.js"; +import { useCaseNameFromFile } from "./_usecase-name.js"; +import { manifestPathForFeature, featureRootForFile } from "./_manifest-source.js"; + +/** @type {import("eslint").Rule.RuleModule} */ +export default { + meta: { + type: "problem", + docs: { + description: + "auditLog.record({ type: \"X\" }) inside a use-case factory must declare X in manifest.useCases[name].audits.", + }, + schema: [ + { + type: "object", + properties: { repoRoot: { type: "string" } }, + additionalProperties: false, + }, + ], + messages: { + undeclared: + "{{useCase}} calls auditLog.record with type \"{{event}}\" but {{event}} is not declared in manifest.useCases.{{useCase}}.audits. Add it to the manifest or remove the call.", + }, + }, + create(context) { + const opts = context.options[0] ?? {}; + const repoRoot = opts.repoRoot ?? context.cwd ?? process.cwd(); + const filename = context.filename; + const useCaseName = useCaseNameFromFile(filename); + if (!useCaseName) return {}; + const featureRoot = featureRootForFile(filename, repoRoot); + if (!featureRoot) return {}; + const manifest = parseManifestUseCases(manifestPathForFeature(featureRoot)); + if (!manifest || !manifest[useCaseName]) return {}; + const declared = new Set(manifest[useCaseName].audits); + return { + CallExpression(node) { + if ( + node.callee.type === "MemberExpression" && + node.callee.object.type === "Identifier" && + node.callee.object.name === "auditLog" && + node.callee.property.type === "Identifier" && + node.callee.property.name === "record" && + node.arguments.length > 0 && + node.arguments[0].type === "ObjectExpression" + ) { + const typeProp = node.arguments[0].properties.find( + (p) => + p.type === "Property" && + p.key.type === "Identifier" && + p.key.name === "type", + ); + if ( + typeProp && + typeProp.value.type === "Literal" && + typeof typeProp.value.value === "string" + ) { + const event = typeProp.value.value; + if (!declared.has(event)) { + context.report({ node, messageId: "undeclared", data: { event, useCase: useCaseName } }); + } + } + } + }, + }; + }, +}; diff --git a/packages/core-eslint/rules/no-undeclared-audit.test.js b/packages/core-eslint/rules/no-undeclared-audit.test.js new file mode 100644 index 0000000..065a142 --- /dev/null +++ b/packages/core-eslint/rules/no-undeclared-audit.test.js @@ -0,0 +1,80 @@ +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-audit.js"; + +function makeFixture({ manifestUseCases, useCaseBody }) { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nua-")); + const featureDir = path.join(repoRoot, "packages", "demo"); + fs.mkdirSync(path.join(featureDir, "src", "application", "use-cases"), { recursive: true }); + const useCasesObj = Object.entries(manifestUseCases) + .map(([name, uc]) => + ` ${name}: { mutates: ${uc.mutates}, audits: [${uc.audits.map((a) => `"${a}"`).join(", ")}], publishes: [], consumes: [] },`, + ) + .join("\n"); + fs.writeFileSync( + path.join(featureDir, "src", "feature.manifest.ts"), + `export const demoManifest = defineFeature({ + name: "demo", + requiredCores: [], + useCases: { +${useCasesObj} + }, + 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-audit", () => { + it("passes when auditLog.record type matches manifest audits[]", () => { + const { repoRoot, useCaseFile } = makeFixture({ + manifestUseCases: { signUp: { mutates: true, audits: ["user.created"] } }, + useCaseBody: `export const signUpUseCase = (auditLog) => async () => { auditLog.record({ type: "user.created", subject: "x", actor: "y" }); };`, + }); + tester.run("no-undeclared-audit", rule, { + valid: [{ filename: useCaseFile, code: fs.readFileSync(useCaseFile, "utf8"), options: [{ repoRoot }] }], + invalid: [], + }); + }); + + it("fires when auditLog.record type is not in manifest", () => { + const { repoRoot, useCaseFile } = makeFixture({ + manifestUseCases: { signUp: { mutates: true, audits: [] } }, + useCaseBody: `export const signUpUseCase = (auditLog) => async () => { auditLog.record({ type: "user.created", subject: "x" }); };`, + }); + tester.run("no-undeclared-audit", rule, { + valid: [], + invalid: [{ + filename: useCaseFile, + code: fs.readFileSync(useCaseFile, "utf8"), + options: [{ repoRoot }], + errors: [{ messageId: "undeclared", data: { event: "user.created", useCase: "signUp" } }], + }], + }); + }); + + it("is a no-op when auditLog.record is called with a non-literal type", () => { + const { repoRoot, useCaseFile } = makeFixture({ + manifestUseCases: { signUp: { mutates: true, audits: [] } }, + useCaseBody: `export const signUpUseCase = (auditLog, type) => async () => { auditLog.record({ type, subject: "x" }); };`, + }); + tester.run("no-undeclared-audit", rule, { + valid: [{ filename: useCaseFile, code: fs.readFileSync(useCaseFile, "utf8"), options: [{ repoRoot }] }], + invalid: [], + }); + }); +});