diff --git a/packages/core-shared/src/conformance/assert-bindings.test.ts b/packages/core-shared/src/conformance/assert-bindings.test.ts new file mode 100644 index 0000000..4d26263 --- /dev/null +++ b/packages/core-shared/src/conformance/assert-bindings.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect, vi } from "vitest"; +import "reflect-metadata"; +import { Container } from "inversify"; +import { defineFeature } from "@/conformance/define-feature"; +import { ConformanceError } from "@/conformance/conformance-error"; +import { assertFeatureConformance } from "@/conformance/assert-bindings"; +import { withSpan } from "@/instrumentation/with-span"; +import { withCapture } from "@/instrumentation/with-capture"; +import type { ITracer, ISpan } from "@/instrumentation/tracer.interface"; +import type { ILogger } from "@/instrumentation/logger.interface"; +import type { BindContext } from "@/di/bind-context"; + +function makeTracer(): ITracer { + return { + startSpan: vi.fn(async (_opts, fn) => { + const span: ISpan = { setAttribute: () => {}, setStatus: () => {} }; + return fn(span); + }), + }; +} + +function makeLogger(): ILogger { + return { + captureException: vi.fn(), + captureMessage: vi.fn(), + addBreadcrumb: vi.fn(), + setUser: vi.fn(), + }; +} + +function makeCtx(): BindContext { + return { tracer: makeTracer(), logger: makeLogger() }; +} + +describe("assertFeatureConformance", () => { + it("passes when every use case is bound through withSpan + withCapture", () => { + const container = new Container(); + const sym = Symbol("test.signIn"); + const ctx = makeCtx(); + const bound = withSpan( + ctx.tracer, + { name: "test.signIn", op: "use-case" }, + withCapture(ctx.logger, { feature: "test", layer: "use-case" }, async (x: number) => x + 1), + ); + container.bind(sym).toConstantValue(bound); + + const manifest = defineFeature({ + name: "test", + requiredCores: [], + useCases: { + signIn: { mutates: false, audits: [], publishes: [], consumes: [] }, + }, + realtimeChannels: [], + jobs: [], + } as const); + + expect(() => + assertFeatureConformance(container, manifest, { signIn: sym }, ctx), + ).not.toThrow(); + }); + + it("throws when a use case binding is missing the __instrumented brand", () => { + const container = new Container(); + const sym = Symbol("test.signIn"); + const ctx = makeCtx(); + const unwrapped = async (x: number) => x + 1; + container.bind(sym).toConstantValue(unwrapped); + + const manifest = defineFeature({ + name: "test", + requiredCores: [], + useCases: { + signIn: { mutates: false, audits: [], publishes: [], consumes: [] }, + }, + realtimeChannels: [], + jobs: [], + } as const); + + expect(() => + assertFeatureConformance(container, manifest, { signIn: sym }, ctx), + ).toThrow(ConformanceError); + expect(() => + assertFeatureConformance(container, manifest, { signIn: sym }, ctx), + ).toThrow(/test\.signIn.*__instrumented/); + }); + + it("throws when a use case binding is missing the __captured brand", () => { + const container = new Container(); + const sym = Symbol("test.signIn"); + const ctx = makeCtx(); + // withSpan-only — no withCapture wrap + const partial = withSpan( + ctx.tracer, + { name: "test.signIn", op: "use-case" }, + async (x: number) => x + 1, + ); + container.bind(sym).toConstantValue(partial); + + const manifest = defineFeature({ + name: "test", + requiredCores: [], + useCases: { + signIn: { mutates: false, audits: [], publishes: [], consumes: [] }, + }, + realtimeChannels: [], + jobs: [], + } as const); + + expect(() => + assertFeatureConformance(container, manifest, { signIn: sym }, ctx), + ).toThrow(/__captured/); + }); + + it("throws when a mutating use case with audits is missing the __audited brand", () => { + const container = new Container(); + const sym = Symbol("test.signUp"); + const ctx = makeCtx(); + const wrappedNoAudit = withSpan( + ctx.tracer, + { name: "test.signUp", op: "use-case" }, + withCapture(ctx.logger, { feature: "test" }, async (x: number) => x + 1), + ); + container.bind(sym).toConstantValue(wrappedNoAudit); + + const manifest = defineFeature({ + name: "test", + requiredCores: [], + useCases: { + signUp: { + mutates: true, + audits: ["user.created"], + publishes: [], + consumes: [], + }, + }, + realtimeChannels: [], + jobs: [], + } as const); + + expect(() => + assertFeatureConformance(container, manifest, { signUp: sym }, ctx), + ).toThrow(/__audited/); + }); + + it("passes for a mutating use case with empty audits (no __audited required)", () => { + const container = new Container(); + const sym = Symbol("test.signUp"); + const ctx = makeCtx(); + const wrappedNoAudit = withSpan( + ctx.tracer, + { name: "test.signUp", op: "use-case" }, + withCapture(ctx.logger, { feature: "test" }, async (x: number) => x + 1), + ); + container.bind(sym).toConstantValue(wrappedNoAudit); + + const manifest = defineFeature({ + name: "test", + requiredCores: [], + useCases: { + signUp: { + mutates: true, + audits: [], + publishes: [], + consumes: [], + }, + }, + realtimeChannels: [], + jobs: [], + } as const); + + expect(() => + assertFeatureConformance(container, manifest, { signUp: sym }, ctx), + ).not.toThrow(); + }); + + it("throws when no symbol is provided for a manifest use case", () => { + const container = new Container(); + const ctx = makeCtx(); + const manifest = defineFeature({ + name: "test", + requiredCores: [], + useCases: { + signIn: { mutates: false, audits: [], publishes: [], consumes: [] }, + }, + realtimeChannels: [], + jobs: [], + } as const); + expect(() => assertFeatureConformance(container, manifest, {}, ctx)).toThrow( + /no symbol provided/, + ); + }); + + it("throws when the container cannot resolve the symbol", () => { + const container = new Container(); + const sym = Symbol("test.signIn"); + const ctx = makeCtx(); + const manifest = defineFeature({ + name: "test", + requiredCores: [], + useCases: { + signIn: { mutates: false, audits: [], publishes: [], consumes: [] }, + }, + realtimeChannels: [], + jobs: [], + } as const); + expect(() => + assertFeatureConformance(container, manifest, { signIn: sym }, ctx), + ).toThrow(ConformanceError); + }); +}); diff --git a/packages/core-shared/src/conformance/assert-bindings.ts b/packages/core-shared/src/conformance/assert-bindings.ts new file mode 100644 index 0000000..28fd7f7 --- /dev/null +++ b/packages/core-shared/src/conformance/assert-bindings.ts @@ -0,0 +1,62 @@ +import type { Container } from "inversify"; +import type { FeatureManifest } from "./define-feature"; +import { isInstrumented, isCaptured, isAudited } from "./brand-runtime"; +import { ConformanceError } from "./conformance-error"; +import type { BindContext } from "../di/bind-context"; + +/** + * Runtime check that every manifest-declared use case is bound through the + * brand-attaching wrappers (`withSpan` → `__instrumented`, + * `withCapture` → `__captured`, `withAudit` → `__audited` when required). + * + * Called at the tail of each feature's `bindProductionX(ctx)` so: + * - `pnpm dev` refuses to boot on drift (synchronous throw) + * - The check runs once per feature, not per request + * - Future apps that wire `bindProductionX` inherit the check for free + * + * The `symbols` map is declared inline by the feature's binder; the manifest + * holds the contract, the binder holds the container symbols. This keeps + * the manifest free of DI-coupling while letting each feature declare its + * own wiring keys. + */ +export function assertFeatureConformance( + container: Container, + manifest: FeatureManifest, + symbols: Record, + _ctx: BindContext, +): void { + void _ctx; // future: also check ctx.bus / ctx.auditLog presence vs requiredCores + for (const [name, useCase] of Object.entries(manifest.useCases)) { + const sym = symbols[name]; + if (!sym) { + throw new ConformanceError( + `${manifest.name}.${name}: no symbol provided in symbols map`, + ); + } + let bound: unknown; + try { + bound = container.get(sym); + } catch (cause) { + throw new ConformanceError( + `${manifest.name}.${name}: container could not resolve symbol (${String(cause)})`, + ); + } + if (!isInstrumented(bound)) { + throw new ConformanceError( + `${manifest.name}.${name}: missing __instrumented brand — was withSpan applied at bind time?`, + ); + } + if (!isCaptured(bound)) { + throw new ConformanceError( + `${manifest.name}.${name}: missing __captured brand — was withCapture applied at bind time?`, + ); + } + if (useCase.mutates && useCase.audits.length > 0) { + if (!isAudited(bound)) { + throw new ConformanceError( + `${manifest.name}.${name}: declares audits but binding is missing __audited brand — was withAudit applied at bind time?`, + ); + } + } + } +} diff --git a/packages/core-shared/src/conformance/index.ts b/packages/core-shared/src/conformance/index.ts index 5def5fa..13bcf94 100644 --- a/packages/core-shared/src/conformance/index.ts +++ b/packages/core-shared/src/conformance/index.ts @@ -9,3 +9,4 @@ export { isAudited, } from "./brand-runtime"; export { ConformanceError } from "./conformance-error"; +export { assertFeatureConformance } from "./assert-bindings"; diff --git a/packages/core-shared/src/instrumentation/with-span.ts b/packages/core-shared/src/instrumentation/with-span.ts index 839a4ea..1e00da1 100644 --- a/packages/core-shared/src/instrumentation/with-span.ts +++ b/packages/core-shared/src/instrumentation/with-span.ts @@ -2,6 +2,8 @@ import type { ITracer, SpanOpts } from "./tracer.interface"; import type { Instrumented } from "../conformance/brands"; import { attachBrand } from "../conformance/brand-runtime"; +const PROPAGATED_BRANDS = ["__captured", "__audited"] as const; + export function withSpan( tracer: ITracer, opts: SpanOpts | ((args: Args) => SpanOpts), @@ -22,6 +24,15 @@ export function withSpan( return tracer.startSpan(resolved, () => fn(...args)); }; attachBrand(wrapped, "__instrumented"); + // Propagate brands from the inner function (e.g. __captured from withCapture, + // __audited from withAudit) so the outermost binding carries all brands. + // withSpan is always outermost — the assertFeatureConformance check reads the + // container-resolved value (the withSpan result), so brands must be visible here. + for (const brand of PROPAGATED_BRANDS) { + if ((fn as unknown as Record)[brand] === true) { + attachBrand(wrapped, brand); + } + } // Cast is the type-level concession — the brand is now also a non-enumerable // runtime property attached above by `attachBrand`. return wrapped as Instrumented<(...args: Args) => Promise>;