diff --git a/coverage/summary.json b/coverage/summary.json index 526d666..0a2d1e7 100644 --- a/coverage/summary.json +++ b/coverage/summary.json @@ -1,6 +1,6 @@ { - "generatedAt": "2026-05-18T11:50:20.112Z", - "commit": "a5ab698", + "generatedAt": "2026-05-18T15:19:07.787Z", + "commit": "81f75f7", "repo": { "statements": 95.89, "branches": 89.07, diff --git a/packages/core-shared/src/conformance/assert-bindings.test.ts b/packages/core-shared/src/conformance/assert-bindings.test.ts index 4d26263..f2f52a3 100644 --- a/packages/core-shared/src/conformance/assert-bindings.test.ts +++ b/packages/core-shared/src/conformance/assert-bindings.test.ts @@ -6,6 +6,7 @@ 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 { attachBrand } from "@/conformance/brand-runtime"; import type { ITracer, ISpan } from "@/instrumentation/tracer.interface"; import type { ILogger } from "@/instrumentation/logger.interface"; import type { BindContext } from "@/di/bind-context"; @@ -40,7 +41,11 @@ describe("assertFeatureConformance", () => { const bound = withSpan( ctx.tracer, { name: "test.signIn", op: "use-case" }, - withCapture(ctx.logger, { feature: "test", layer: "use-case" }, async (x: number) => x + 1), + withCapture( + ctx.logger, + { feature: "test", layer: "use-case" }, + async (x: number) => x + 1, + ), ); container.bind(sym).toConstantValue(bound); @@ -185,9 +190,9 @@ describe("assertFeatureConformance", () => { realtimeChannels: [], jobs: [], } as const); - expect(() => assertFeatureConformance(container, manifest, {}, ctx)).toThrow( - /no symbol provided/, - ); + expect(() => + assertFeatureConformance(container, manifest, {}, ctx), + ).toThrow(/no symbol provided/); }); it("throws when the container cannot resolve the symbol", () => { @@ -207,4 +212,112 @@ describe("assertFeatureConformance", () => { assertFeatureConformance(container, manifest, { signIn: sym }, ctx), ).toThrow(ConformanceError); }); + + it("passes when analyticsEvents declared and binding carries __analyzed brand", () => { + const container = new Container(); + const sym = Symbol("test.trackClick"); + const ctx = makeCtx(); + const base = withSpan( + ctx.tracer, + { name: "test.trackClick", op: "use-case" }, + withCapture( + ctx.logger, + { feature: "test", layer: "use-case" }, + async (x: number) => x, + ), + ); + attachBrand(base, "__analyzed"); + container.bind(sym).toConstantValue(base); + + const manifest = defineFeature({ + name: "test", + requiredCores: [], + useCases: { + trackClick: { + mutates: false, + audits: [], + publishes: [], + consumes: [], + analyticsEvents: ["button.clicked"], + }, + }, + realtimeChannels: [], + jobs: [], + } as const); + + expect(() => + assertFeatureConformance(container, manifest, { trackClick: sym }, ctx), + ).not.toThrow(); + }); + + it("throws when analyticsEvents declared but binding is missing __analyzed brand", () => { + const container = new Container(); + const sym = Symbol("test.trackClick"); + const ctx = makeCtx(); + const wrappedNoAnalytics = withSpan( + ctx.tracer, + { name: "test.trackClick", op: "use-case" }, + withCapture(ctx.logger, { feature: "test" }, async (x: number) => x), + ); + container.bind(sym).toConstantValue(wrappedNoAnalytics); + + const manifest = defineFeature({ + name: "test", + requiredCores: [], + useCases: { + trackClick: { + mutates: false, + audits: [], + publishes: [], + consumes: [], + analyticsEvents: ["button.clicked"], + }, + }, + realtimeChannels: [], + jobs: [], + } as const); + + expect(() => + assertFeatureConformance(container, manifest, { trackClick: sym }, ctx), + ).toThrow(ConformanceError); + expect(() => + assertFeatureConformance(container, manifest, { trackClick: sym }, ctx), + ).toThrow(/Analyzed|__analyzed/); + }); + + it("passes when analyticsEvents is empty and __analyzed brand is absent", () => { + 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, + ), + ); + container.bind(sym).toConstantValue(bound); + + const manifest = defineFeature({ + name: "test", + requiredCores: [], + useCases: { + signIn: { + mutates: false, + audits: [], + publishes: [], + consumes: [], + analyticsEvents: [], + }, + }, + realtimeChannels: [], + jobs: [], + } as const); + + expect(() => + assertFeatureConformance(container, manifest, { signIn: sym }, ctx), + ).not.toThrow(); + }); }); diff --git a/packages/core-shared/src/conformance/assert-bindings.ts b/packages/core-shared/src/conformance/assert-bindings.ts index 28fd7f7..40d2750 100644 --- a/packages/core-shared/src/conformance/assert-bindings.ts +++ b/packages/core-shared/src/conformance/assert-bindings.ts @@ -1,6 +1,11 @@ import type { Container } from "inversify"; import type { FeatureManifest } from "./define-feature"; -import { isInstrumented, isCaptured, isAudited } from "./brand-runtime"; +import { + isInstrumented, + isCaptured, + isAudited, + isAnalyzed, +} from "./brand-runtime"; import { ConformanceError } from "./conformance-error"; import type { BindContext } from "../di/bind-context"; @@ -58,5 +63,12 @@ export function assertFeatureConformance( ); } } + if ((useCase.analyticsEvents?.length ?? 0) > 0) { + if (!isAnalyzed(bound)) { + throw new ConformanceError( + `${manifest.name}.${name}: declares analyticsEvents but binding is missing __analyzed brand — was withAnalytics applied at bind time?`, + ); + } + } } }