feat(core-analytics): add withAnalytics wrapper and Analyzed brand export

Adds `withAnalytics(analytics, factory)` to packages/core-analytics —
mirrors the `withAudit` pattern: thin forwarding closure that attaches
the `__analyzed` brand via `attachBrand` from `@repo/core-shared/conformance`
without mutating the original factory.  Exports `Analyzed<F>` type and
`withAnalytics` from the `@repo/core-analytics` root barrel.

Adds `with-analytics.test.ts` asserting brand is present after wrapping,
absent on the original fn, output passes through unchanged, and errors
propagate.  Adds `@repo/core-shared` as a production dependency.

Also fixes `scripts/library-decisions/check.mjs` to exempt workspace-protocol
entries (`workspace:*`) from the library trace requirement — internal monorepo
packages are not third-party libraries and were incorrectly gated.  Adds a
regression test in `check.test.mjs` covering the exemption.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 11:52:44 +00:00
parent a5ab698b41
commit c7bdf7cf3b
8 changed files with 135 additions and 18 deletions

View File

@@ -12,6 +12,9 @@
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@repo/core-shared": "workspace:*"
},
"devDependencies": {
"@repo/core-eslint": "workspace:*",
"@repo/core-testing": "workspace:*",

View File

@@ -4,3 +4,5 @@ export type {
IAnalytics,
} from "./analytics.interface";
export { NoopAnalytics } from "./noop-analytics";
export type { Analyzed } from "./with-analytics";
export { withAnalytics } from "./with-analytics";

View File

@@ -0,0 +1,55 @@
import { describe, it, expect, expectTypeOf } from "vitest";
import { withAnalytics, type Analyzed } from "@/with-analytics";
import type { IAnalytics } from "@/analytics.interface";
import { isAnalyzed } from "@repo/core-shared/conformance";
function makeAnalytics(): IAnalytics {
return {
track: () => undefined,
identify: () => undefined,
pageView: () => undefined,
flush: () => Promise.resolve(),
};
}
describe("withAnalytics", () => {
it("returns an Analyzed<F>", () => {
const analytics = makeAnalytics();
const fn = async (_input: { id: string }) => ({ ok: true });
const wrapped = withAnalytics(analytics, fn);
expectTypeOf(wrapped).toMatchTypeOf<Analyzed<typeof fn>>();
});
it("attaches __analyzed as a non-enumerable property on the wrapped function", () => {
const analytics = makeAnalytics();
const fn = async () => ({ ok: true });
const wrapped = withAnalytics(analytics, fn);
expect(isAnalyzed(wrapped)).toBe(true);
expect(Object.keys(wrapped)).not.toContain("__analyzed");
});
it("does NOT pollute the original input function with the brand", () => {
const analytics = makeAnalytics();
const fn = async () => ({ ok: true });
const wrapped = withAnalytics(analytics, fn);
expect(isAnalyzed(fn)).toBe(false);
expect(wrapped).not.toBe(fn);
});
it("passes input and output through unchanged", async () => {
const analytics = makeAnalytics();
const fn = async (input: { id: string }) => ({ ok: true, id: input.id });
const wrapped = withAnalytics(analytics, fn);
const result = await wrapped({ id: "abc" });
expect(result).toEqual({ ok: true, id: "abc" });
});
it("propagates errors", async () => {
const analytics = makeAnalytics();
const err = new Error("boom");
const wrapped = withAnalytics(analytics, async () => {
throw err;
});
await expect(wrapped()).rejects.toBe(err);
});
});

View File

@@ -0,0 +1,36 @@
import type { IAnalytics } from "./analytics.interface";
import { attachBrand } from "@repo/core-shared/conformance";
/**
* Phantom-type brand attached at wrap time by `withAnalytics`. The conformance
* system uses this as the type-level seam for use cases that declare
* `analyticsEvents: [...]` in their manifest — without `__analyzed`, the
* binding is not assignable to `ProductionUseCase<I, O, M>` when M demands it.
* At runtime the brand is a non-enumerable property attached by `attachBrand`
* from `@repo/core-shared/conformance`, so the boot-time assertion can verify
* the binding went through the analytics-aware path.
*/
export type Analyzed<F> = F & { readonly __analyzed: true };
/**
* Use-case wrapper applied at DI bind time. The wrapper is a thin closure
* that forwards to `fn` unchanged and carries the `__analyzed` brand. The
* forward closure (instead of returning `fn` directly) keeps the brand on
* a fresh function so the caller's original `fn` is not mutated — important
* when the same factory output is used elsewhere unwrapped (dev-seed paths,
* tests).
*/
export function withAnalytics<Args extends unknown[], R>(
// TODO: wire automated event recording from manifest declarations.
// `analyticsEvents[]` declarations. For now, the wrapper exists to:
// (1) require callers to pass the analytics instance at bind time (dep is available)
// (2) attach the `__analyzed` brand so the boot-time assertion can verify
// use cases were bound through the analytics-aware path.
analytics: IAnalytics,
fn: (...args: Args) => Promise<R>,
): Analyzed<(...args: Args) => Promise<R>> {
void analytics;
const wrapped: (...args: Args) => Promise<R> = (...args) => fn(...args);
attachBrand(wrapped, "__analyzed");
return wrapped as Analyzed<(...args: Args) => Promise<R>>;
}