Files
agentic-dev/docs/superpowers/plans/2026-05-12-conformance-milestone-iii-a-structural-rules.md
Danijel Martinek f22f747aa9 plan(conformance): milestone iii.a — structural ESLint rules
10-task plan for standing up the conformance ESLint plugin in
@repo/core-eslint and shipping three structural rules:

- feature-must-have-manifest (warn): use-case files require a manifest
- usecase-must-have-test-file (error): TDD sibling test enforcement
- required-cores-installed (error): manifest cores ↔ workspace.yaml

Scope deliberately excludes manifest-AST analysis (no-undeclared-event-
publish, no-undeclared-audit) — those require parsing the manifest's TS
source as JS and walking the use-case AST, deferred to plan iii.b.

Uses regex-based manifest source extraction to avoid pulling in the
TypeScript compiler API at lint time. Manifest shape is constrained by
the literal `as const` form that defineFeature enforces, so the regex
path is sufficient.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 23:15:35 +02:00

36 KiB

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

---
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
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:

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

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.

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

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

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

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
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
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:

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:

"./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
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:

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

{
  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
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-progressstatus: 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:

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

Replace with:

- [ ] 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
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-publishbus.publish("X") in a factory body must match manifest.useCases[name].publishes
  • no-undeclared-auditauditLog.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.