From 1eb32ab23b3945cc9ef099e501148b01593c272d Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Mon, 18 May 2026 18:33:48 +0000 Subject: [PATCH] feat(core-eslint): add pii-declaration-must-be-complete rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the `conformance/pii-declaration-must-be-complete` ESLint rule at warn severity. The rule detects `custom: { pii: { ... } }` blocks in Payload config files and warns when any of the four required sub-fields (`category`, `purpose`, `exportable`, `restrictable`) is missing. Incomplete PII declarations can produce incorrect audit reports — sub-second editor feedback catches the gap before it reaches compliance/data-map.yml. - Rule + 7 RuleTester fixtures (complete passes, each missing field warns, non-pii custom block is no-op, malformed custom.pii is no-op) - Registered in plugin.js + base.js at "warn" - Conformance rule count bumped 7 → 8 in CLAUDE.md + conformance-quickref.md Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 2 +- coverage/summary.json | 4 +- docs/guides/conformance-quickref.md | 19 +- packages/core-eslint/base.js | 1 + packages/core-eslint/plugin.js | 2 + .../rules/pii-declaration-must-be-complete.js | 57 ++++++ .../pii-declaration-must-be-complete.test.js | 163 ++++++++++++++++++ pnpm-lock.yaml | 3 + 8 files changed, 239 insertions(+), 12 deletions(-) create mode 100644 packages/core-eslint/rules/pii-declaration-must-be-complete.js create mode 100644 packages/core-eslint/rules/pii-declaration-must-be-complete.test.js diff --git a/CLAUDE.md b/CLAUDE.md index c4c29fd..949f85f 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 seven 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). Fallow runs as a fifth layer, post-ESLint, whole-codebase. +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. 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/coverage/summary.json b/coverage/summary.json index 6e48b82..e124fb2 100644 --- a/coverage/summary.json +++ b/coverage/summary.json @@ -1,6 +1,6 @@ { - "generatedAt": "2026-05-18T18:25:58.074Z", - "commit": "a94e803", + "generatedAt": "2026-05-18T18:33:27.459Z", + "commit": "fa1a10c", "repo": { "statements": 96.34, "branches": 91.41, diff --git a/docs/guides/conformance-quickref.md b/docs/guides/conformance-quickref.md index 85d142e..88d4142 100644 --- a/docs/guides/conformance-quickref.md +++ b/docs/guides/conformance-quickref.md @@ -86,15 +86,16 @@ 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 | +| 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` | ## Workflow ordering for new use cases diff --git a/packages/core-eslint/base.js b/packages/core-eslint/base.js index 8cab9d7..2769709 100644 --- a/packages/core-eslint/base.js +++ b/packages/core-eslint/base.js @@ -52,6 +52,7 @@ export default [ "conformance/component-must-have-story": "warn", "conformance/component-must-have-test": "warn", "conformance/atomic-tier-import-direction": "warn", + "conformance/pii-declaration-must-be-complete": "warn", }, }, { diff --git a/packages/core-eslint/plugin.js b/packages/core-eslint/plugin.js index 7acef39..6821a3a 100644 --- a/packages/core-eslint/plugin.js +++ b/packages/core-eslint/plugin.js @@ -8,6 +8,7 @@ import usecaseMustBeWired from "./rules/usecase-must-be-wired.js"; 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"; /** * The `@repo/core-eslint` conformance plugin. Aggregates custom rules that @@ -35,6 +36,7 @@ const plugin = { "component-must-have-story": componentMustHaveStory, "component-must-have-test": componentMustHaveTest, "atomic-tier-import-direction": atomicTierImportDirection, + "pii-declaration-must-be-complete": piiDeclarationMustBeComplete, }, }; diff --git a/packages/core-eslint/rules/pii-declaration-must-be-complete.js b/packages/core-eslint/rules/pii-declaration-must-be-complete.js new file mode 100644 index 0000000..c84447b --- /dev/null +++ b/packages/core-eslint/rules/pii-declaration-must-be-complete.js @@ -0,0 +1,57 @@ +const REQUIRED_FIELDS = ["category", "purpose", "exportable", "restrictable"]; + +/** @type {import("eslint").Rule.RuleModule} */ +export default { + meta: { + type: "problem", + docs: { + description: + "custom.pii blocks in Payload config files must declare all required sub-fields: category, purpose, exportable, restrictable.", + }, + schema: [], + messages: { + missingField: + "custom.pii block is missing required field '{{field}}'. Incomplete PII declarations can produce incorrect audit reports.", + }, + }, + create(context) { + return { + Property(node) { + if ( + node.key.type !== "Identifier" || + node.key.name !== "custom" || + node.value.type !== "ObjectExpression" + ) { + return; + } + + const piiProp = node.value.properties.find( + (p) => + p.type === "Property" && + p.key.type === "Identifier" && + p.key.name === "pii", + ); + + if (!piiProp || piiProp.value.type !== "ObjectExpression") { + return; + } + + const presentFields = new Set( + piiProp.value.properties + .filter((p) => p.type === "Property" && p.key.type === "Identifier") + .map((p) => p.key.name), + ); + + for (const field of REQUIRED_FIELDS) { + if (!presentFields.has(field)) { + context.report({ + node: piiProp, + messageId: "missingField", + data: { field }, + }); + } + } + }, + }; + }, +}; diff --git a/packages/core-eslint/rules/pii-declaration-must-be-complete.test.js b/packages/core-eslint/rules/pii-declaration-must-be-complete.test.js new file mode 100644 index 0000000..cb551c8 --- /dev/null +++ b/packages/core-eslint/rules/pii-declaration-must-be-complete.test.js @@ -0,0 +1,163 @@ +import { describe, it } from "vitest"; +import { RuleTester } from "eslint"; +import rule from "./pii-declaration-must-be-complete.js"; + +const tester = new RuleTester({ + languageOptions: { + parser: await import("@typescript-eslint/parser"), + ecmaVersion: "latest", + sourceType: "module", + }, +}); + +describe("pii-declaration-must-be-complete", () => { + it("passes when custom.pii has all required fields", () => { + tester.run("pii-declaration-must-be-complete", rule, { + valid: [ + { + code: ` + const field = { + slug: "email", + type: "email", + custom: { + pii: { + category: "contact", + purpose: "authentication", + exportable: false, + restrictable: true, + }, + }, + }; + `, + }, + ], + invalid: [], + }); + }); + + it("fires when category is missing", () => { + tester.run("pii-declaration-must-be-complete", rule, { + valid: [], + invalid: [ + { + code: ` + const field = { + custom: { + pii: { + purpose: "authentication", + exportable: false, + restrictable: true, + }, + }, + }; + `, + errors: [{ messageId: "missingField", data: { field: "category" } }], + }, + ], + }); + }); + + it("fires when purpose is missing", () => { + tester.run("pii-declaration-must-be-complete", rule, { + valid: [], + invalid: [ + { + code: ` + const field = { + custom: { + pii: { + category: "contact", + exportable: false, + restrictable: true, + }, + }, + }; + `, + errors: [{ messageId: "missingField", data: { field: "purpose" } }], + }, + ], + }); + }); + + it("fires when exportable is missing", () => { + tester.run("pii-declaration-must-be-complete", rule, { + valid: [], + invalid: [ + { + code: ` + const field = { + custom: { + pii: { + category: "contact", + purpose: "authentication", + restrictable: true, + }, + }, + }; + `, + errors: [ + { messageId: "missingField", data: { field: "exportable" } }, + ], + }, + ], + }); + }); + + it("fires when restrictable is missing", () => { + tester.run("pii-declaration-must-be-complete", rule, { + valid: [], + invalid: [ + { + code: ` + const field = { + custom: { + pii: { + category: "contact", + purpose: "authentication", + exportable: false, + }, + }, + }; + `, + errors: [ + { messageId: "missingField", data: { field: "restrictable" } }, + ], + }, + ], + }); + }); + + it("is a no-op when custom has no pii property", () => { + tester.run("pii-declaration-must-be-complete", rule, { + valid: [ + { + code: ` + const field = { + custom: { + someOtherProperty: "value", + }, + }; + `, + }, + ], + invalid: [], + }); + }); + + it("is a no-op when custom.pii is not an object", () => { + tester.run("pii-declaration-must-be-complete", rule, { + valid: [ + { + code: ` + const field = { + custom: { + pii: true, + }, + }; + `, + }, + ], + invalid: [], + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ddd5939..f31375a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -696,6 +696,9 @@ importers: "@types/node": specifier: ^22.0.0 version: 22.19.17 + "@vitest/coverage-v8": + specifier: ^3.2.4 + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.17)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0)) inversify: specifier: ^6.2.0 version: 6.2.2(reflect-metadata@0.2.2)