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:
@@ -40,7 +40,9 @@
|
||||
"zod",
|
||||
"@eslint/js",
|
||||
"@opentelemetry/sdk-node",
|
||||
"@sentry/opentelemetry"
|
||||
"@sentry/opentelemetry",
|
||||
"@stryker-mutator/core",
|
||||
"@stryker-mutator/vitest-runner"
|
||||
],
|
||||
"ignoreExportsUsedInFile": true,
|
||||
"rules": {
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 }],
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {},
|
||||
});
|
||||
});
|
||||
|
||||
18
packages/core-eslint/rules/_rule-context.js
Normal file
18
packages/core-eslint/rules/_rule-context.js
Normal 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 };
|
||||
}
|
||||
8
packages/core-eslint/rules/_rule-schema.js
Normal file
8
packages/core-eslint/rules/_rule-schema.js
Normal 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,
|
||||
},
|
||||
];
|
||||
@@ -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 ?? []);
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
68
packages/core-eslint/rules/no-undeclared-consent-check.js
Normal file
68
packages/core-eslint/rules/no-undeclared-consent-check.js
Normal 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 },
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
132
packages/core-eslint/rules/no-undeclared-consent-check.test.js
Normal file
132
packages/core-eslint/rules/no-undeclared-consent-check.test.js
Normal 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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user