fix(conformance): withCapture propagates inner brands + chain test + docstring fixes
This commit is contained in:
76
packages/core-audit/src/with-audit.chain.test.ts
Normal file
76
packages/core-audit/src/with-audit.chain.test.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { withSpan } from "@repo/core-shared/instrumentation";
|
||||||
|
import { withCapture } from "@repo/core-shared/instrumentation";
|
||||||
|
import { isInstrumented, isCaptured, isAudited } from "@repo/core-shared/conformance";
|
||||||
|
import { withAudit } from "@/with-audit";
|
||||||
|
import type { IAuditLog } from "@/audit-log.interface";
|
||||||
|
import type { ITracer, ISpan } from "@repo/core-shared/instrumentation";
|
||||||
|
import type { ILogger } from "@repo/core-shared/instrumentation";
|
||||||
|
|
||||||
|
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 makeAuditLog(): IAuditLog {
|
||||||
|
return {
|
||||||
|
record: vi.fn().mockResolvedValue(undefined),
|
||||||
|
eraseSubject: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("brand propagation across the full wrapper chain", () => {
|
||||||
|
it("withSpan(withCapture(withAudit(fn))) carries all three brands on the outer wrapper", () => {
|
||||||
|
const tracer = makeTracer();
|
||||||
|
const logger = makeLogger();
|
||||||
|
const auditLog = makeAuditLog();
|
||||||
|
const fn = async (input: { id: string }) => ({ ok: true, id: input.id });
|
||||||
|
|
||||||
|
const wrapped = withSpan(
|
||||||
|
tracer,
|
||||||
|
{ name: "test.chain", op: "use-case" },
|
||||||
|
withCapture(
|
||||||
|
logger,
|
||||||
|
{ feature: "test", layer: "use-case" },
|
||||||
|
withAudit(auditLog, fn),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(isInstrumented(wrapped)).toBe(true);
|
||||||
|
expect(isCaptured(wrapped)).toBe(true);
|
||||||
|
expect(isAudited(wrapped)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the full chain preserves input/output behaviour end-to-end", async () => {
|
||||||
|
const tracer = makeTracer();
|
||||||
|
const logger = makeLogger();
|
||||||
|
const auditLog = makeAuditLog();
|
||||||
|
const fn = async (input: { id: string }) => ({ ok: true, id: input.id });
|
||||||
|
|
||||||
|
const wrapped = withSpan(
|
||||||
|
tracer,
|
||||||
|
{ name: "test.chain", op: "use-case" },
|
||||||
|
withCapture(
|
||||||
|
logger,
|
||||||
|
{ feature: "test", layer: "use-case" },
|
||||||
|
withAudit(auditLog, fn),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await wrapped({ id: "abc" });
|
||||||
|
expect(result).toEqual({ ok: true, id: "abc" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,9 +17,11 @@ type Brand = "__instrumented" | "__captured" | "__audited";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Attaches the brand as a non-enumerable property on the given function.
|
* Attaches the brand as a non-enumerable property on the given function.
|
||||||
* Returns the same reference (no allocation). Idempotent: calling twice with
|
* Returns the same reference (no allocation). Calling twice with the same
|
||||||
* the same brand throws because the property is non-configurable — wrappers
|
* brand on the same fn is a no-op (matching descriptors) — the engine silently
|
||||||
* never double-wrap, so this is the correct safety net.
|
* accepts a redundant `defineProperty` call when every descriptor attribute
|
||||||
|
* matches the existing one. Redefining with different attributes (e.g. flipping
|
||||||
|
* `configurable`) would throw, but wrappers never do that.
|
||||||
*/
|
*/
|
||||||
export function attachBrand<F extends object>(fn: F, brand: Brand): F {
|
export function attachBrand<F extends object>(fn: F, brand: Brand): F {
|
||||||
Object.defineProperty(fn, brand, {
|
Object.defineProperty(fn, brand, {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Phantom-type brands attached at wrap time by `withSpan`, `withCapture`,
|
* Phantom-type brands attached at wrap time by `withSpan`, `withCapture`,
|
||||||
* and `withAudit`. Pure type-level — no runtime cost, no proxy, no
|
* and `withAudit`. The brand has a type-level form (this file) and a
|
||||||
* `Object.assign`. The conformance system uses these as the type-level
|
* non-enumerable runtime counterpart (see `./brand-runtime.ts`). The runtime
|
||||||
* seam the binding signature checks; a use-case factory that hasn't been
|
* cost is one `Object.defineProperty` call per wrap. The conformance system
|
||||||
* wrapped is not assignable to a `ProductionUseCase<...>` slot.
|
* uses these as the type-level seam the binding signature checks; a use-case
|
||||||
|
* factory that hasn't been wrapped is not assignable to a
|
||||||
|
* `ProductionUseCase<...>` slot.
|
||||||
*/
|
*/
|
||||||
export type Instrumented<F> = F & { readonly __instrumented: true };
|
export type Instrumented<F> = F & { readonly __instrumented: true };
|
||||||
export type Captured<F> = F & { readonly __captured: true };
|
export type Captured<F> = F & { readonly __captured: true };
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ 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 wrapped: (...args: Args) => Promise<R> = async (...args) => {
|
const wrapped: (...args: Args) => Promise<R> = async (...args) => {
|
||||||
try {
|
try {
|
||||||
return await fn(...args);
|
return await fn(...args);
|
||||||
@@ -39,6 +41,14 @@ export function withCapture<Args extends unknown[], R>(
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// Propagate brands from the inner function (e.g. __audited from withAudit,
|
||||||
|
// __instrumented if already spanned) so the outermost binding carries all brands.
|
||||||
|
// __captured is omitted here because it is attached explicitly below.
|
||||||
|
for (const brand of PROPAGATED_BRANDS) {
|
||||||
|
if ((fn as unknown as Record<string, unknown>)[brand] === true) {
|
||||||
|
attachBrand(wrapped, brand);
|
||||||
|
}
|
||||||
|
}
|
||||||
attachBrand(wrapped, "__captured");
|
attachBrand(wrapped, "__captured");
|
||||||
return wrapped as Captured<(...args: Args) => Promise<R>>;
|
return wrapped as Captured<(...args: Args) => Promise<R>>;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user