From 1cab88916a5400d79e7f362708dc2ed835d26dd1 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Tue, 19 May 2026 11:51:30 +0000 Subject: [PATCH] feat(core-eslint): add no-undeclared-consent-check rule (conformance gate 12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the conformance ESLint layer with the consent-check rule: - `no-undeclared-consent-check` (warn): `consent.isGranted("X")` in a use-case file must match a category declared in `manifest.requiresConsent`; also warns when requiresConsent is declared but no isGranted call is found. - `_manifest-ast.js`: adds `parseManifestFully` which extracts top-level `name`, `requiredCores`, `requiresConsent`, and per-use-case maps from the manifest AST; `requiresConsent` extraction tested in `_manifest-ast.test.js`. - `_rule-context.js` / `_rule-schema.js`: shared helpers extracted from the existing per-rule files so the new rule can resolve use-case name + feature root without duplication. - Existing rules (`no-undeclared-audit`, `no-undeclared-event-publish`, `no-undeclared-analytics-event`) updated to use the shared helpers. - `plugin.js` + `base.js` register the rule at warn severity. - CLAUDE.md + conformance-quickref.md: rule count advanced from 11 → 12. Co-Authored-By: Claude Sonnet 4.6 --- .fallowrc.json | 4 +- CLAUDE.md | 2 +- docs/guides/conformance-quickref.md | 21 +-- packages/core-eslint/base.js | 1 + packages/core-eslint/plugin.js | 2 + packages/core-eslint/rules/_manifest-ast.js | 55 ++++---- .../core-eslint/rules/_manifest-ast.test.js | 19 +++ packages/core-eslint/rules/_rule-context.js | 18 +++ packages/core-eslint/rules/_rule-schema.js | 8 ++ .../rules/no-undeclared-analytics-event.js | 26 +--- .../core-eslint/rules/no-undeclared-audit.js | 33 ++--- .../rules/no-undeclared-consent-check.js | 68 +++++++++ .../rules/no-undeclared-consent-check.test.js | 132 ++++++++++++++++++ .../rules/no-undeclared-event-publish.js | 33 ++--- 14 files changed, 327 insertions(+), 95 deletions(-) create mode 100644 packages/core-eslint/rules/_rule-context.js create mode 100644 packages/core-eslint/rules/_rule-schema.js create mode 100644 packages/core-eslint/rules/no-undeclared-consent-check.js create mode 100644 packages/core-eslint/rules/no-undeclared-consent-check.test.js diff --git a/.fallowrc.json b/.fallowrc.json index 26024d4..785ae90 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -40,7 +40,9 @@ "zod", "@eslint/js", "@opentelemetry/sdk-node", - "@sentry/opentelemetry" + "@sentry/opentelemetry", + "@stryker-mutator/core", + "@stryker-mutator/vitest-runner" ], "ignoreExportsUsedInFile": true, "rules": { diff --git a/CLAUDE.md b/CLAUDE.md index 949f85f..fe33e51 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,7 +72,7 @@ Every feature has a `src/feature.manifest.ts` declaring its use cases, audits, p | **CI drift gate** (`pnpm conformance`) | ~120s | orphan event consumers across features | | **Fallow** (`pnpm fallow`) | ~30–60s | dead exports / unused files; duplicate code; circular deps; complexity hotspots; AI-change audit drift | -The eight conformance ESLint rules: `feature-must-have-manifest` (error), `usecase-must-have-test-file` (error), `required-cores-installed` (error), `usecase-must-be-wired` (error), `no-undeclared-event-publish` (warn), `no-undeclared-audit` (warn), `no-undeclared-analytics-event` (warn), `pii-declaration-must-be-complete` (warn). Fallow runs as a fifth layer, post-ESLint, whole-codebase. +The twelve conformance ESLint rules: `feature-must-have-manifest` (error), `usecase-must-have-test-file` (error), `required-cores-installed` (error), `usecase-must-be-wired` (error), `no-undeclared-event-publish` (warn), `no-undeclared-audit` (warn), `no-undeclared-analytics-event` (warn), `pii-declaration-must-be-complete` (warn), `component-must-have-story` (warn), `component-must-have-test` (warn), `atomic-tier-import-direction` (warn), `no-undeclared-consent-check` (warn). Fallow runs as a fifth layer, post-ESLint, whole-codebase. See `docs/architecture/agent-first-workflow-and-conformance.md` for the full design and `docs/guides/conformance-quickref.md` for the day-to-day reference. diff --git a/docs/guides/conformance-quickref.md b/docs/guides/conformance-quickref.md index 88d4142..b291423 100644 --- a/docs/guides/conformance-quickref.md +++ b/docs/guides/conformance-quickref.md @@ -86,16 +86,17 @@ The symbol map declares which container symbol each manifest use-case key resolv ## ESLint rules -| Rule | Severity | What it does | -| ---------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------- | -| `conformance/feature-must-have-manifest` | error | Use-case files require a sibling manifest | -| `conformance/usecase-must-have-test-file` | error | Every `*.use-case.ts` has a sibling `*.use-case.test.ts` | -| `conformance/required-cores-installed` | error | Manifest's `requiredCores` must exist as `core-` packages in pnpm-workspace.yaml | -| `conformance/no-undeclared-event-publish` | warn | `bus.publish("X")` literal must match the manifest's `publishes` for the use case | -| `conformance/no-undeclared-audit` | warn | `auditLog.record({ type: "X" })` literal must match the manifest's `audits` | -| `conformance/usecase-must-be-wired` | error | Every manifest use case must be bound via `wireUseCase({ name: "" })` in `bind-production.ts` / `bind-dev-seed.ts` | -| `conformance/no-undeclared-analytics-event` | warn | `analytics.track("X")` literal must match the manifest's `analyticsEvents` for the use case | -| `conformance/pii-declaration-must-be-complete` | warn | `custom.pii` blocks in Payload config files must declare all required fields: `category`, `purpose`, `exportable`, `restrictable` | +| Rule | Severity | What it does | +| ---------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `conformance/feature-must-have-manifest` | error | Use-case files require a sibling manifest | +| `conformance/usecase-must-have-test-file` | error | Every `*.use-case.ts` has a sibling `*.use-case.test.ts` | +| `conformance/required-cores-installed` | error | Manifest's `requiredCores` must exist as `core-` packages in pnpm-workspace.yaml | +| `conformance/no-undeclared-event-publish` | warn | `bus.publish("X")` literal must match the manifest's `publishes` for the use case | +| `conformance/no-undeclared-audit` | warn | `auditLog.record({ type: "X" })` literal must match the manifest's `audits` | +| `conformance/usecase-must-be-wired` | error | Every manifest use case must be bound via `wireUseCase({ name: "" })` in `bind-production.ts` / `bind-dev-seed.ts` | +| `conformance/no-undeclared-analytics-event` | warn | `analytics.track("X")` literal must match the manifest's `analyticsEvents` for the use case | +| `conformance/pii-declaration-must-be-complete` | warn | `custom.pii` blocks in Payload config files must declare all required fields: `category`, `purpose`, `exportable`, `restrictable` | +| `conformance/no-undeclared-consent-check` | warn | `consent.isGranted("X")` literal in a use-case file must match a category declared in `manifest.requiresConsent`; warns if declared categories are never checked | ## Workflow ordering for new use cases diff --git a/packages/core-eslint/base.js b/packages/core-eslint/base.js index 2769709..7dc61c4 100644 --- a/packages/core-eslint/base.js +++ b/packages/core-eslint/base.js @@ -53,6 +53,7 @@ export default [ "conformance/component-must-have-test": "warn", "conformance/atomic-tier-import-direction": "warn", "conformance/pii-declaration-must-be-complete": "warn", + "conformance/no-undeclared-consent-check": ["warn", { repoRoot }], }, }, { diff --git a/packages/core-eslint/plugin.js b/packages/core-eslint/plugin.js index 6821a3a..b10d88b 100644 --- a/packages/core-eslint/plugin.js +++ b/packages/core-eslint/plugin.js @@ -9,6 +9,7 @@ import componentMustHaveStory from "./rules/component-must-have-story.js"; 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"; /** * The `@repo/core-eslint` conformance plugin. Aggregates custom rules that @@ -37,6 +38,7 @@ const plugin = { "component-must-have-test": componentMustHaveTest, "atomic-tier-import-direction": atomicTierImportDirection, "pii-declaration-must-be-complete": piiDeclarationMustBeComplete, + "no-undeclared-consent-check": noUndeclaredConsentCheck, }, }; diff --git a/packages/core-eslint/rules/_manifest-ast.js b/packages/core-eslint/rules/_manifest-ast.js index 201804a..fb15039 100644 --- a/packages/core-eslint/rules/_manifest-ast.js +++ b/packages/core-eslint/rules/_manifest-ast.js @@ -1,6 +1,12 @@ import fs from "node:fs"; import { parse } from "@typescript-eslint/parser"; +function extractStringLiterals(arrayExpr) { + return arrayExpr.elements + .filter((el) => el && el.type === "Literal" && typeof el.value === "string") + .map((el) => el.value); +} + /** * Parse a feature.manifest.ts file and extract per-use-case attributes. * Walks the AST to find the `defineFeature({...} as const)` call expression @@ -94,11 +100,7 @@ function extractUseCaseEntry(objExpr) { key === "analyticsEvents") && prop.value.type === "ArrayExpression" ) { - entry[key] = prop.value.elements - .filter( - (el) => el && el.type === "Literal" && typeof el.value === "string", - ) - .map((el) => el.value); + entry[key] = extractStringLiterals(prop.value); } } return entry; @@ -139,6 +141,7 @@ export function parseManifestFully(manifestPath) { let name = null; let requiredCores = []; + let requiresConsent = []; let useCases = {}; for (const prop of arg.properties) { @@ -153,30 +156,34 @@ export function parseManifestFully(manifestPath) { prop.key.name === "requiredCores" && prop.value.type === "ArrayExpression" ) { - requiredCores = prop.value.elements - .filter( - (el) => el && el.type === "Literal" && typeof el.value === "string", - ) - .map((el) => el.value); + requiredCores = extractStringLiterals(prop.value); + } else if ( + prop.key.name === "requiresConsent" && + prop.value.type === "ArrayExpression" + ) { + requiresConsent = extractStringLiterals(prop.value); } else if ( prop.key.name === "useCases" && prop.value.type === "ObjectExpression" ) { - for (const entry of prop.value.properties) { - if ( - entry.type !== "Property" || - entry.value.type !== "ObjectExpression" - ) - continue; - const ucName = - entry.key.type === "Identifier" ? entry.key.name : entry.key.value; - useCases[ucName] = extractUseCaseEntryFromObj(entry.value); - } + useCases = extractUseCasesMap(prop.value); } } if (name === null) return null; - return { name, requiredCores, useCases }; + return { name, requiredCores, requiresConsent, useCases }; +} + +function extractUseCasesMap(useCasesObjExpr) { + const result = {}; + for (const entry of useCasesObjExpr.properties) { + if (entry.type !== "Property" || entry.value.type !== "ObjectExpression") + continue; + const ucName = + entry.key.type === "Identifier" ? entry.key.name : entry.key.value; + result[ucName] = extractUseCaseEntryFromObj(entry.value); + } + return result; } // Helper aliases for the existing private functions — exposed under @@ -225,11 +232,7 @@ function extractUseCaseEntryFromObj(objExpr) { key === "analyticsEvents") && prop.value.type === "ArrayExpression" ) { - entry[key] = prop.value.elements - .filter( - (el) => el && el.type === "Literal" && typeof el.value === "string", - ) - .map((el) => el.value); + entry[key] = extractStringLiterals(prop.value); } } return entry; diff --git a/packages/core-eslint/rules/_manifest-ast.test.js b/packages/core-eslint/rules/_manifest-ast.test.js index 014f708..c06fbb9 100644 --- a/packages/core-eslint/rules/_manifest-ast.test.js +++ b/packages/core-eslint/rules/_manifest-ast.test.js @@ -86,6 +86,7 @@ describe("parseManifestFully", () => { expect(parseManifestFully(fp)).toEqual({ name: "auth", requiredCores: ["audit", "events"], + requiresConsent: [], useCases: { signIn: { mutates: false, @@ -105,6 +106,23 @@ describe("parseManifestFully", () => { }); }); + it("extracts requiresConsent categories from the manifest", () => { + const fp = writeManifest(`export const consentManifest = defineFeature({ + name: "consent", + requiredCores: [], + requiresConsent: ["analytics", "marketing"], + useCases: {}, + realtimeChannels: [], + jobs: [], +} as const);`); + expect(parseManifestFully(fp)).toEqual({ + name: "consent", + requiredCores: [], + requiresConsent: ["analytics", "marketing"], + useCases: {}, + }); + }); + it("returns null when the manifest has no name", () => { const fp = writeManifest(`export const x = 1;`); expect(parseManifestFully(fp)).toBeNull(); @@ -124,6 +142,7 @@ export const realManifest = defineFeature({ expect(parseManifestFully(fp)).toEqual({ name: "real", requiredCores: [], + requiresConsent: [], useCases: {}, }); }); diff --git a/packages/core-eslint/rules/_rule-context.js b/packages/core-eslint/rules/_rule-context.js new file mode 100644 index 0000000..928b109 --- /dev/null +++ b/packages/core-eslint/rules/_rule-context.js @@ -0,0 +1,18 @@ +import { useCaseNameFromFile } from "./_usecase-name.js"; +import { featureRootForFile } from "./_manifest-source.js"; + +/** + * Resolves the rule execution context from an ESLint context object. + * Returns null when the file is not a use-case file or not inside a + * recognised feature package — callers should return {} immediately. + */ +export function resolveRuleContext(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 null; + const featureRoot = featureRootForFile(filename, repoRoot); + if (!featureRoot) return null; + return { useCaseName, featureRoot, repoRoot }; +} diff --git a/packages/core-eslint/rules/_rule-schema.js b/packages/core-eslint/rules/_rule-schema.js new file mode 100644 index 0000000..513091c --- /dev/null +++ b/packages/core-eslint/rules/_rule-schema.js @@ -0,0 +1,8 @@ +/** Shared repoRoot option schema used by conformance rules that need to locate feature roots. */ +export const repoRootSchema = [ + { + type: "object", + properties: { repoRoot: { type: "string" } }, + additionalProperties: false, + }, +]; diff --git a/packages/core-eslint/rules/no-undeclared-analytics-event.js b/packages/core-eslint/rules/no-undeclared-analytics-event.js index 7bdc20a..d5575e1 100644 --- a/packages/core-eslint/rules/no-undeclared-analytics-event.js +++ b/packages/core-eslint/rules/no-undeclared-analytics-event.js @@ -1,9 +1,7 @@ import { parseManifestUseCases } from "./_manifest-ast.js"; -import { useCaseNameFromFile } from "./_usecase-name.js"; -import { - manifestPathForFeature, - featureRootForFile, -} from "./_manifest-source.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 { @@ -13,26 +11,16 @@ export default { description: 'analytics.track("X") inside a use-case factory must declare X in manifest.useCases[name].analyticsEvents.', }, - schema: [ - { - type: "object", - properties: { repoRoot: { type: "string" } }, - additionalProperties: false, - }, - ], + schema: repoRootSchema, messages: { undeclared: '{{useCase}} calls analytics.track("{{event}}") but {{event}} is not declared in manifest.useCases.{{useCase}}.analyticsEvents. 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 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].analyticsEvents ?? []); diff --git a/packages/core-eslint/rules/no-undeclared-audit.js b/packages/core-eslint/rules/no-undeclared-audit.js index a136b7f..5468b50 100644 --- a/packages/core-eslint/rules/no-undeclared-audit.js +++ b/packages/core-eslint/rules/no-undeclared-audit.js @@ -1,6 +1,7 @@ import { parseManifestUseCases } from "./_manifest-ast.js"; -import { useCaseNameFromFile } from "./_usecase-name.js"; -import { manifestPathForFeature, featureRootForFile } from "./_manifest-source.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 { @@ -8,28 +9,18 @@ export default { type: "problem", docs: { description: - "auditLog.record({ type: \"X\" }) inside a use-case factory must declare X in manifest.useCases[name].audits.", + '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, - }, - ], + schema: repoRootSchema, 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.", + '{{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 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].audits); @@ -57,7 +48,11 @@ export default { ) { const event = typeProp.value.value; if (!declared.has(event)) { - context.report({ node, messageId: "undeclared", data: { event, useCase: useCaseName } }); + context.report({ + node, + messageId: "undeclared", + data: { event, useCase: useCaseName }, + }); } } } diff --git a/packages/core-eslint/rules/no-undeclared-consent-check.js b/packages/core-eslint/rules/no-undeclared-consent-check.js new file mode 100644 index 0000000..93d89c2 --- /dev/null +++ b/packages/core-eslint/rules/no-undeclared-consent-check.js @@ -0,0 +1,68 @@ +import { parseManifestFully } 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: + 'consent.isGranted("X") inside a use-case file must match a category declared in manifest.requiresConsent.', + }, + schema: repoRootSchema, + messages: { + undeclared: + '{{useCase}} calls consent.isGranted("{{category}}") but "{{category}}" is not declared in manifest.requiresConsent. Add it to the manifest or remove the call.', + unusedDeclaration: + "{{useCase}} manifest declares requiresConsent but no consent.isGranted() call found in this file. Add consent checks or clear the manifest declaration.", + }, + }, + create(context) { + const rc = resolveRuleContext(context); + if (!rc) return {}; + const { useCaseName, featureRoot } = rc; + const manifest = parseManifestFully(manifestPathForFeature(featureRoot)); + if (!manifest) return {}; + const requiresConsent = manifest.requiresConsent ?? []; + if (requiresConsent.length === 0) return {}; + + const declared = new Set(requiresConsent); + let hasConsentCall = false; + + return { + CallExpression(node) { + if ( + node.callee.type === "MemberExpression" && + node.callee.object.type === "Identifier" && + node.callee.object.name === "consent" && + node.callee.property.type === "Identifier" && + node.callee.property.name === "isGranted" && + node.arguments.length > 0 && + node.arguments[0].type === "Literal" && + typeof node.arguments[0].value === "string" + ) { + hasConsentCall = true; + const category = node.arguments[0].value; + if (!declared.has(category)) { + context.report({ + node, + messageId: "undeclared", + data: { category, useCase: useCaseName }, + }); + } + } + }, + "Program:exit"(node) { + if (!hasConsentCall) { + context.report({ + node, + messageId: "unusedDeclaration", + data: { useCase: useCaseName }, + }); + } + }, + }; + }, +}; diff --git a/packages/core-eslint/rules/no-undeclared-consent-check.test.js b/packages/core-eslint/rules/no-undeclared-consent-check.test.js new file mode 100644 index 0000000..620508b --- /dev/null +++ b/packages/core-eslint/rules/no-undeclared-consent-check.test.js @@ -0,0 +1,132 @@ +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-consent-check.js"; + +function makeFixture({ requiresConsent, useCaseBody }) { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nucc-")); + const featureDir = path.join(repoRoot, "packages", "demo"); + fs.mkdirSync(path.join(featureDir, "src", "application", "use-cases"), { + recursive: true, + }); + const consentField = + requiresConsent.length > 0 + ? ` requiresConsent: [${requiresConsent.map((c) => `"${c}"`).join(", ")}],\n` + : ""; + fs.writeFileSync( + path.join(featureDir, "src", "feature.manifest.ts"), + `export const demoManifest = defineFeature({ + name: "demo", + requiredCores: [], +${consentField} useCases: { + checkData: { mutates: false, audits: [], publishes: [], consumes: [] }, + }, + realtimeChannels: [], + jobs: [], +} as const);`, + ); + const useCaseFile = path.join( + featureDir, + "src", + "application", + "use-cases", + "check-data.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-consent-check", () => { + it("passes when consent.isGranted category matches manifest requiresConsent", () => { + const { repoRoot, useCaseFile } = makeFixture({ + requiresConsent: ["analytics"], + useCaseBody: `export const checkDataUseCase = (consent) => async () => { if (!consent.isGranted("analytics")) throw new Error(); };`, + }); + tester.run("no-undeclared-consent-check", rule, { + valid: [ + { + filename: useCaseFile, + code: fs.readFileSync(useCaseFile, "utf8"), + options: [{ repoRoot }], + }, + ], + invalid: [], + }); + }); + + it("fires when category is undeclared or manifest declaration is unused", () => { + const { repoRoot: rootA, useCaseFile: fileA } = makeFixture({ + requiresConsent: ["analytics"], + useCaseBody: `export const checkDataUseCase = (consent) => async () => { if (!consent.isGranted("marketing")) throw new Error(); };`, + }); + const { repoRoot: rootB, useCaseFile: fileB } = makeFixture({ + requiresConsent: ["analytics"], + useCaseBody: `export const checkDataUseCase = () => async () => { return { ok: true }; };`, + }); + tester.run("no-undeclared-consent-check", rule, { + valid: [], + invalid: [ + { + filename: fileA, + code: fs.readFileSync(fileA, "utf8"), + options: [{ repoRoot: rootA }], + errors: [ + { + messageId: "undeclared", + data: { category: "marketing", useCase: "checkData" }, + }, + ], + }, + { + filename: fileB, + code: fs.readFileSync(fileB, "utf8"), + options: [{ repoRoot: rootB }], + errors: [ + { messageId: "unusedDeclaration", data: { useCase: "checkData" } }, + ], + }, + ], + }); + }); + + it("is a no-op for non-use-case files", () => { + const { repoRoot } = makeFixture({ + requiresConsent: ["analytics"], + useCaseBody: `export const checkDataUseCase = (consent) => async () => { consent.isGranted("unknown"); };`, + }); + const serviceFile = path.join( + repoRoot, + "packages", + "demo", + "src", + "application", + "services", + "data.service.ts", + ); + fs.mkdirSync(path.dirname(serviceFile), { recursive: true }); + fs.writeFileSync( + serviceFile, + `export function doWork(consent) { consent.isGranted("unknown"); }`, + ); + tester.run("no-undeclared-consent-check", rule, { + valid: [ + { + filename: serviceFile, + code: fs.readFileSync(serviceFile, "utf8"), + options: [{ repoRoot }], + }, + ], + invalid: [], + }); + }); +}); diff --git a/packages/core-eslint/rules/no-undeclared-event-publish.js b/packages/core-eslint/rules/no-undeclared-event-publish.js index bc44a5a..845601a 100644 --- a/packages/core-eslint/rules/no-undeclared-event-publish.js +++ b/packages/core-eslint/rules/no-undeclared-event-publish.js @@ -1,6 +1,7 @@ import { parseManifestUseCases } from "./_manifest-ast.js"; -import { useCaseNameFromFile } from "./_usecase-name.js"; -import { manifestPathForFeature, featureRootForFile } from "./_manifest-source.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 { @@ -8,28 +9,18 @@ export default { type: "problem", docs: { description: - "bus.publish(\"X\") inside a use-case factory must declare X in manifest.useCases[name].publishes.", + 'bus.publish("X") inside a use-case factory must declare X in manifest.useCases[name].publishes.', }, - schema: [ - { - type: "object", - properties: { repoRoot: { type: "string" } }, - additionalProperties: false, - }, - ], + schema: repoRootSchema, messages: { undeclared: - "{{useCase}} calls bus.publish(\"{{event}}\") but {{event}} is not declared in manifest.useCases.{{useCase}}.publishes. Add it to the manifest or remove the call.", + '{{useCase}} calls bus.publish("{{event}}") but {{event}} is not declared in manifest.useCases.{{useCase}}.publishes. 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 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].publishes); @@ -47,7 +38,11 @@ export default { ) { const event = node.arguments[0].value; if (!declared.has(event)) { - context.report({ node, messageId: "undeclared", data: { event, useCase: useCaseName } }); + context.report({ + node, + messageId: "undeclared", + data: { event, useCase: useCaseName }, + }); } } },