#!/usr/bin/env node /** * pnpm conformance — cross-feature drift gate. * * Walks every `packages/*\/src/feature.manifest.ts`, reuses the AST parser * from `@repo/core-eslint` to extract per-use-case publishes/consumes, * builds global publish + consume sets across all features, and fails on: * * - Orphan consumer: a feature declares `consumes: ["X"]` but no * feature publishes "X". * * Exits 0 on success, 1 on any violation. Prints a tabular summary of * the event graph for transparency. */ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { parseManifestUseCases } from "../packages/core-eslint/rules/_manifest-ast.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = path.resolve(__dirname, ".."); export function findAllManifests(repoRoot = REPO_ROOT) { const packagesDir = path.join(repoRoot, "packages"); if (!fs.existsSync(packagesDir)) return []; const out = []; for (const entry of fs.readdirSync(packagesDir)) { const manifestPath = path.join(packagesDir, entry, "src", "feature.manifest.ts"); if (fs.existsSync(manifestPath)) { out.push({ feature: entry, path: manifestPath }); } } return out; } export function buildEventGraph(manifests) { const graph = new Map(); for (const { feature, path: manifestPath } of manifests) { const useCases = parseManifestUseCases(manifestPath); if (!useCases) continue; for (const [useCase, entry] of Object.entries(useCases)) { for (const event of entry.publishes) { if (!graph.has(event)) graph.set(event, { publishers: [], consumers: [] }); graph.get(event).publishers.push({ feature, useCase }); } for (const event of entry.consumes) { if (!graph.has(event)) graph.set(event, { publishers: [], consumers: [] }); graph.get(event).consumers.push({ feature, useCase }); } } } return graph; } export function findOrphanConsumers(graph) { const orphans = []; for (const [event, { publishers, consumers }] of graph.entries()) { if (consumers.length > 0 && publishers.length === 0) { orphans.push({ event, consumers }); } } return orphans; } function main() { const manifests = findAllManifests(); console.log(`Found ${manifests.length} feature manifest(s):`); for (const { feature } of manifests) console.log(` - ${feature}`); console.log(); const graph = buildEventGraph(manifests); if (graph.size === 0) { console.log("No cross-feature events declared yet — nothing to check."); process.exit(0); } console.log(`Event graph (${graph.size} event(s)):`); for (const [event, { publishers, consumers }] of graph.entries()) { console.log(` ${event}`); console.log(` publishers: ${publishers.length === 0 ? "(none)" : publishers.map((p) => `${p.feature}.${p.useCase}`).join(", ")}`); console.log(` consumers: ${consumers.length === 0 ? "(none)" : consumers.map((c) => `${c.feature}.${c.useCase}`).join(", ")}`); } console.log(); const orphans = findOrphanConsumers(graph); if (orphans.length === 0) { console.log("✓ pnpm conformance — passed"); process.exit(0); } console.error(`✗ pnpm conformance — ${orphans.length} orphan consumer(s):`); for (const { event, consumers } of orphans) { console.error(` ${event}`); for (const c of consumers) { console.error(` consumed by ${c.feature}.${c.useCase}, but no feature publishes it`); } } process.exit(1); } if (import.meta.url === `file://${process.argv[1]}`) { main(); }