From bfb2324dd95aaa0a4df96d07f92a7565e0126236 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Tue, 12 May 2026 23:48:35 +0200 Subject: [PATCH 1/7] =?UTF-8?q?docs(work):=20story=2003.b=20=E2=80=94=20AS?= =?UTF-8?q?T-aware=20ESLint=20rules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../03-b-ast-eslint-rules/_story.md | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 docs/work/conformance-system-v1/03-b-ast-eslint-rules/_story.md diff --git a/docs/work/conformance-system-v1/03-b-ast-eslint-rules/_story.md b/docs/work/conformance-system-v1/03-b-ast-eslint-rules/_story.md new file mode 100644 index 0000000..5e77f6a --- /dev/null +++ b/docs/work/conformance-system-v1/03-b-ast-eslint-rules/_story.md @@ -0,0 +1,45 @@ +--- +id: 03-b-ast-eslint-rules +epic: conformance-system-v1 +title: AST-aware ESLint rules (no-undeclared-event-publish, no-undeclared-audit) +type: technical-story +status: in-progress +feature: core-eslint +depends-on: [03-a-structural-eslint-rules] +blocks: [04-ci-drift-gate] +--- + +## Goal +Ship two AST-aware rules that catch manifest ↔ use-case drift inside factory bodies: +- `no-undeclared-event-publish`: `bus.publish("X")` in a factory must match `manifest.useCases[name].publishes` +- `no-undeclared-audit`: `auditLog.record({ type: "X" })` must match `manifest.useCases[name].audits` + +## Why +Boot assertion + structural rules can't see what happens inside a factory body. AST-aware rules catch publish/audit drift the moment a developer (or agent) saves the file. + +## Done when +- Manifest AST parser extracts per-use-case publishes/audits arrays +- Two rules registered in the conformance plugin +- Tests cover positive (declared event) and negative (undeclared event) cases for each rule +- `pnpm lint` passes (auth's signUp has empty publishes/audits today → no false positives) + +## In scope +- `_manifest-ast.js` helper using `@typescript-eslint/parser` to extract per-use-case arrays +- `_usecase-name.js` helper (file slug → camelCase use-case key) +- The two rules + RuleTester tests +- Plugin + base.js wiring + +## Out of scope +- Bus / auditLog parameter detection beyond the conventional names `bus` and `auditLog` +- Detection of dynamic event names (`bus.publish(eventVar, payload)` is allowed without warning) +- Conditional / nested calls — rules only check top-level CallExpressions in factory bodies + +## Tasks +- [ ] Story 03.b scaffold +- [ ] Manifest AST parser + tests +- [ ] Use-case name helper + tests +- [ ] `no-undeclared-event-publish` rule + tests +- [ ] `no-undeclared-audit` rule + tests +- [ ] Plugin update + base.js wiring +- [ ] Verify `pnpm lint` passes +- [ ] Final verification + story closeout (tick 03 + 03.b in epic) From 7cfb78b99e16a696756c81fa746ad35ca70fa98c Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Tue, 12 May 2026 23:49:12 +0200 Subject: [PATCH 2/7] feat(core-eslint): manifest AST parser for per-use-case attributes --- packages/core-eslint/rules/_manifest-ast.js | 76 +++++++++++++++++++ .../core-eslint/rules/_manifest-ast.test.js | 53 +++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 packages/core-eslint/rules/_manifest-ast.js create mode 100644 packages/core-eslint/rules/_manifest-ast.test.js diff --git a/packages/core-eslint/rules/_manifest-ast.js b/packages/core-eslint/rules/_manifest-ast.js new file mode 100644 index 0000000..54d4962 --- /dev/null +++ b/packages/core-eslint/rules/_manifest-ast.js @@ -0,0 +1,76 @@ +import fs from "node:fs"; +import { parse } from "@typescript-eslint/parser"; + +/** + * Parse a feature.manifest.ts file and extract per-use-case attributes. + * Walks the AST to find the `defineFeature({...} as const)` call expression + * and reads literal values from its argument object. + * + * Returns: { [useCaseName]: { mutates, audits[], publishes[], consumes[] } } + * Returns null if the file is missing or doesn't match the expected shape. + */ +export function parseManifestUseCases(manifestPath) { + let src; + try { + src = fs.readFileSync(manifestPath, "utf8"); + } catch { + return null; + } + let ast; + try { + ast = parse(src, { sourceType: "module", ecmaVersion: "latest", loc: false, range: false }); + } catch { + return null; + } + const defineCall = findDefineFeatureCall(ast); + if (!defineCall) return null; + const arg = unwrapAsConst(defineCall.arguments[0]); + if (!arg || arg.type !== "ObjectExpression") return null; + const useCasesProp = arg.properties.find( + (p) => p.type === "Property" && p.key.type === "Identifier" && p.key.name === "useCases", + ); + if (!useCasesProp || useCasesProp.value.type !== "ObjectExpression") return {}; + const result = {}; + for (const entry of useCasesProp.value.properties) { + if (entry.type !== "Property" || entry.value.type !== "ObjectExpression") continue; + const name = entry.key.type === "Identifier" ? entry.key.name : entry.key.value; + result[name] = extractUseCaseEntry(entry.value); + } + return result; +} + +function findDefineFeatureCall(ast) { + for (const node of ast.body) { + if (node.type !== "ExportNamedDeclaration" || !node.declaration) continue; + if (node.declaration.type !== "VariableDeclaration") continue; + for (const decl of node.declaration.declarations) { + const init = decl.init; + if (!init) continue; + if (init.type === "CallExpression" && init.callee.type === "Identifier" && init.callee.name === "defineFeature") { + return init; + } + } + } + return null; +} + +function unwrapAsConst(node) { + if (node && node.type === "TSAsExpression") return node.expression; + return node; +} + +function extractUseCaseEntry(objExpr) { + const entry = { mutates: false, audits: [], publishes: [], consumes: [] }; + for (const prop of objExpr.properties) { + if (prop.type !== "Property" || prop.key.type !== "Identifier") continue; + const key = prop.key.name; + if (key === "mutates" && prop.value.type === "Literal") { + entry.mutates = prop.value.value === true; + } else if ((key === "audits" || key === "publishes" || key === "consumes") && prop.value.type === "ArrayExpression") { + entry[key] = prop.value.elements + .filter((el) => el && el.type === "Literal" && typeof el.value === "string") + .map((el) => el.value); + } + } + return entry; +} diff --git a/packages/core-eslint/rules/_manifest-ast.test.js b/packages/core-eslint/rules/_manifest-ast.test.js new file mode 100644 index 0000000..f33227c --- /dev/null +++ b/packages/core-eslint/rules/_manifest-ast.test.js @@ -0,0 +1,53 @@ +import { describe, it, expect } from "vitest"; +import path from "node:path"; +import os from "node:os"; +import fs from "node:fs"; +import { parseManifestUseCases } from "./_manifest-ast.js"; + +function writeManifest(content) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "manifest-ast-")); + const fp = path.join(dir, "feature.manifest.ts"); + fs.writeFileSync(fp, content); + return fp; +} + +describe("parseManifestUseCases", () => { + it("returns an empty object for a manifest with no useCases", () => { + const fp = writeManifest(`export const xManifest = defineFeature({ + name: "x", + requiredCores: [], + useCases: {}, + realtimeChannels: [], + jobs: [], +} as const);`); + expect(parseManifestUseCases(fp)).toEqual({}); + }); + + it("extracts per-use-case publishes/audits/consumes arrays", () => { + const fp = writeManifest(`export const authManifest = defineFeature({ + name: "auth", + requiredCores: [], + useCases: { + signIn: { mutates: false, audits: [], publishes: [], consumes: [] }, + signUp: { mutates: true, audits: ["user.created"], publishes: ["auth.signed-up"], consumes: [] }, + signOut: { mutates: true, audits: ["session.ended"], publishes: [], consumes: [] }, + }, + realtimeChannels: [], + jobs: [], +} as const);`); + expect(parseManifestUseCases(fp)).toEqual({ + signIn: { mutates: false, audits: [], publishes: [], consumes: [] }, + signUp: { mutates: true, audits: ["user.created"], publishes: ["auth.signed-up"], consumes: [] }, + signOut: { mutates: true, audits: ["session.ended"], publishes: [], consumes: [] }, + }); + }); + + it("returns null when file does not exist", () => { + expect(parseManifestUseCases("/nonexistent/manifest.ts")).toBeNull(); + }); + + it("returns null when the file has no defineFeature call", () => { + const fp = writeManifest(`export const x = 1;`); + expect(parseManifestUseCases(fp)).toBeNull(); + }); +}); From 21a6822d55a750357a4bda1527d83a5f2111e885 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Tue, 12 May 2026 23:49:32 +0200 Subject: [PATCH 3/7] =?UTF-8?q?feat(core-eslint):=20use-case=20name=20help?= =?UTF-8?q?er=20(file=20slug=20=E2=86=92=20manifest=20key)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/core-eslint/rules/_usecase-name.js | 17 +++++++++++++++++ .../core-eslint/rules/_usecase-name.test.js | 19 +++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 packages/core-eslint/rules/_usecase-name.js create mode 100644 packages/core-eslint/rules/_usecase-name.test.js diff --git a/packages/core-eslint/rules/_usecase-name.js b/packages/core-eslint/rules/_usecase-name.js new file mode 100644 index 0000000..c8299b8 --- /dev/null +++ b/packages/core-eslint/rules/_usecase-name.js @@ -0,0 +1,17 @@ +/** + * Derive the manifest use-case key from a use-case file path. + * + * Convention: `packages//src/application/use-cases/.use-case.ts` + * → manifest.useCases. + * + * Returns null for non-use-case files. + */ +export function useCaseNameFromFile(filepath) { + if (!filepath.endsWith(".use-case.ts")) return null; + if (!filepath.includes("/application/use-cases/") && !filepath.includes("\\application\\use-cases\\")) { + return null; + } + const base = filepath.split(/[\\/]/).pop(); + const slug = base.replace(/\.use-case\.ts$/, ""); + return slug.split("-").map((part, i) => i === 0 ? part : part[0].toUpperCase() + part.slice(1)).join(""); +} diff --git a/packages/core-eslint/rules/_usecase-name.test.js b/packages/core-eslint/rules/_usecase-name.test.js new file mode 100644 index 0000000..3cfcc63 --- /dev/null +++ b/packages/core-eslint/rules/_usecase-name.test.js @@ -0,0 +1,19 @@ +import { describe, it, expect } from "vitest"; +import { useCaseNameFromFile } from "./_usecase-name.js"; + +describe("useCaseNameFromFile", () => { + it("converts kebab-case slug to camelCase", () => { + expect(useCaseNameFromFile("/repo/packages/auth/src/application/use-cases/sign-in.use-case.ts")).toBe("signIn"); + expect(useCaseNameFromFile("/repo/packages/auth/src/application/use-cases/sign-up.use-case.ts")).toBe("signUp"); + expect(useCaseNameFromFile("/repo/packages/blog/src/application/use-cases/get-article-by-slug.use-case.ts")).toBe("getArticleBySlug"); + }); + + it("handles single-word slugs", () => { + expect(useCaseNameFromFile("/repo/packages/x/src/application/use-cases/login.use-case.ts")).toBe("login"); + }); + + it("returns null for non-use-case files", () => { + expect(useCaseNameFromFile("/repo/packages/auth/src/index.ts")).toBeNull(); + expect(useCaseNameFromFile("/repo/packages/auth/src/feature.manifest.ts")).toBeNull(); + }); +}); From 3e2b096c3c366b6de3a0274ca516e3aa89672fe0 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Tue, 12 May 2026 23:51:06 +0200 Subject: [PATCH 4/7] feat(core-eslint): no-undeclared-event-publish rule AST-aware ESLint rule that catches bus.publish("X") calls in use-case files where X is not declared in the matching manifest.useCases[name].publishes array. Co-Authored-By: Claude Sonnet 4.6 --- .../rules/no-undeclared-event-publish.js | 56 +++++++++++++ .../rules/no-undeclared-event-publish.test.js | 80 +++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 packages/core-eslint/rules/no-undeclared-event-publish.js create mode 100644 packages/core-eslint/rules/no-undeclared-event-publish.test.js diff --git a/packages/core-eslint/rules/no-undeclared-event-publish.js b/packages/core-eslint/rules/no-undeclared-event-publish.js new file mode 100644 index 0000000..bc44a5a --- /dev/null +++ b/packages/core-eslint/rules/no-undeclared-event-publish.js @@ -0,0 +1,56 @@ +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: + "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, + }, + ], + 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.", + }, + }, + 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].publishes); + return { + CallExpression(node) { + if ( + node.callee.type === "MemberExpression" && + node.callee.object.type === "Identifier" && + node.callee.object.name === "bus" && + node.callee.property.type === "Identifier" && + node.callee.property.name === "publish" && + node.arguments.length > 0 && + node.arguments[0].type === "Literal" && + typeof node.arguments[0].value === "string" + ) { + const event = node.arguments[0].value; + if (!declared.has(event)) { + context.report({ node, messageId: "undeclared", data: { event, useCase: useCaseName } }); + } + } + }, + }; + }, +}; diff --git a/packages/core-eslint/rules/no-undeclared-event-publish.test.js b/packages/core-eslint/rules/no-undeclared-event-publish.test.js new file mode 100644 index 0000000..5a8b880 --- /dev/null +++ b/packages/core-eslint/rules/no-undeclared-event-publish.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-event-publish.js"; + +function makeFixture({ manifestUseCases, useCaseBody }) { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nuep-")); + 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: [${uc.publishes.map((p) => `"${p}"`).join(", ")}], 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-event-publish", () => { + it("passes when bus.publish event name matches manifest publishes[]", () => { + const { repoRoot, useCaseFile } = makeFixture({ + manifestUseCases: { signUp: { mutates: true, audits: [], publishes: ["demo.signed-up"] } }, + useCaseBody: `export const signUpUseCase = (bus) => async () => { bus.publish("demo.signed-up", {}); };`, + }); + tester.run("no-undeclared-event-publish", rule, { + valid: [{ filename: useCaseFile, code: fs.readFileSync(useCaseFile, "utf8"), options: [{ repoRoot }] }], + invalid: [], + }); + }); + + it("fires when bus.publish event name is not in manifest", () => { + const { repoRoot, useCaseFile } = makeFixture({ + manifestUseCases: { signUp: { mutates: true, audits: [], publishes: [] } }, + useCaseBody: `export const signUpUseCase = (bus) => async () => { bus.publish("demo.signed-up", {}); };`, + }); + tester.run("no-undeclared-event-publish", rule, { + valid: [], + invalid: [{ + filename: useCaseFile, + code: fs.readFileSync(useCaseFile, "utf8"), + options: [{ repoRoot }], + errors: [{ messageId: "undeclared", data: { event: "demo.signed-up", useCase: "signUp" } }], + }], + }); + }); + + it("is a no-op when bus.publish is called with a non-literal argument", () => { + const { repoRoot, useCaseFile } = makeFixture({ + manifestUseCases: { signUp: { mutates: true, audits: [], publishes: [] } }, + useCaseBody: `export const signUpUseCase = (bus, name) => async () => { bus.publish(name, {}); };`, + }); + tester.run("no-undeclared-event-publish", rule, { + valid: [{ filename: useCaseFile, code: fs.readFileSync(useCaseFile, "utf8"), options: [{ repoRoot }] }], + invalid: [], + }); + }); +}); From 15e90820d24017c753ef6d52b5516c38877c0593 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Tue, 12 May 2026 23:51:42 +0200 Subject: [PATCH 5/7] feat(core-eslint): no-undeclared-audit rule AST-aware ESLint rule that catches auditLog.record({ type: "X" }) calls in use-case files where X is not declared in the matching manifest.useCases[name].audits array. Co-Authored-By: Claude Sonnet 4.6 --- .../core-eslint/rules/no-undeclared-audit.js | 67 ++++++++++++++++ .../rules/no-undeclared-audit.test.js | 80 +++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 packages/core-eslint/rules/no-undeclared-audit.js create mode 100644 packages/core-eslint/rules/no-undeclared-audit.test.js 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: [], + }); + }); +}); From 4d7b25fd15bbdb2fb10fbc9e9655bdf9aa110312 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Tue, 12 May 2026 23:52:31 +0200 Subject: [PATCH 6/7] feat(core-eslint): wire AST-aware conformance rules into plugin + base Co-Authored-By: Claude Sonnet 4.6 --- packages/core-eslint/base.js | 2 ++ packages/core-eslint/plugin.js | 6 +++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/core-eslint/base.js b/packages/core-eslint/base.js index 0b4ba59..130aa20 100644 --- a/packages/core-eslint/base.js +++ b/packages/core-eslint/base.js @@ -44,6 +44,8 @@ export default [ "error", { repoRoot }, ], + "conformance/no-undeclared-event-publish": ["warn", { repoRoot }], + "conformance/no-undeclared-audit": ["warn", { repoRoot }], }, }, { diff --git a/packages/core-eslint/plugin.js b/packages/core-eslint/plugin.js index 55a9a38..20ebd7a 100644 --- a/packages/core-eslint/plugin.js +++ b/packages/core-eslint/plugin.js @@ -1,6 +1,8 @@ import featureMustHaveManifest from "./rules/feature-must-have-manifest.js"; import usecaseMustHaveTestFile from "./rules/usecase-must-have-test-file.js"; import requiredCoresInstalled from "./rules/required-cores-installed.js"; +import noUndeclaredEventPublish from "./rules/no-undeclared-event-publish.js"; +import noUndeclaredAudit from "./rules/no-undeclared-audit.js"; /** * The `@repo/core-eslint` conformance plugin. Aggregates custom rules that @@ -16,11 +18,13 @@ import requiredCoresInstalled from "./rules/required-cores-installed.js"; * ]; */ const plugin = { - meta: { name: "conformance", version: "0.1.0" }, + meta: { name: "conformance", version: "0.2.0" }, rules: { "feature-must-have-manifest": featureMustHaveManifest, "usecase-must-have-test-file": usecaseMustHaveTestFile, "required-cores-installed": requiredCoresInstalled, + "no-undeclared-event-publish": noUndeclaredEventPublish, + "no-undeclared-audit": noUndeclaredAudit, }, }; From a07e62befdd7e3f5522731bca5bc69d046c9d4bf Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Tue, 12 May 2026 23:53:13 +0200 Subject: [PATCH 7/7] =?UTF-8?q?docs(work):=20close=20story=2003.b=20?= =?UTF-8?q?=E2=80=94=20AST-aware=20ESLint=20rules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../03-b-ast-eslint-rules/_story.md | 18 +++++++++--------- docs/work/conformance-system-v1/_epic.md | 4 ++-- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/work/conformance-system-v1/03-b-ast-eslint-rules/_story.md b/docs/work/conformance-system-v1/03-b-ast-eslint-rules/_story.md index 5e77f6a..f21c412 100644 --- a/docs/work/conformance-system-v1/03-b-ast-eslint-rules/_story.md +++ b/docs/work/conformance-system-v1/03-b-ast-eslint-rules/_story.md @@ -3,7 +3,7 @@ id: 03-b-ast-eslint-rules epic: conformance-system-v1 title: AST-aware ESLint rules (no-undeclared-event-publish, no-undeclared-audit) type: technical-story -status: in-progress +status: done feature: core-eslint depends-on: [03-a-structural-eslint-rules] blocks: [04-ci-drift-gate] @@ -35,11 +35,11 @@ Boot assertion + structural rules can't see what happens inside a factory body. - Conditional / nested calls — rules only check top-level CallExpressions in factory bodies ## Tasks -- [ ] Story 03.b scaffold -- [ ] Manifest AST parser + tests -- [ ] Use-case name helper + tests -- [ ] `no-undeclared-event-publish` rule + tests -- [ ] `no-undeclared-audit` rule + tests -- [ ] Plugin update + base.js wiring -- [ ] Verify `pnpm lint` passes -- [ ] Final verification + story closeout (tick 03 + 03.b in epic) +- [x] Story 03.b scaffold +- [x] Manifest AST parser + tests +- [x] Use-case name helper + tests +- [x] `no-undeclared-event-publish` rule + tests +- [x] `no-undeclared-audit` rule + tests +- [x] Plugin update + base.js wiring +- [x] Verify `pnpm lint` passes +- [x] Final verification + story closeout (tick 03 + 03.b in epic) diff --git a/docs/work/conformance-system-v1/_epic.md b/docs/work/conformance-system-v1/_epic.md index a0957d0..ff74758 100644 --- a/docs/work/conformance-system-v1/_epic.md +++ b/docs/work/conformance-system-v1/_epic.md @@ -32,9 +32,9 @@ See `docs/architecture/feature-conformance-explainer.html` and ## Stories - [x] [01 — defineFeature helper + Instrumented/Captured/Audited brands](01-define-feature-helper/_story.md) - [x] [02 — `assertFeatureConformance` + boot wiring](02-boot-assertions/_story.md) -- [ ] 03 — AST-aware ESLint rules (continuing — see 03.a + future 03.b) +- [x] 03 — AST-aware ESLint rules (both halves shipped) - [x] [03.a — Structural rules](03-a-structural-eslint-rules/_story.md) - - [ ] 03.b — Manifest-aware AST rules (later plan) + - [x] [03.b — Manifest-aware AST rules](03-b-ast-eslint-rules/_story.md) - [ ] 04 — CI drift gate (later plan) - [ ] 05 — Generator emits manifest + contracts + test stubs (later plan) - [ ] 06 — Documentation rewrite (later plan)