feat(conformance): implement ReadOnly brand and reader generator
- Add ReadOnly<F> phantom brand to core-shared/conformance (compile-time enforcement that readers only wrap non-mutating use cases) - Add isReadOnly runtime predicate for boot-time assertReaderPurity - Scaffold pnpm turbo gen reader: creates integrations/readers/ with interface, implementation, test, barrel, and adds ./reader export subpath to package.json
This commit is contained in:
@@ -476,6 +476,50 @@ export default function generator(plop: PlopTypes.NodePlopAPI): void {
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Turbo generator: `reader`
|
||||
*
|
||||
* Scaffolds a cross-feature reader under
|
||||
* `packages/<feature>/src/integrations/readers/` and adds the `./reader`
|
||||
* export subpath to the feature's `package.json`. The reader interface is
|
||||
* the public contract; the implementation and test are internal.
|
||||
*
|
||||
* See ADR-026 for the full design (rules Q0–Q3).
|
||||
*/
|
||||
plop.setGenerator("reader", {
|
||||
description:
|
||||
"Scaffold a cross-feature reader interface + implementation (ADR-026)",
|
||||
prompts: [
|
||||
{
|
||||
type: "input",
|
||||
name: "feature",
|
||||
message: "Feature that will EXPOSE the reader (kebab-case):",
|
||||
validate(input: string) {
|
||||
if (!/^[a-z][a-z0-9-]*$/.test(input)) return "Must be kebab-case";
|
||||
if (!existsSync(join(process.cwd(), "packages", input, "src"))) {
|
||||
return `packages/${input}/src does not exist`;
|
||||
}
|
||||
const readersDir = join(
|
||||
process.cwd(),
|
||||
"packages",
|
||||
input,
|
||||
"src",
|
||||
"integrations",
|
||||
"readers",
|
||||
);
|
||||
if (existsSync(readersDir)) {
|
||||
return `packages/${input}/src/integrations/readers/ already exists — this feature already has a reader`;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
],
|
||||
actions(answers) {
|
||||
const a = answers as { feature: string };
|
||||
return readerActions(a);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Turbo generator: `realtime`
|
||||
*
|
||||
@@ -1034,6 +1078,82 @@ function jobBindBlock(a: { feature: string; job: string }): string {
|
||||
${containerVar}.bind(${symbol}).toConstantValue(wrapped${pascalCase(a.job)});`;
|
||||
}
|
||||
|
||||
function readerActions(a: { feature: string }): PlopTypes.ActionType[] {
|
||||
const base = `packages/${a.feature}/src/integrations/readers`;
|
||||
const pkgJsonPath = `packages/${a.feature}/package.json`;
|
||||
return [
|
||||
{
|
||||
type: "add",
|
||||
path: `${base}/${a.feature}.reader.interface.ts`,
|
||||
templateFile: "templates/reader/reader.interface.ts.hbs",
|
||||
data: a,
|
||||
},
|
||||
{
|
||||
type: "add",
|
||||
path: `${base}/${a.feature}.reader.ts`,
|
||||
templateFile: "templates/reader/reader.ts.hbs",
|
||||
data: a,
|
||||
},
|
||||
{
|
||||
type: "add",
|
||||
path: `${base}/${a.feature}.reader.test.ts`,
|
||||
templateFile: "templates/reader/reader.test.ts.hbs",
|
||||
data: a,
|
||||
},
|
||||
{
|
||||
type: "add",
|
||||
path: `${base}/index.ts`,
|
||||
templateFile: "templates/reader/index.ts.hbs",
|
||||
data: a,
|
||||
},
|
||||
// Add ./reader subpath to package.json exports map
|
||||
() => {
|
||||
const fs = require("node:fs");
|
||||
const pkgPath = join(process.cwd(), pkgJsonPath);
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
||||
if (!pkg.exports) pkg.exports = {};
|
||||
pkg.exports["./reader"] = "./src/integrations/readers/index.ts";
|
||||
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
|
||||
return `Added "./reader" export to ${pkgJsonPath}`;
|
||||
},
|
||||
() => printReaderNextSteps(a),
|
||||
];
|
||||
}
|
||||
|
||||
function printReaderNextSteps(a: { feature: string }): string {
|
||||
const pascal = pascalCase(a.feature);
|
||||
return [
|
||||
"",
|
||||
"─────────────────────────────────────────────────────────────",
|
||||
` Reader scaffolded for @repo/${a.feature}`,
|
||||
"─────────────────────────────────────────────────────────────",
|
||||
"",
|
||||
" Next steps:",
|
||||
"",
|
||||
` 1. Add methods to I${pascal}Reader interface:`,
|
||||
` packages/${a.feature}/src/integrations/readers/${a.feature}.reader.interface.ts`,
|
||||
"",
|
||||
` 2. Implement methods in ${pascal}Reader (delegate to use cases):`,
|
||||
` packages/${a.feature}/src/integrations/readers/${a.feature}.reader.ts`,
|
||||
"",
|
||||
` 3. Construct the reader in bind-production.ts and return it:`,
|
||||
` const reader = new ${pascal}Reader(/* injected use cases */);`,
|
||||
` return { reader };`,
|
||||
"",
|
||||
` 4. In bindAll(), pass the reader to consuming features:`,
|
||||
` const ${a.feature}Result = bindProduction${pascal}(ctx);`,
|
||||
` bindProductionConsumer(ctx, { ${a.feature}Reader: ${a.feature}Result.reader });`,
|
||||
"",
|
||||
` 5. In consuming features' manifest, declare: reads: ["${a.feature}"]`,
|
||||
"",
|
||||
" Rules: Q0 (cross-feature only), Q1 (interface public, impl private),",
|
||||
" Q2 (read-only — wraps only mutates:false use cases),",
|
||||
" Q3 (reader cycles are a design error)",
|
||||
"",
|
||||
"─────────────────────────────────────────────────────────────",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function printJobNextSteps(a: { feature: string; job: string }): string {
|
||||
return [
|
||||
"",
|
||||
|
||||
Reference in New Issue
Block a user