# Conformance Milestone iii.a — Structural ESLint rules

> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Stand up the custom ESLint rule plugin in `@repo/core-eslint` and ship three structural conformance rules: (1) `feature-must-have-manifest`, (2) `usecase-must-have-test-file`, (3) `required-cores-installed`. Each reads filesystem state (manifest presence, sibling test file, workspace.yaml content) — no cross-file AST analysis. Editor + CLI feedback in `<1s`.

**Architecture:** Custom rules live in `packages/core-eslint/rules/`. A new index file at `packages/core-eslint/plugin.js` exports them as a flat-config ESLint plugin. `base.js` references the plugin and enables each rule (initially as `error` or `warn` depending on whether all features satisfy it today). Rules use Node's `fs` to inspect sibling files and `js-yaml` to read `pnpm-workspace.yaml`. No TypeScript parsing yet — manifest content (for `required-cores-installed`) is extracted via a small regex on the source text, which is sufficient for the literal `as const` manifest shape.

**Tech Stack:** TypeScript (strict), Vitest, ESLint flat config, `@typescript-eslint/parser` (already in tree), `js-yaml` (already a transitive dep via Payload). No new runtime deps.

---

## File structure

### Create
- `packages/core-eslint/rules/feature-must-have-manifest.js` — rule module
- `packages/core-eslint/rules/feature-must-have-manifest.test.js` — RuleTester tests
- `packages/core-eslint/rules/usecase-must-have-test-file.js` — rule module
- `packages/core-eslint/rules/usecase-must-have-test-file.test.js` — RuleTester tests
- `packages/core-eslint/rules/required-cores-installed.js` — rule module
- `packages/core-eslint/rules/required-cores-installed.test.js` — RuleTester tests
- `packages/core-eslint/rules/_manifest-source.js` — small helper: load + regex-parse `feature.manifest.ts` for `name` and `requiredCores`
- `packages/core-eslint/rules/_manifest-source.test.js` — manifest-source unit tests
- `packages/core-eslint/rules/_workspace.js` — small helper: read `pnpm-workspace.yaml` and return the list of declared package globs
- `packages/core-eslint/plugin.js` — flat-config plugin export aggregating the three rules
- `docs/work/conformance-system-v1/03-a-structural-eslint-rules/_story.md` — story 03.a record

### Modify
- `packages/core-eslint/package.json` — add `js-yaml` as dependency (if not already declared) and add an `"exports"` entry for `./plugin`
- `packages/core-eslint/base.js` — import the plugin, register the three rules in the relevant `files` block(s)
- `docs/work/conformance-system-v1/_epic.md` — tick story 03 (which is the umbrella for both 03.a and 03.b) once both ship; for now add 03.a as a checked link, leave 03 unchecked
- `pnpm-workspace.yaml` — no changes (read-only consumption)

---

## Task 1: Story 03.a scaffold

**Files:**
- Create: `docs/work/conformance-system-v1/03-a-structural-eslint-rules/_story.md`

- [ ] **Step 1: Write the story file**

```markdown
---
id: 03-a-structural-eslint-rules
epic: conformance-system-v1
title: Structural ESLint rules (feature-must-have-manifest, usecase-must-have-test-file, required-cores-installed)
type: technical-story
status: in-progress
feature: core-eslint
depends-on: [02-boot-assertions]
blocks: [03-b-ast-aware-eslint-rules]
---

## Goal
Stand up the custom ESLint rule plugin in `@repo/core-eslint` and ship three
structural conformance rules that don't require cross-file AST analysis.
Editor + CLI feedback fires in <1s.

## Why
Boot-time assertion catches drift in bound use cases, but cannot catch:
- Features that exist on disk but have no manifest at all
- Use-case files lacking a sibling test file
- Manifest declaring required cores that aren't in `pnpm-workspace.yaml`

Structural ESLint rules surface these in the editor, before code is even
saved past lint-on-save.

## Done when
- Custom rule plugin exists at `packages/core-eslint/plugin.js`
- Three rules registered: `conformance/feature-must-have-manifest`,
  `conformance/usecase-must-have-test-file`,
  `conformance/required-cores-installed`
- Each rule has RuleTester tests with positive + negative cases
- `base.js` registers the plugin and enables the rules
- `pnpm lint` passes for the current monorepo state (rules tuned to today's
  reality — see Out of scope)

## In scope
- Custom rule plugin module at `packages/core-eslint/plugin.js`
- Rule modules in `packages/core-eslint/rules/*.js`
- Manifest text parser (`_manifest-source.js`) — regex-based, sufficient for
  literal `as const` manifests
- Workspace.yaml reader (`_workspace.js`)
- Rule integration into `base.js`
- Tests for each rule using ESLint's RuleTester

## Out of scope
- Cross-file AST analysis rules (`no-undeclared-event-publish`,
  `no-undeclared-audit`) — milestone iii.b
- Enforcement on features that don't yet have a manifest — `feature-must-have-manifest`
  ships as a WARNING today (only auth has a manifest); flips to ERROR after
  blog/media/navigation/marketing-pages get manifests
- Manifest parser based on TypeScript compiler API — regex is sufficient for
  literal manifests; the AST path comes in iii.b

## Tasks
- [ ] Story 03.a scaffold
- [ ] Manifest source helper (`_manifest-source.js`)
- [ ] Workspace helper (`_workspace.js`)
- [ ] `feature-must-have-manifest` rule + tests
- [ ] `usecase-must-have-test-file` rule + tests
- [ ] `required-cores-installed` rule + tests
- [ ] Plugin module + `exports` entry in `package.json`
- [ ] Wire plugin into `base.js`
- [ ] Verify `pnpm lint` passes against the monorepo
- [ ] Final verification + story closeout
```

- [ ] **Step 2: Commit**

```bash
git add docs/work/conformance-system-v1/03-a-structural-eslint-rules/_story.md
git commit -m "docs(work): story 03.a — structural ESLint rules"
```

---

## Task 2: Manifest source helper (`_manifest-source.js`)

A small filesystem helper that loads a `feature.manifest.ts` file and extracts two pieces of information via regex: the `name` literal and the `requiredCores` array. The manifest is in a known canonical shape (`defineFeature({ name: "<x>", requiredCores: [...], ... } as const)`), so regex is sufficient — no TypeScript parser required.

**Files:**
- Create: `packages/core-eslint/rules/_manifest-source.js`
- Create: `packages/core-eslint/rules/_manifest-source.test.js`

- [ ] **Step 1: Write the failing test**

`packages/core-eslint/rules/_manifest-source.test.js`:

```js
import { describe, it, expect } from "vitest";
import path from "node:path";
import os from "node:os";
import fs from "node:fs";
import { readManifestSource, manifestPathForFeature, featureRootForFile } from "./_manifest-source.js";

function writeTempFile(filename, contents) {
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "manifest-source-"));
  const filepath = path.join(dir, filename);
  fs.writeFileSync(filepath, contents);
  return { dir, filepath };
}

describe("_manifest-source", () => {
  describe("readManifestSource", () => {
    it("returns { name, requiredCores: [] } for a minimal manifest", () => {
      const { filepath } = writeTempFile(
        "feature.manifest.ts",
        `import { defineFeature } from "@repo/core-shared/conformance";
export const xManifest = defineFeature({
  name: "test",
  requiredCores: [],
  useCases: {},
  realtimeChannels: [],
  jobs: [],
} as const);`
      );
      expect(readManifestSource(filepath)).toEqual({ name: "test", requiredCores: [] });
    });

    it("extracts multi-element requiredCores", () => {
      const { filepath } = writeTempFile(
        "feature.manifest.ts",
        `export const xManifest = defineFeature({
  name: "auth",
  requiredCores: ["audit", "events"],
  useCases: {},
  realtimeChannels: [],
  jobs: [],
} as const);`
      );
      expect(readManifestSource(filepath)).toEqual({ name: "auth", requiredCores: ["audit", "events"] });
    });

    it("returns null when the file does not exist", () => {
      expect(readManifestSource("/nonexistent/path/feature.manifest.ts")).toBeNull();
    });

    it("returns null when the file is unreadable as a manifest (no name field)", () => {
      const { filepath } = writeTempFile("feature.manifest.ts", `export const x = 1;`);
      expect(readManifestSource(filepath)).toBeNull();
    });
  });

  describe("manifestPathForFeature", () => {
    it("returns the canonical manifest path for a feature root", () => {
      expect(manifestPathForFeature("/repo/packages/auth")).toBe(
        path.join("/repo/packages/auth", "src", "feature.manifest.ts"),
      );
    });
  });

  describe("featureRootForFile", () => {
    it("returns the package root containing a use-case file", () => {
      const file = "/repo/packages/auth/src/application/use-cases/sign-in.use-case.ts";
      expect(featureRootForFile(file, "/repo")).toBe("/repo/packages/auth");
    });

    it("returns null for files outside the packages tree", () => {
      const file = "/repo/apps/web-next/src/app/page.tsx";
      expect(featureRootForFile(file, "/repo")).toBeNull();
    });
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

```
pnpm --filter @repo/core-eslint test _manifest-source
```
Expected: FAIL — module not found.

- [ ] **Step 3: Write `_manifest-source.js`**

```js
import fs from "node:fs";
import path from "node:path";

/**
 * Reads a feature.manifest.ts file and extracts the manifest's `name` field
 * and `requiredCores` array via regex. Returns null if the file does not
 * exist or does not match the expected literal `as const` manifest shape.
 *
 * The repo's convention is that every feature.manifest.ts uses defineFeature
 * with literal `as const` syntax — this is enforced by the type-system
 * design (defineFeature has `<const M>` to preserve literal types). The
 * regex extraction is therefore safe; the AST path is overkill.
 *
 * Returns: { name: string, requiredCores: string[] } | null
 */
export function readManifestSource(manifestPath) {
  let src;
  try {
    src = fs.readFileSync(manifestPath, "utf8");
  } catch {
    return null;
  }
  const nameMatch = src.match(/name:\s*"([^"]+)"/);
  if (!nameMatch) return null;
  const coresMatch = src.match(/requiredCores:\s*\[([^\]]*)\]/);
  const cores = coresMatch
    ? coresMatch[1]
        .split(",")
        .map((s) => s.trim().replace(/^"/, "").replace(/"$/, ""))
        .filter((s) => s.length > 0)
    : [];
  return { name: nameMatch[1], requiredCores: cores };
}

/**
 * Canonical manifest path for a given feature package root.
 */
export function manifestPathForFeature(featureRoot) {
  return path.join(featureRoot, "src", "feature.manifest.ts");
}

/**
 * Given an absolute file path and the monorepo root, returns the feature
 * package root that contains the file (e.g. /repo/packages/auth). Returns
 * null when the file lives outside the packages/ tree.
 *
 * Assumes the conventional layout `packages/<feature>/src/...`.
 */
export function featureRootForFile(filepath, repoRoot) {
  const packagesDir = path.join(repoRoot, "packages");
  if (!filepath.startsWith(packagesDir + path.sep)) return null;
  const rel = filepath.slice(packagesDir.length + 1); // e.g. "auth/src/..."
  const slash = rel.indexOf(path.sep);
  if (slash === -1) return null;
  const featureName = rel.slice(0, slash);
  return path.join(packagesDir, featureName);
}
```

- [ ] **Step 4: Run tests**

```
pnpm --filter @repo/core-eslint test _manifest-source
```
Expected: PASS, 7 tests.

- [ ] **Step 5: Commit**

```bash
git add packages/core-eslint/rules/_manifest-source.js packages/core-eslint/rules/_manifest-source.test.js
git commit -m "feat(core-eslint): manifest source helper for conformance rules"
```

---

## Task 3: Workspace helper (`_workspace.js`)

**Files:**
- Create: `packages/core-eslint/rules/_workspace.js`
- Create: `packages/core-eslint/rules/_workspace.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 { readWorkspacePackages } from "./_workspace.js";

function writeWorkspaceYaml(contents) {
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "workspace-"));
  const filepath = path.join(dir, "pnpm-workspace.yaml");
  fs.writeFileSync(filepath, contents);
  return { dir, filepath };
}

describe("_workspace", () => {
  it("returns the list of declared package globs", () => {
    const { dir } = writeWorkspaceYaml(
      `packages:
  - "apps/*"
  - "packages/*"
`,
    );
    expect(readWorkspacePackages(dir)).toEqual(["apps/*", "packages/*"]);
  });

  it("returns [] when pnpm-workspace.yaml is missing", () => {
    const dir = fs.mkdtempSync(path.join(os.tmpdir(), "workspace-"));
    expect(readWorkspacePackages(dir)).toEqual([]);
  });

  it("returns [] when packages key is absent", () => {
    const { dir } = writeWorkspaceYaml(`# empty\n`);
    expect(readWorkspacePackages(dir)).toEqual([]);
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

```
pnpm --filter @repo/core-eslint test _workspace
```
Expected: FAIL.

- [ ] **Step 3: Write `_workspace.js`**

Note: we deliberately avoid pulling in `js-yaml` as a dep. The `packages:` list in `pnpm-workspace.yaml` has a stable shape; a simple regex extraction is sufficient and avoids one transitive dependency.

```js
import fs from "node:fs";
import path from "node:path";

/**
 * Reads the `packages:` list from `pnpm-workspace.yaml` at the given repo
 * root. Returns an array of package glob strings (e.g. ["apps/*", "packages/*"]).
 * Returns [] when the file is missing or has no `packages:` key.
 *
 * Uses regex extraction over YAML parsing to avoid a `js-yaml` dependency;
 * the `packages:` block in pnpm-workspace.yaml has a stable, well-known
 * shape and this approach is sufficient.
 */
export function readWorkspacePackages(repoRoot) {
  const yamlPath = path.join(repoRoot, "pnpm-workspace.yaml");
  let src;
  try {
    src = fs.readFileSync(yamlPath, "utf8");
  } catch {
    return [];
  }
  const blockMatch = src.match(/^packages:\s*\n((?:\s+-\s+.+\n?)+)/m);
  if (!blockMatch) return [];
  return blockMatch[1]
    .split("\n")
    .map((line) => line.match(/^\s+-\s+"?([^"]+?)"?\s*$/))
    .filter((m) => m !== null)
    .map((m) => m[1]);
}
```

- [ ] **Step 4: Run tests**

```
pnpm --filter @repo/core-eslint test _workspace
```
Expected: PASS, 3 tests.

- [ ] **Step 5: Commit**

```bash
git add packages/core-eslint/rules/_workspace.js packages/core-eslint/rules/_workspace.test.js
git commit -m "feat(core-eslint): workspace helper for conformance rules"
```

---

## Task 4: `feature-must-have-manifest` rule

Fires on any TypeScript file inside `packages/<feature>/src/application/use-cases/` if the feature package does NOT have a `feature.manifest.ts`. Reports once per use-case file (deliberately verbose so the agent sees the error wherever they're looking).

**Files:**
- Create: `packages/core-eslint/rules/feature-must-have-manifest.js`
- Create: `packages/core-eslint/rules/feature-must-have-manifest.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 "./feature-must-have-manifest.js";

function makeFeatureFixture({ withManifest }) {
  const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "fmm-"));
  const featureDir = path.join(repoRoot, "packages", "demo");
  fs.mkdirSync(path.join(featureDir, "src", "application", "use-cases"), { recursive: true });
  if (withManifest) {
    fs.writeFileSync(
      path.join(featureDir, "src", "feature.manifest.ts"),
      `export const demoManifest = defineFeature({ name: "demo", requiredCores: [], useCases: {}, realtimeChannels: [], jobs: [] } as const);`,
    );
  }
  const useCaseFile = path.join(featureDir, "src", "application", "use-cases", "do-thing.use-case.ts");
  fs.writeFileSync(useCaseFile, `export const doThingUseCase = () => async () => {};`);
  return { repoRoot, useCaseFile };
}

const tester = new RuleTester({ languageOptions: { ecmaVersion: "latest", sourceType: "module" } });

describe("feature-must-have-manifest", () => {
  it("passes when the feature has a manifest", () => {
    const { repoRoot, useCaseFile } = makeFeatureFixture({ withManifest: true });
    tester.run("feature-must-have-manifest", rule, {
      valid: [
        {
          filename: useCaseFile,
          code: fs.readFileSync(useCaseFile, "utf8"),
          options: [{ repoRoot }],
        },
      ],
      invalid: [],
    });
  });

  it("fires when the feature has no manifest", () => {
    const { repoRoot, useCaseFile } = makeFeatureFixture({ withManifest: false });
    tester.run("feature-must-have-manifest", rule, {
      valid: [],
      invalid: [
        {
          filename: useCaseFile,
          code: fs.readFileSync(useCaseFile, "utf8"),
          options: [{ repoRoot }],
          errors: [{ messageId: "missingManifest" }],
        },
      ],
    });
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

```
pnpm --filter @repo/core-eslint test feature-must-have-manifest
```
Expected: FAIL — module not found.

- [ ] **Step 3: Write `feature-must-have-manifest.js`**

```js
import fs from "node:fs";
import { manifestPathForFeature, featureRootForFile } from "./_manifest-source.js";

/** @type {import("eslint").Rule.RuleModule} */
export default {
  meta: {
    type: "problem",
    docs: {
      description:
        "Every feature with use-case files must declare a feature.manifest.ts at its src/ root.",
    },
    schema: [
      {
        type: "object",
        properties: {
          repoRoot: { type: "string" },
        },
        additionalProperties: false,
      },
    ],
    messages: {
      missingManifest:
        "Feature {{feature}} has use cases but no feature.manifest.ts. Run `pnpm turbo gen feature {{feature}}` or scaffold the manifest manually at {{expected}}.",
    },
  },
  create(context) {
    const opts = context.options[0] ?? {};
    const repoRoot = opts.repoRoot ?? context.cwd ?? process.cwd();
    return {
      Program(node) {
        const filename = context.filename;
        const featureRoot = featureRootForFile(filename, repoRoot);
        if (!featureRoot) return;
        // Only check use-case files
        if (!filename.includes("/application/use-cases/") || !filename.endsWith(".use-case.ts")) {
          return;
        }
        const manifestPath = manifestPathForFeature(featureRoot);
        if (fs.existsSync(manifestPath)) return;
        const featureName = featureRoot.split("/").pop();
        context.report({
          node,
          messageId: "missingManifest",
          data: { feature: featureName, expected: manifestPath },
        });
      },
    };
  },
};
```

- [ ] **Step 4: Run tests**

```
pnpm --filter @repo/core-eslint test feature-must-have-manifest
```
Expected: PASS, 2 tests.

- [ ] **Step 5: Commit**

```bash
git add packages/core-eslint/rules/feature-must-have-manifest.js packages/core-eslint/rules/feature-must-have-manifest.test.js
git commit -m "feat(core-eslint): feature-must-have-manifest rule"
```

---

## Task 5: `usecase-must-have-test-file` rule

Fires on any `*.use-case.ts` file that does NOT have a sibling `*.use-case.test.ts`. No manifest involvement; pure filesystem check.

**Files:**
- Create: `packages/core-eslint/rules/usecase-must-have-test-file.js`
- Create: `packages/core-eslint/rules/usecase-must-have-test-file.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 "./usecase-must-have-test-file.js";

function makeUseCaseFixture({ withTest }) {
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "umht-"));
  const useCase = path.join(dir, "sign-in.use-case.ts");
  fs.writeFileSync(useCase, `export const signInUseCase = () => async () => {};`);
  if (withTest) {
    fs.writeFileSync(path.join(dir, "sign-in.use-case.test.ts"), `import { it } from "vitest"; it("works", () => {});`);
  }
  return { useCase };
}

const tester = new RuleTester({ languageOptions: { ecmaVersion: "latest", sourceType: "module" } });

describe("usecase-must-have-test-file", () => {
  it("passes when a sibling .test.ts exists", () => {
    const { useCase } = makeUseCaseFixture({ withTest: true });
    tester.run("usecase-must-have-test-file", rule, {
      valid: [{ filename: useCase, code: fs.readFileSync(useCase, "utf8") }],
      invalid: [],
    });
  });

  it("fires when no sibling test file exists", () => {
    const { useCase } = makeUseCaseFixture({ withTest: false });
    tester.run("usecase-must-have-test-file", rule, {
      valid: [],
      invalid: [
        {
          filename: useCase,
          code: fs.readFileSync(useCase, "utf8"),
          errors: [{ messageId: "missingTestFile" }],
        },
      ],
    });
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

```
pnpm --filter @repo/core-eslint test usecase-must-have-test-file
```
Expected: FAIL.

- [ ] **Step 3: Write `usecase-must-have-test-file.js`**

```js
import fs from "node:fs";

/** @type {import("eslint").Rule.RuleModule} */
export default {
  meta: {
    type: "problem",
    docs: {
      description:
        "Every *.use-case.ts file must have a sibling *.use-case.test.ts (TDD discipline).",
    },
    schema: [],
    messages: {
      missingTestFile:
        "Use case {{filename}} has no sibling test file at {{expected}}. Write the red test first.",
    },
  },
  create(context) {
    return {
      Program(node) {
        const filename = context.filename;
        if (!filename.endsWith(".use-case.ts")) return;
        const expected = filename.replace(/\.use-case\.ts$/, ".use-case.test.ts");
        if (fs.existsSync(expected)) return;
        context.report({
          node,
          messageId: "missingTestFile",
          data: { filename: filename.split("/").pop(), expected: expected.split("/").pop() },
        });
      },
    };
  },
};
```

- [ ] **Step 4: Run tests**

```
pnpm --filter @repo/core-eslint test usecase-must-have-test-file
```
Expected: PASS, 2 tests.

- [ ] **Step 5: Commit**

```bash
git add packages/core-eslint/rules/usecase-must-have-test-file.js packages/core-eslint/rules/usecase-must-have-test-file.test.js
git commit -m "feat(core-eslint): usecase-must-have-test-file rule"
```

---

## Task 6: `required-cores-installed` rule

Fires on a feature's `feature.manifest.ts` if any entry in its `requiredCores` array is NOT listed (via glob match) in `pnpm-workspace.yaml`.

**Files:**
- Create: `packages/core-eslint/rules/required-cores-installed.js`
- Create: `packages/core-eslint/rules/required-cores-installed.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 "./required-cores-installed.js";

function makeFixture({ workspacePackages, manifestCores }) {
  const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "rci-"));
  fs.writeFileSync(
    path.join(repoRoot, "pnpm-workspace.yaml"),
    `packages:\n${workspacePackages.map((p) => `  - "${p}"`).join("\n")}\n`,
  );
  const manifestDir = path.join(repoRoot, "packages", "demo", "src");
  fs.mkdirSync(manifestDir, { recursive: true });
  // We also need each declared core to exist as a package directory for the glob to resolve.
  for (const core of manifestCores) {
    fs.mkdirSync(path.join(repoRoot, "packages", `core-${core}`), { recursive: true });
  }
  const manifest = path.join(manifestDir, "feature.manifest.ts");
  fs.writeFileSync(
    manifest,
    `export const demoManifest = defineFeature({
  name: "demo",
  requiredCores: [${manifestCores.map((c) => `"${c}"`).join(", ")}],
  useCases: {},
  realtimeChannels: [],
  jobs: [],
} as const);`,
  );
  return { repoRoot, manifest };
}

const tester = new RuleTester({ languageOptions: { ecmaVersion: "latest", sourceType: "module" } });

describe("required-cores-installed", () => {
  it("passes when all declared cores are present as core-<x> packages under a workspace glob", () => {
    const { repoRoot, manifest } = makeFixture({
      workspacePackages: ["packages/*"],
      manifestCores: ["audit", "events"],
    });
    tester.run("required-cores-installed", rule, {
      valid: [
        {
          filename: manifest,
          code: fs.readFileSync(manifest, "utf8"),
          options: [{ repoRoot }],
        },
      ],
      invalid: [],
    });
  });

  it("fires for any declared core that has no matching package", () => {
    const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "rci-"));
    fs.writeFileSync(path.join(repoRoot, "pnpm-workspace.yaml"), `packages:\n  - "packages/*"\n`);
    const manifestDir = path.join(repoRoot, "packages", "demo", "src");
    fs.mkdirSync(manifestDir, { recursive: true });
    // NB: do NOT create packages/core-realtime — that's the missing one.
    fs.mkdirSync(path.join(repoRoot, "packages", "core-audit"), { recursive: true });
    const manifest = path.join(manifestDir, "feature.manifest.ts");
    fs.writeFileSync(
      manifest,
      `export const demoManifest = defineFeature({
  name: "demo",
  requiredCores: ["audit", "realtime"],
  useCases: {},
  realtimeChannels: [],
  jobs: [],
} as const);`,
    );

    tester.run("required-cores-installed", rule, {
      valid: [],
      invalid: [
        {
          filename: manifest,
          code: fs.readFileSync(manifest, "utf8"),
          options: [{ repoRoot }],
          errors: [{ messageId: "coreNotInstalled", data: { core: "realtime" } }],
        },
      ],
    });
  });

  it("is a no-op for files that are not feature.manifest.ts", () => {
    const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rci-"));
    const other = path.join(dir, "not-a-manifest.ts");
    fs.writeFileSync(other, `export const x = 1;`);
    tester.run("required-cores-installed", rule, {
      valid: [{ filename: other, code: fs.readFileSync(other, "utf8"), options: [{ repoRoot: dir }] }],
      invalid: [],
    });
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

```
pnpm --filter @repo/core-eslint test required-cores-installed
```
Expected: FAIL.

- [ ] **Step 3: Write `required-cores-installed.js`**

```js
import fs from "node:fs";
import path from "node:path";
import { readManifestSource } from "./_manifest-source.js";
import { readWorkspacePackages } from "./_workspace.js";

/**
 * Check whether `packages/core-<name>` exists under any of the workspace
 * globs. The glob set is small and predictable (e.g. ["apps/*", "packages/*"]);
 * we simulate matching by checking each glob's directory portion + verifying
 * `core-<name>` exists in that directory.
 */
function coreExistsInWorkspace(coreName, repoRoot, packageGlobs) {
  for (const glob of packageGlobs) {
    const slashStar = glob.endsWith("/*") ? glob.slice(0, -2) : null;
    if (!slashStar) continue;
    const candidate = path.join(repoRoot, slashStar, `core-${coreName}`);
    if (fs.existsSync(candidate)) return true;
  }
  return false;
}

/** @type {import("eslint").Rule.RuleModule} */
export default {
  meta: {
    type: "problem",
    docs: {
      description:
        "Cores declared in a feature.manifest.ts's requiredCores must exist as core-<name> packages within a workspace glob.",
    },
    schema: [
      {
        type: "object",
        properties: {
          repoRoot: { type: "string" },
        },
        additionalProperties: false,
      },
    ],
    messages: {
      coreNotInstalled:
        "Manifest declares requiredCores: [..., \"{{core}}\", ...] but `core-{{core}}` is not present in any workspace glob. Run `pnpm turbo gen core-package {{core}}` or drop the entry.",
    },
  },
  create(context) {
    const opts = context.options[0] ?? {};
    const repoRoot = opts.repoRoot ?? context.cwd ?? process.cwd();
    return {
      Program(node) {
        const filename = context.filename;
        if (!filename.endsWith("/feature.manifest.ts") && !filename.endsWith("\\feature.manifest.ts")) {
          return;
        }
        const manifest = readManifestSource(filename);
        if (!manifest) return;
        const globs = readWorkspacePackages(repoRoot);
        for (const core of manifest.requiredCores) {
          if (!coreExistsInWorkspace(core, repoRoot, globs)) {
            context.report({
              node,
              messageId: "coreNotInstalled",
              data: { core },
            });
          }
        }
      },
    };
  },
};
```

- [ ] **Step 4: Run tests**

```
pnpm --filter @repo/core-eslint test required-cores-installed
```
Expected: PASS, 3 tests.

- [ ] **Step 5: Commit**

```bash
git add packages/core-eslint/rules/required-cores-installed.js packages/core-eslint/rules/required-cores-installed.test.js
git commit -m "feat(core-eslint): required-cores-installed rule"
```

---

## Task 7: Plugin module + `package.json` exports

**Files:**
- Create: `packages/core-eslint/plugin.js`
- Modify: `packages/core-eslint/package.json`

- [ ] **Step 1: Write the plugin module**

`packages/core-eslint/plugin.js`:

```js
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";

/**
 * The `@repo/core-eslint` conformance plugin. Aggregates custom rules that
 * enforce feature-conformance contracts (manifest presence, sibling tests,
 * required-cores ↔ workspace consistency).
 *
 * Registered as the `conformance` plugin in flat config:
 *
 *   import conformancePlugin from "@repo/core-eslint/plugin";
 *   export default [
 *     { plugins: { conformance: conformancePlugin } },
 *     { rules: { "conformance/feature-must-have-manifest": ["warn", { repoRoot: import.meta.dirname }] } },
 *   ];
 */
const plugin = {
  meta: { name: "conformance", version: "0.1.0" },
  rules: {
    "feature-must-have-manifest": featureMustHaveManifest,
    "usecase-must-have-test-file": usecaseMustHaveTestFile,
    "required-cores-installed": requiredCoresInstalled,
  },
};

export default plugin;
```

- [ ] **Step 2: Add `./plugin` to `package.json` exports**

Open `packages/core-eslint/package.json`. Find the `"exports"` object (or `"main"` if no exports yet). Add an entry for `./plugin`:

```json
"./plugin": "./plugin.js"
```

If the package.json has no `"exports"` field today, add one. Match the existing field style.

- [ ] **Step 3: Verify the plugin loads**

```
node -e "import('./packages/core-eslint/plugin.js').then(m => console.log(Object.keys(m.default.rules)))"
```

Expected output:
```
[
  'feature-must-have-manifest',
  'usecase-must-have-test-file',
  'required-cores-installed'
]
```

- [ ] **Step 4: Commit**

```bash
git add packages/core-eslint/plugin.js packages/core-eslint/package.json
git commit -m "feat(core-eslint): conformance plugin module + ./plugin export"
```

---

## Task 8: Wire the plugin into `base.js`

**Files:**
- Modify: `packages/core-eslint/base.js`

- [ ] **Step 1: Add the import + the plugin block**

Open `packages/core-eslint/base.js`. Near the other imports (top of file), add:

```js
import conformancePlugin from "./plugin.js";
import path from "node:path";
import { fileURLToPath } from "node:url";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, "..", "..");
```

(`__dirname` is needed because the file is ESM. `repoRoot` resolves to two levels up from `packages/core-eslint/`.)

Then add a new entry to the exported flat-config array (place it near the existing `plugins: { turbo: turboPlugin }` block):

```js
{
  plugins: { conformance: conformancePlugin },
  rules: {
    // Structural conformance rules (milestone iii.a).
    // `feature-must-have-manifest` is WARN today because only auth has a manifest;
    // flip to ERROR after blog/media/navigation/marketing-pages migrate.
    "conformance/feature-must-have-manifest": [
      "warn",
      { repoRoot },
    ],
    "conformance/usecase-must-have-test-file": "error",
    "conformance/required-cores-installed": [
      "error",
      { repoRoot },
    ],
  },
},
```

- [ ] **Step 2: Verify the eslint config still parses**

```
pnpm --filter @repo/core-eslint exec eslint --print-config base.js > /dev/null
```

Expected: no error output (the resolved config is printed and discarded).

- [ ] **Step 3: Commit**

```bash
git add packages/core-eslint/base.js
git commit -m "feat(core-eslint): register conformance plugin + structural rules in base config"
```

---

## Task 9: Verify `pnpm lint` passes for the monorepo

**Files:** none (verification only)

- [ ] **Step 1: Run lint across the monorepo**

```
pnpm lint
```

Expected outcome:
- `feature-must-have-manifest` is a WARN, so it doesn't fail lint
- `usecase-must-have-test-file` and `required-cores-installed` are ERROR

Likely issues to anticipate:
- (a) Some `*.use-case.ts` files may not have sibling tests — this would fail lint. Investigate and either add the missing tests OR change the rule to `warn` temporarily.
- (b) `auth/src/feature.manifest.ts` declares `requiredCores: []` so `required-cores-installed` has nothing to check. Should pass.

If failures appear, capture them and STOP. Report failures with the file paths so we can decide whether to fix the underlying issue OR adjust the rule's severity in `base.js`.

- [ ] **Step 2: If pnpm lint passes, no commit needed (verification step)**

If lint fails, DO NOT commit any temporary workarounds. Report failures verbatim.

---

## Task 10: Final verification + story closeout

**Files:**
- Modify: `docs/work/conformance-system-v1/03-a-structural-eslint-rules/_story.md`
- Modify: `docs/work/conformance-system-v1/_epic.md`

- [ ] **Step 1: Run the full verification matrix**

```
pnpm typecheck
pnpm test
pnpm lint
pnpm turbo boundaries
```

All four MUST pass. Stop on any failure.

- [ ] **Step 2: Tick the story's task checkboxes and flip status**

Edit `docs/work/conformance-system-v1/03-a-structural-eslint-rules/_story.md`:

(a) Frontmatter: change `status: in-progress` → `status: done`
(b) Change all 10 `- [ ]` task checkboxes to `- [x]`

- [ ] **Step 3: Update `_epic.md` to add 03.a as a checked link**

Edit `docs/work/conformance-system-v1/_epic.md`. Find the line:

```markdown
- [ ] 03 — AST-aware ESLint rules (later plan)
```

Replace with:

```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)
```

(Indented sub-items represent the split — 03.a is done; 03.b is the follow-up for `no-undeclared-event-publish` / `no-undeclared-audit`.)

- [ ] **Step 4: Commit the milestone close**

```bash
git add docs/work/conformance-system-v1/03-a-structural-eslint-rules/_story.md docs/work/conformance-system-v1/_epic.md
git commit -m "docs(work): close story 03.a — structural ESLint rules"
```

---

## Done — what this leaves behind

- `@repo/core-eslint/plugin.js` exports a flat-config plugin named `conformance` with three rules
- `feature-must-have-manifest` (warn): every feature with use-case files must declare a manifest
- `usecase-must-have-test-file` (error): every use-case file must have a sibling test
- `required-cores-installed` (error): every manifest's `requiredCores` entry must exist as a `core-<name>` package in a workspace glob
- Helpers `_manifest-source.js` and `_workspace.js` extracted as small modules with their own tests
- All wired into `base.js`; `pnpm lint` clean

## What comes next (separate plan: milestone iii.b)

The AST-aware rules that read manifest content + walk use-case AST:

- `no-undeclared-event-publish` — `bus.publish("X")` in a factory body must match `manifest.useCases[name].publishes`
- `no-undeclared-audit` — `auditLog.record({...})` in a factory body must match `manifest.useCases[name].audits`

These require parsing the manifest as JS (not just regex extraction) AND walking the use-case AST to find call expressions. Heavier — a separate plan.
