feat(core-eslint): add no-undeclared-consent-check rule (conformance gate 12)

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 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 11:51:30 +00:00
parent ef9cfd243e
commit 1cab88916a
14 changed files with 327 additions and 95 deletions

View File

@@ -40,7 +40,9 @@
"zod",
"@eslint/js",
"@opentelemetry/sdk-node",
"@sentry/opentelemetry"
"@sentry/opentelemetry",
"@stryker-mutator/core",
"@stryker-mutator/vitest-runner"
],
"ignoreExportsUsedInFile": true,
"rules": {

View File

@@ -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`) | ~3060s | 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.

View File

@@ -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-<name>` 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: "<key>" })` 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-<name>` 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: "<key>" })` 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

View File

@@ -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 }],
},
},
{

View File

@@ -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,
},
};

View File

@@ -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;

View File

@@ -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: {},
});
});

View File

@@ -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 };
}

View File

@@ -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,
},
];

View File

@@ -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 ?? []);

View File

@@ -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 },
});
}
}
}

View File

@@ -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 },
});
}
},
};
},
};

View File

@@ -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: [],
});
});
});

View File

@@ -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 },
});
}
}
},