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:
danijel-lf
2026-05-28 22:01:51 +02:00
parent b97e6105d3
commit 5b74939a51
8 changed files with 187 additions and 2 deletions

View File

@@ -13,7 +13,7 @@
* commitment, not a mutable flag.
*/
import type { Analyzed, ConsentChecked, RateLimited } from "./brands";
import type { Analyzed, ConsentChecked, RateLimited, ReadOnly } from "./brands";
type Brand =
| "__instrumented"
@@ -21,7 +21,8 @@ type Brand =
| "__audited"
| "__analyzed"
| "__consentChecked"
| "__rateLimited";
| "__rateLimited"
| "__readonly";
/**
* Attaches the brand as a non-enumerable property on the given function.
@@ -75,3 +76,7 @@ export function isRateLimited<F extends object>(
): fn is RateLimited<F> {
return hasBrand(fn, "__rateLimited");
}
export function isReadOnly<F extends object>(fn: unknown): fn is ReadOnly<F> {
return hasBrand(fn, "__readonly");
}

View File

@@ -12,3 +12,9 @@ export type Captured<F> = F & { readonly __captured: true };
export type Analyzed<F> = F & { readonly __analyzed: true };
export type ConsentChecked<F> = F & { readonly __consentChecked: true };
export type RateLimited<F> = F & { readonly __rateLimited: true };
/**
* Brand for use cases declared `mutates: false`. Readers only accept
* `ReadOnly`-branded use cases in their constructor — prevents mutating
* use cases from being wired into a reader at compile time.
*/
export type ReadOnly<F> = F & { readonly __readonly: true };

View File

@@ -4,6 +4,7 @@ export type {
Analyzed,
ConsentChecked,
RateLimited,
ReadOnly,
} from "./brands";
export type { FeatureManifest, UseCaseManifest } from "./define-feature";
export { defineFeature } from "./define-feature";
@@ -31,6 +32,7 @@ export {
isAnalyzed,
isConsentChecked,
isRateLimited,
isReadOnly,
} from "./brand-runtime";
export { ConformanceError } from "./conformance-error";
export { assertFeatureConformance } from "./assert-bindings";

View File

@@ -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 Q0Q3).
*/
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 [
"",

View File

@@ -0,0 +1,3 @@
// packages/{{kebabCase feature}}/src/integrations/readers/index.ts
// Public surface: type-only export. Implementation is internal.
export type { I{{pascalCase feature}}Reader } from "./{{kebabCase feature}}.reader.interface";

View File

@@ -0,0 +1,14 @@
// packages/{{kebabCase feature}}/src/integrations/readers/{{kebabCase feature}}.reader.interface.ts
/**
* Cross-feature read-only query contract for the {{kebabCase feature}} vertical.
* Consumers import this type from `@repo/{{kebabCase feature}}/reader`.
* Implementation is private — constructed by the feature's binder.
*
* Rules: Q0 (cross-feature domain queries only), Q1 (interface public,
* impl private), Q2 (read-only — wraps only mutates:false use cases).
*/
export interface I{{pascalCase feature}}Reader {
// Add methods as consumers need them. Each method must delegate to a
// use case declared mutates: false in feature.manifest.ts.
}

View File

@@ -0,0 +1,10 @@
// packages/{{kebabCase feature}}/src/integrations/readers/{{kebabCase feature}}.reader.test.ts
import { describe, it, expect } from "vitest";
import { {{pascalCase feature}}Reader } from "@/integrations/readers/{{kebabCase feature}}.reader";
describe("{{pascalCase feature}}Reader", () => {
it("can be constructed", () => {
const reader = new {{pascalCase feature}}Reader();
expect(reader).toBeDefined();
});
});

View File

@@ -0,0 +1,25 @@
// packages/{{kebabCase feature}}/src/integrations/readers/{{kebabCase feature}}.reader.ts
import type { I{{pascalCase feature}}Reader } from "./{{kebabCase feature}}.reader.interface";
/**
* Internal implementation of I{{pascalCase feature}}Reader.
* Wraps existing use cases — does not add domain logic.
* Constructed by bind-production / bind-dev-seed; never exported.
*
* All injected use cases MUST be ReadOnly-branded (mutates: false).
*/
export class {{pascalCase feature}}Reader implements I{{pascalCase feature}}Reader {
// Inject ReadOnly-branded use cases via constructor:
//
// constructor(
// private getUser: ReadOnly<IGetUserUseCase>,
// ) {}
//
// Then delegate:
//
// async exists(userId: string): Promise<boolean> {
// const user = await this.getUser({ id: userId });
// return user !== null;
// }
}