diff --git a/packages/core-eslint/rules/_manifest-source.js b/packages/core-eslint/rules/_manifest-source.js new file mode 100644 index 0000000..dba87ba --- /dev/null +++ b/packages/core-eslint/rules/_manifest-source.js @@ -0,0 +1,57 @@ +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 `` 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//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); +} diff --git a/packages/core-eslint/rules/_manifest-source.test.js b/packages/core-eslint/rules/_manifest-source.test.js new file mode 100644 index 0000000..bbab369 --- /dev/null +++ b/packages/core-eslint/rules/_manifest-source.test.js @@ -0,0 +1,74 @@ +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(); + }); + }); +});