feat(core-shared): extend wireUseCase with analytics arg and Analyzed brand propagation

Add AnalyticsProtocol to bind-protocols, extend WireUseCaseOptions with
optional analytics field, and compose the __analyzed brand inline in
wireUseCase (innermost, before withAudit) when analytics is provided.

Propagate __analyzed through withCapture and withSpan PROPAGATED_BRANDS
so the outermost container binding carries the brand for boot-time
assertion checks (Story 05).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 15:12:57 +00:00
parent ff1e0b052c
commit 32018e8a7b
5 changed files with 149 additions and 9 deletions

View File

@@ -6,6 +6,7 @@ import {
isInstrumented, isInstrumented,
isCaptured, isCaptured,
isAudited, isAudited,
isAnalyzed,
} from "@/conformance/brand-runtime"; } from "@/conformance/brand-runtime";
import type { import type {
ITracer, ITracer,
@@ -13,7 +14,7 @@ import type {
SpanOpts, SpanOpts,
} from "@/instrumentation/tracer.interface"; } from "@/instrumentation/tracer.interface";
import type { ILogger } from "@/instrumentation/logger.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() { function makeTracer() {
const calls: SpanOpts[] = []; const calls: SpanOpts[] = [];
@@ -42,6 +43,10 @@ function makeAuditLog(): AuditLogProtocol {
return { record: vi.fn() }; return { record: vi.fn() };
} }
function makeAnalytics(): AnalyticsProtocol {
return { track: vi.fn() };
}
const doubleFactory = () => async (x: number) => x * 2; const doubleFactory = () => async (x: number) => x * 2;
describe("wireUseCase — no-audit path", () => { 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", () => { describe("wireUseCase — idempotent re-bind", () => {
it("unbinds the existing binding and replaces it when the symbol is already bound", () => { it("unbinds the existing binding and replaces it when the symbol is already bound", () => {
const { tracer } = makeTracer(); const { tracer } = makeTracer();

View File

@@ -1,7 +1,7 @@
import type { Container } from "inversify"; import type { Container } from "inversify";
import type { ITracer } from "../instrumentation/tracer.interface"; import type { ITracer } from "../instrumentation/tracer.interface";
import type { ILogger } from "../instrumentation/logger.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 { withSpan } from "../instrumentation/with-span";
import { withCapture } from "../instrumentation/with-capture"; import { withCapture } from "../instrumentation/with-capture";
import { attachBrand } from "./brand-runtime"; import { attachBrand } from "./brand-runtime";
@@ -20,6 +20,7 @@ export type WireUseCaseOptions<
name: string; name: string;
tracer: ITracer; tracer: ITracer;
logger: ILogger; logger: ILogger;
analytics?: AnalyticsProtocol;
auditLog?: AuditLogProtocol; auditLog?: AuditLogProtocol;
}; };
@@ -50,6 +51,7 @@ export function wireUseCase<
name, name,
tracer, tracer,
logger, logger,
analytics,
auditLog, auditLog,
} = opts; } = opts;
@@ -59,14 +61,30 @@ export function wireUseCase<
const raw = factory(...deps); const raw = factory(...deps);
let toWrap: (...args: FnArgs) => Promise<R>; let toWrap: (...args: FnArgs) => Promise<R>;
if (auditLog !== undefined) { // analytics is innermost — wraps raw before audit. withAnalytics lives in
void auditLog; // reserved for future automated audit recording from manifest declarations // @repo/core-analytics which depends on core-shared (not vice versa), so we
const audited: (...args: FnArgs) => Promise<R> = (...args) => raw(...args); // replicate the forwarding-wrapper semantics inline to avoid a circular dep.
attachBrand(audited, "__audited"); if (analytics !== undefined) {
toWrap = audited; void analytics; // reserved for future automated event recording from manifest declarations
const analyzed: (...args: FnArgs) => Promise<R> = (...args) => raw(...args);
attachBrand(analyzed, "__analyzed");
toWrap = analyzed;
} else { } else {
toWrap = raw; 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<R> = (...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<string, unknown>)["__analyzed"] === true) {
attachBrand(audited, "__analyzed");
}
toWrap = audited;
}
const wired = withSpan( const wired = withSpan(
tracer, tracer,

View File

@@ -68,3 +68,15 @@ export type MetricsProtocol = {
export type AuditLogProtocol = { export type AuditLogProtocol = {
record(entry: AuditEntry): Promise<void>; record(entry: AuditEntry): Promise<void>;
}; };
/**
* 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<string, string | number | boolean>,
): void;
};

View File

@@ -28,7 +28,11 @@ export function withCapture<Args extends unknown[], R>(
tags: Record<string, string>, tags: Record<string, string>,
fn: (...args: Args) => Promise<R>, fn: (...args: Args) => Promise<R>,
): Captured<(...args: Args) => Promise<R>> { ): Captured<(...args: Args) => Promise<R>> {
const PROPAGATED_BRANDS = ["__instrumented", "__audited"] as const; const PROPAGATED_BRANDS = [
"__instrumented",
"__audited",
"__analyzed",
] as const;
const wrapped: (...args: Args) => Promise<R> = async (...args) => { const wrapped: (...args: Args) => Promise<R> = async (...args) => {
try { try {

View File

@@ -2,7 +2,7 @@ import type { ITracer, SpanOpts } from "./tracer.interface";
import type { Instrumented } from "../conformance/brands"; import type { Instrumented } from "../conformance/brands";
import { attachBrand } from "../conformance/brand-runtime"; import { attachBrand } from "../conformance/brand-runtime";
const PROPAGATED_BRANDS = ["__captured", "__audited"] as const; const PROPAGATED_BRANDS = ["__captured", "__audited", "__analyzed"] as const;
export function withSpan<Args extends unknown[], R, Extra extends object>( export function withSpan<Args extends unknown[], R, Extra extends object>(
tracer: ITracer, tracer: ITracer,