diff --git a/packages/core-shared/src/conformance/wire-use-case.test.ts b/packages/core-shared/src/conformance/wire-use-case.test.ts index 860a590..780fa7d 100644 --- a/packages/core-shared/src/conformance/wire-use-case.test.ts +++ b/packages/core-shared/src/conformance/wire-use-case.test.ts @@ -6,6 +6,7 @@ import { isInstrumented, isCaptured, isAudited, + isAnalyzed, } from "@/conformance/brand-runtime"; import type { ITracer, @@ -13,7 +14,7 @@ import type { SpanOpts, } from "@/instrumentation/tracer.interface"; import type { ILogger } from "@/instrumentation/logger.interface"; -import type { AuditLogProtocol } from "@/di/bind-protocols"; +import type { AuditLogProtocol, AnalyticsProtocol } from "@/di/bind-protocols"; function makeTracer() { const calls: SpanOpts[] = []; @@ -42,6 +43,10 @@ function makeAuditLog(): AuditLogProtocol { return { record: vi.fn() }; } +function makeAnalytics(): AnalyticsProtocol { + return { track: vi.fn() }; +} + const doubleFactory = () => async (x: number) => x * 2; describe("wireUseCase — no-audit path", () => { @@ -241,6 +246,107 @@ describe("wireUseCase — container binding", () => { }); }); +describe("wireUseCase — analytics path", () => { + it("attaches Analyzed brand when analytics is provided", () => { + const { tracer } = makeTracer(); + const logger = makeLogger(); + const container = new Container(); + const sym = Symbol("test.double"); + const analytics = makeAnalytics(); + + const wired = wireUseCase({ + container, + symbol: sym, + factory: doubleFactory, + deps: [], + feature: "test", + layer: "use-case", + name: "double", + tracer, + logger, + analytics, + }); + + expect(isAnalyzed(wired)).toBe(true); + expect(isInstrumented(wired)).toBe(true); + expect(isCaptured(wired)).toBe(true); + }); + + it("attaches Analyzed + Audited brands when both analytics and auditLog are provided", () => { + const { tracer } = makeTracer(); + const logger = makeLogger(); + const container = new Container(); + const sym = Symbol("test.double"); + const analytics = makeAnalytics(); + const auditLog = makeAuditLog(); + + const wired = wireUseCase({ + container, + symbol: sym, + factory: doubleFactory, + deps: [], + feature: "test", + layer: "use-case", + name: "double", + tracer, + logger, + analytics, + auditLog, + }); + + expect(isAnalyzed(wired)).toBe(true); + expect(isAudited(wired)).toBe(true); + expect(isInstrumented(wired)).toBe(true); + expect(isCaptured(wired)).toBe(true); + }); + + it("executes the factory result on invocation (analytics path)", async () => { + const { tracer } = makeTracer(); + const logger = makeLogger(); + const container = new Container(); + const sym = Symbol("test.double"); + const analytics = makeAnalytics(); + + const wired = wireUseCase({ + container, + symbol: sym, + factory: doubleFactory, + deps: [], + feature: "test", + layer: "use-case", + name: "double", + tracer, + logger, + analytics, + }); + + await expect(wired(7)).resolves.toBe(14); + }); +}); + +describe("wireUseCase — no-analytics path", () => { + it("does not attach Analyzed brand when analytics is absent", () => { + const { tracer } = makeTracer(); + const logger = makeLogger(); + const container = new Container(); + const sym = Symbol("test.double"); + + const wired = wireUseCase({ + container, + symbol: sym, + factory: doubleFactory, + deps: [], + feature: "test", + layer: "use-case", + name: "double", + tracer, + logger, + }); + + expect(isAnalyzed(wired)).toBe(false); + }); +}); + describe("wireUseCase — idempotent re-bind", () => { it("unbinds the existing binding and replaces it when the symbol is already bound", () => { const { tracer } = makeTracer(); diff --git a/packages/core-shared/src/conformance/wire-use-case.ts b/packages/core-shared/src/conformance/wire-use-case.ts index cfee8b4..28b855a 100644 --- a/packages/core-shared/src/conformance/wire-use-case.ts +++ b/packages/core-shared/src/conformance/wire-use-case.ts @@ -1,7 +1,7 @@ import type { Container } from "inversify"; import type { ITracer } from "../instrumentation/tracer.interface"; import type { ILogger } from "../instrumentation/logger.interface"; -import type { AuditLogProtocol } from "../di/bind-protocols"; +import type { AuditLogProtocol, AnalyticsProtocol } from "../di/bind-protocols"; import { withSpan } from "../instrumentation/with-span"; import { withCapture } from "../instrumentation/with-capture"; import { attachBrand } from "./brand-runtime"; @@ -20,6 +20,7 @@ export type WireUseCaseOptions< name: string; tracer: ITracer; logger: ILogger; + analytics?: AnalyticsProtocol; auditLog?: AuditLogProtocol; }; @@ -50,6 +51,7 @@ export function wireUseCase< name, tracer, logger, + analytics, auditLog, } = opts; @@ -59,14 +61,30 @@ export function wireUseCase< const raw = factory(...deps); let toWrap: (...args: FnArgs) => Promise; - if (auditLog !== undefined) { - void auditLog; // reserved for future automated audit recording from manifest declarations - const audited: (...args: FnArgs) => Promise = (...args) => raw(...args); - attachBrand(audited, "__audited"); - toWrap = audited; + // analytics is innermost — wraps raw before audit. withAnalytics lives in + // @repo/core-analytics which depends on core-shared (not vice versa), so we + // replicate the forwarding-wrapper semantics inline to avoid a circular dep. + if (analytics !== undefined) { + void analytics; // reserved for future automated event recording from manifest declarations + const analyzed: (...args: FnArgs) => Promise = (...args) => raw(...args); + attachBrand(analyzed, "__analyzed"); + toWrap = analyzed; } else { toWrap = raw; } + if (auditLog !== undefined) { + void auditLog; // reserved for future automated audit recording from manifest declarations + // snapshot before reassignment — closure captures variable reference, not value + const prev = toWrap; + const audited: (...args: FnArgs) => Promise = (...args) => prev(...args); + attachBrand(audited, "__audited"); + // propagate __analyzed from the analytics layer below so withCapture/withSpan + // can see it on the outermost binding + if ((prev as unknown as Record)["__analyzed"] === true) { + attachBrand(audited, "__analyzed"); + } + toWrap = audited; + } const wired = withSpan( tracer, diff --git a/packages/core-shared/src/di/bind-protocols.ts b/packages/core-shared/src/di/bind-protocols.ts index e6ea235..c187375 100644 --- a/packages/core-shared/src/di/bind-protocols.ts +++ b/packages/core-shared/src/di/bind-protocols.ts @@ -68,3 +68,15 @@ export type MetricsProtocol = { export type AuditLogProtocol = { record(entry: AuditEntry): Promise; }; + +/** + * Minimal analytics protocol surface. `IAnalytics` (in `@repo/core-analytics`) + * extends this — typechecks fail if narrowed below. Feature binders that + * receive `ctx.analytics` see only this protocol type. + */ +export type AnalyticsProtocol = { + track( + event: string, + attributes?: Record, + ): void; +}; diff --git a/packages/core-shared/src/instrumentation/with-capture.ts b/packages/core-shared/src/instrumentation/with-capture.ts index 8652115..0781b1a 100644 --- a/packages/core-shared/src/instrumentation/with-capture.ts +++ b/packages/core-shared/src/instrumentation/with-capture.ts @@ -28,7 +28,11 @@ export function withCapture( tags: Record, fn: (...args: Args) => Promise, ): Captured<(...args: Args) => Promise> { - const PROPAGATED_BRANDS = ["__instrumented", "__audited"] as const; + const PROPAGATED_BRANDS = [ + "__instrumented", + "__audited", + "__analyzed", + ] as const; const wrapped: (...args: Args) => Promise = async (...args) => { try { diff --git a/packages/core-shared/src/instrumentation/with-span.ts b/packages/core-shared/src/instrumentation/with-span.ts index 1e00da1..d7c752d 100644 --- a/packages/core-shared/src/instrumentation/with-span.ts +++ b/packages/core-shared/src/instrumentation/with-span.ts @@ -2,7 +2,7 @@ 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; +const PROPAGATED_BRANDS = ["__captured", "__audited", "__analyzed"] as const; export function withSpan( tracer: ITracer,