From c7bdf7cf3bc073e30e91de8ede5e1dd8eefea37a Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Mon, 18 May 2026 11:52:44 +0000 Subject: [PATCH] feat(core-analytics): add withAnalytics wrapper and Analyzed brand export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` 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 --- coverage/summary.json | 34 ++++++------ packages/core-analytics/package.json | 3 + packages/core-analytics/src/index.ts | 2 + .../core-analytics/src/with-analytics.test.ts | 55 +++++++++++++++++++ packages/core-analytics/src/with-analytics.ts | 36 ++++++++++++ pnpm-lock.yaml | 4 ++ scripts/library-decisions/check.mjs | 9 ++- scripts/library-decisions/check.test.mjs | 10 ++++ 8 files changed, 135 insertions(+), 18 deletions(-) create mode 100644 packages/core-analytics/src/with-analytics.test.ts create mode 100644 packages/core-analytics/src/with-analytics.ts diff --git a/coverage/summary.json b/coverage/summary.json index 91543fa..526d666 100644 --- a/coverage/summary.json +++ b/coverage/summary.json @@ -1,18 +1,18 @@ { - "generatedAt": "2026-05-18T11:31:45.093Z", - "commit": "80c702e", + "generatedAt": "2026-05-18T11:50:20.112Z", + "commit": "a5ab698", "repo": { - "statements": 95.87, - "branches": 89.02, + "statements": 95.89, + "branches": 89.07, "functions": 100, - "lines": 95.87, + "lines": 95.89, "counts": { - "lf": 3121, - "lh": 2992, - "brf": 492, - "brh": 438, - "fnf": 147, - "fnh": 147 + "lf": 3140, + "lh": 3011, + "brf": 494, + "brh": 440, + "fnf": 149, + "fnh": 149 } }, "byPackage": { @@ -50,12 +50,12 @@ "functions": 100, "lines": 100, "counts": { - "lf": 8, - "lh": 8, - "brf": 5, - "brh": 5, - "fnf": 5, - "fnh": 5 + "lf": 27, + "lh": 27, + "brf": 7, + "brh": 7, + "fnf": 7, + "fnh": 7 } }, "@repo/marketing-pages": { diff --git a/packages/core-analytics/package.json b/packages/core-analytics/package.json index 51d4cc3..e9b170e 100644 --- a/packages/core-analytics/package.json +++ b/packages/core-analytics/package.json @@ -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:*", diff --git a/packages/core-analytics/src/index.ts b/packages/core-analytics/src/index.ts index 2d491a2..e111bab 100644 --- a/packages/core-analytics/src/index.ts +++ b/packages/core-analytics/src/index.ts @@ -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"; diff --git a/packages/core-analytics/src/with-analytics.test.ts b/packages/core-analytics/src/with-analytics.test.ts new file mode 100644 index 0000000..e12f40f --- /dev/null +++ b/packages/core-analytics/src/with-analytics.test.ts @@ -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", () => { + const analytics = makeAnalytics(); + const fn = async (_input: { id: string }) => ({ ok: true }); + const wrapped = withAnalytics(analytics, fn); + expectTypeOf(wrapped).toMatchTypeOf>(); + }); + + 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); + }); +}); diff --git a/packages/core-analytics/src/with-analytics.ts b/packages/core-analytics/src/with-analytics.ts new file mode 100644 index 0000000..b0220a1 --- /dev/null +++ b/packages/core-analytics/src/with-analytics.ts @@ -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` 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 & { 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( + // 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, +): Analyzed<(...args: Args) => Promise> { + void analytics; + const wrapped: (...args: Args) => Promise = (...args) => fn(...args); + attachBrand(wrapped, "__analyzed"); + return wrapped as Analyzed<(...args: Args) => Promise>; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d7c7277..fb805b8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -416,6 +416,10 @@ importers: version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.17)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0) packages/core-analytics: + dependencies: + "@repo/core-shared": + specifier: workspace:* + version: link:../core-shared devDependencies: "@repo/core-eslint": specifier: workspace:* diff --git a/scripts/library-decisions/check.mjs b/scripts/library-decisions/check.mjs index f479d50..b10fe87 100644 --- a/scripts/library-decisions/check.mjs +++ b/scripts/library-decisions/check.mjs @@ -63,7 +63,14 @@ function getNewRuntimeDeps(relPath, repoRoot, baseRef) { // New file or initial commit — treat all deps as new } const baseDeps = new Set(Object.keys(base.dependencies ?? {})); - return Object.keys(staged.dependencies ?? {}).filter((d) => !baseDeps.has(d)); + const stagedDeps = staged.dependencies ?? {}; + return Object.keys(stagedDeps).filter( + (d) => + !baseDeps.has(d) && + // Workspace-protocol entries are internal monorepo packages — not + // third-party libraries, so they don't require a library trace. + !String(stagedDeps[d]).startsWith("workspace:"), + ); } /** diff --git a/scripts/library-decisions/check.test.mjs b/scripts/library-decisions/check.test.mjs index a14d8ce..79cb553 100644 --- a/scripts/library-decisions/check.test.mjs +++ b/scripts/library-decisions/check.test.mjs @@ -164,6 +164,16 @@ describe("checkLibraryDecisions", () => { assert.deepEqual(checkLibraryDecisions(dir), []); }); + test("new workspace-protocol dep (internal monorepo package) → exit 0", () => { + const { dir, g } = makeRepo(); + commitPkg(dir, g, "packages/feat-a", { dependencies: {} }); + stagePkg(dir, g, "packages/feat-a", { + dependencies: { "@repo/core-shared": "workspace:*" }, + }); + + assert.deepEqual(checkLibraryDecisions(dir), []); + }); + test("--staged-against mode: new feature-tier dep without trace → exit 1", () => { const { dir, g } = makeRepo(); // Baseline commit: feature package with no deps