# Conformance Milestone iii.b — AST-aware ESLint rules > **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Ship `no-undeclared-event-publish` and `no-undeclared-audit` rules that walk use-case AST and the parent feature.manifest.ts AST, fail on mismatch. Closes the inner ESLint feedback loop for cross-feature events and audit emissions. **Architecture:** Each rule uses `@typescript-eslint/parser` (already a dep) to parse the parent feature.manifest.ts and extract per-use-case `publishes[]` / `audits[]` arrays as an AST tree walk (regex was sufficient for iii.a's flat fields, not for nested useCases). Rules visit `CallExpression` nodes in the lint target. For each call matching `.publish("Y")` or `.record({ type: "Y", ... })`, compare against the manifest's declared array for the current use case. Use case identity is derived from the file slug (`sign-in.use-case.ts` → `signIn`). **Tech Stack:** TypeScript, `@typescript-eslint/parser` (already devDependency), Vitest, ESLint RuleTester. --- ## File structure ### Create - `packages/core-eslint/rules/_manifest-ast.js` — parse manifest.ts and extract per-use-case publishes/audits/consumes via AST walking - `packages/core-eslint/rules/_manifest-ast.test.js` - `packages/core-eslint/rules/_usecase-name.js` — slug→useCase-name helper (sign-in → signIn) - `packages/core-eslint/rules/_usecase-name.test.js` - `packages/core-eslint/rules/no-undeclared-event-publish.js` - `packages/core-eslint/rules/no-undeclared-event-publish.test.js` - `packages/core-eslint/rules/no-undeclared-audit.js` - `packages/core-eslint/rules/no-undeclared-audit.test.js` - `docs/work/conformance-system-v1/03-b-ast-eslint-rules/_story.md` ### Modify - `packages/core-eslint/plugin.js` — register new rules - `packages/core-eslint/base.js` — enable rules (as `warn` initially) - `docs/work/conformance-system-v1/_epic.md` — tick 03.b, then tick the parent 03 since both halves now shipped --- ## Task 1: Story 03.b scaffold **Files:** Create `docs/work/conformance-system-v1/03-b-ast-eslint-rules/_story.md` - [ ] **Step 1: Write the story file** ```markdown --- 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) ``` - [ ] **Step 2: Commit** ```bash git add docs/work/conformance-system-v1/03-b-ast-eslint-rules/_story.md git commit -m "docs(work): story 03.b — AST-aware ESLint rules" ``` --- ## Task 2: Manifest AST parser (`_manifest-ast.js`) Parses a `feature.manifest.ts` using `@typescript-eslint/parser` and returns a structured `{ useCases: { [name]: { publishes, audits, consumes } } }` object. **Files:** - Create: `packages/core-eslint/rules/_manifest-ast.js` - Create: `packages/core-eslint/rules/_manifest-ast.test.js` - [ ] **Step 1: Write the failing test** ```js 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(); }); }); ``` - [ ] **Step 2: Run test to verify it fails** ``` pnpm --filter @repo/core-eslint test _manifest-ast ``` Expected: FAIL. - [ ] **Step 3: Write `_manifest-ast.js`** ```js 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; // Match defineFeature(...) or defineFeature(...) chained if (init.type === "CallExpression" && init.callee.type === "Identifier" && init.callee.name === "defineFeature") { return init; } } } return null; } function unwrapAsConst(node) { // @typescript-eslint produces TSAsExpression for `expr as const` 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; } ``` - [ ] **Step 4: Run tests** ``` pnpm --filter @repo/core-eslint test _manifest-ast ``` Expected: PASS, 4 tests. - [ ] **Step 5: Commit** ```bash git add packages/core-eslint/rules/_manifest-ast.js packages/core-eslint/rules/_manifest-ast.test.js git commit -m "feat(core-eslint): manifest AST parser for per-use-case attributes" ``` --- ## Task 3: Use-case name helper Converts the file path slug to the manifest's use-case key. **Files:** - Create: `packages/core-eslint/rules/_usecase-name.js` - Create: `packages/core-eslint/rules/_usecase-name.test.js` - [ ] **Step 1: Write the failing test** ```js 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(); }); }); ``` - [ ] **Step 2: Run test to verify it fails** ``` pnpm --filter @repo/core-eslint test _usecase-name ``` - [ ] **Step 3: Write `_usecase-name.js`** ```js /** * 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(""); } ``` - [ ] **Step 4: Run tests** ``` pnpm --filter @repo/core-eslint test _usecase-name ``` Expected: PASS, 3 tests. - [ ] **Step 5: Commit** ```bash git add packages/core-eslint/rules/_usecase-name.js packages/core-eslint/rules/_usecase-name.test.js git commit -m "feat(core-eslint): use-case name helper (file slug → manifest key)" ``` --- ## Task 4: `no-undeclared-event-publish` rule Fires on `.publish("Y")` calls inside use-case files when `Y` is not declared in the parent manifest's `useCases[name].publishes`. **Files:** - Create: `packages/core-eslint/rules/no-undeclared-event-publish.js` - Create: `packages/core-eslint/rules/no-undeclared-event-publish.test.js` - [ ] **Step 1: Write the failing test** ```js 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: [], }); }); }); ``` - [ ] **Step 2: Run test to verify it fails** ``` pnpm --filter @repo/core-eslint test no-undeclared-event-publish ``` - [ ] **Step 3: Write `no-undeclared-event-publish.js`** ```js 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 } }); } } }, }; }, }; ``` - [ ] **Step 4: Run tests** ``` pnpm --filter @repo/core-eslint test no-undeclared-event-publish ``` Expected: PASS, 3 tests. - [ ] **Step 5: Commit** ```bash git add packages/core-eslint/rules/no-undeclared-event-publish.js packages/core-eslint/rules/no-undeclared-event-publish.test.js git commit -m "feat(core-eslint): no-undeclared-event-publish rule" ``` --- ## Task 5: `no-undeclared-audit` rule Fires on `auditLog.record({ type: "Y", ... })` calls when `Y` is not in `manifest.useCases[name].audits`. **Files:** - Create: `packages/core-eslint/rules/no-undeclared-audit.js` - Create: `packages/core-eslint/rules/no-undeclared-audit.test.js` - [ ] **Step 1: Write the failing test** ```js 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: [], }); }); }); ``` - [ ] **Step 2: Run test to verify it fails** - [ ] **Step 3: Write `no-undeclared-audit.js`** ```js 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 } }); } } } }, }; }, }; ``` - [ ] **Step 4: Run tests** ``` pnpm --filter @repo/core-eslint test no-undeclared-audit ``` Expected: PASS, 3 tests. - [ ] **Step 5: Commit** ```bash git add packages/core-eslint/rules/no-undeclared-audit.js packages/core-eslint/rules/no-undeclared-audit.test.js git commit -m "feat(core-eslint): no-undeclared-audit rule" ``` --- ## Task 6: Wire new rules into plugin + base.js **Files:** - Modify: `packages/core-eslint/plugin.js` - Modify: `packages/core-eslint/base.js` - [ ] **Step 1: Update plugin.js** Add two imports near the existing rule imports: ```js import noUndeclaredEventPublish from "./rules/no-undeclared-event-publish.js"; import noUndeclaredAudit from "./rules/no-undeclared-audit.js"; ``` Add two entries in the `rules` object: ```js "no-undeclared-event-publish": noUndeclaredEventPublish, "no-undeclared-audit": noUndeclaredAudit, ``` Bump the plugin version to `0.2.0`. - [ ] **Step 2: Update base.js** In the conformance plugin block, add two more rule entries (after `required-cores-installed`): ```js "conformance/no-undeclared-event-publish": ["warn", { repoRoot }], "conformance/no-undeclared-audit": ["warn", { repoRoot }], ``` WARN initially because the rules are new — flip to ERROR after a few days of clean runs. - [ ] **Step 3: Verify the eslint config still parses** ``` pnpm --filter @repo/core-eslint exec eslint --print-config base.js > /dev/null ``` - [ ] **Step 4: Commit** ```bash git add packages/core-eslint/plugin.js packages/core-eslint/base.js git commit -m "feat(core-eslint): wire AST-aware conformance rules into plugin + base" ``` --- ## Task 7: Verify `pnpm lint` passes ``` pnpm lint ``` Expected: 0 errors. New rules WARN by default; auth's manifest has empty publishes/audits so no false positives expected today. If any errors appear, STOP and report. --- ## Task 8: Final verification + closeout **Files:** - Modify: `docs/work/conformance-system-v1/03-b-ast-eslint-rules/_story.md` - Modify: `docs/work/conformance-system-v1/_epic.md` - [ ] **Step 1: Run verification matrix** ``` pnpm typecheck pnpm test pnpm lint pnpm turbo boundaries ``` All four PASS (warnings OK). - [ ] **Step 2: Tick story 03.b checkboxes + flip status to done** Edit `docs/work/conformance-system-v1/03-b-ast-eslint-rules/_story.md`: - frontmatter `status: in-progress` → `status: done` - All `- [ ]` → `- [x]` in Tasks section - [ ] **Step 3: Tick 03.b in epic + tick parent 03** Edit `docs/work/conformance-system-v1/_epic.md`. The current structure is: ```markdown - [ ] 03 — AST-aware ESLint rules (continuing — see 03.a + future 03.b) - [x] [03.a — Structural rules](03-a-structural-eslint-rules/_story.md) - [ ] 03.b — Manifest-aware AST rules (later plan) ``` Replace with: ```markdown - [x] 03 — AST-aware ESLint rules (both halves shipped) - [x] [03.a — Structural rules](03-a-structural-eslint-rules/_story.md) - [x] [03.b — Manifest-aware AST rules](03-b-ast-eslint-rules/_story.md) ``` - [ ] **Step 4: Commit** ```bash git add docs/work/conformance-system-v1/03-b-ast-eslint-rules/_story.md docs/work/conformance-system-v1/_epic.md git commit -m "docs(work): close story 03.b — AST-aware ESLint rules" ```