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:
@@ -1,18 +1,18 @@
|
|||||||
{
|
{
|
||||||
"generatedAt": "2026-05-18T11:31:45.093Z",
|
"generatedAt": "2026-05-18T11:50:20.112Z",
|
||||||
"commit": "80c702e",
|
"commit": "a5ab698",
|
||||||
"repo": {
|
"repo": {
|
||||||
"statements": 95.87,
|
"statements": 95.89,
|
||||||
"branches": 89.02,
|
"branches": 89.07,
|
||||||
"functions": 100,
|
"functions": 100,
|
||||||
"lines": 95.87,
|
"lines": 95.89,
|
||||||
"counts": {
|
"counts": {
|
||||||
"lf": 3121,
|
"lf": 3140,
|
||||||
"lh": 2992,
|
"lh": 3011,
|
||||||
"brf": 492,
|
"brf": 494,
|
||||||
"brh": 438,
|
"brh": 440,
|
||||||
"fnf": 147,
|
"fnf": 149,
|
||||||
"fnh": 147
|
"fnh": 149
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"byPackage": {
|
"byPackage": {
|
||||||
@@ -50,12 +50,12 @@
|
|||||||
"functions": 100,
|
"functions": 100,
|
||||||
"lines": 100,
|
"lines": 100,
|
||||||
"counts": {
|
"counts": {
|
||||||
"lf": 8,
|
"lf": 27,
|
||||||
"lh": 8,
|
"lh": 27,
|
||||||
"brf": 5,
|
"brf": 7,
|
||||||
"brh": 5,
|
"brh": 7,
|
||||||
"fnf": 5,
|
"fnf": 7,
|
||||||
"fnh": 5
|
"fnh": 7
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@repo/marketing-pages": {
|
"@repo/marketing-pages": {
|
||||||
|
|||||||
@@ -12,6 +12,9 @@
|
|||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run --passWithNoTests"
|
"test": "vitest run --passWithNoTests"
|
||||||
},
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@repo/core-shared": "workspace:*"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@repo/core-eslint": "workspace:*",
|
"@repo/core-eslint": "workspace:*",
|
||||||
"@repo/core-testing": "workspace:*",
|
"@repo/core-testing": "workspace:*",
|
||||||
|
|||||||
@@ -4,3 +4,5 @@ export type {
|
|||||||
IAnalytics,
|
IAnalytics,
|
||||||
} from "./analytics.interface";
|
} from "./analytics.interface";
|
||||||
export { NoopAnalytics } from "./noop-analytics";
|
export { NoopAnalytics } from "./noop-analytics";
|
||||||
|
export type { Analyzed } from "./with-analytics";
|
||||||
|
export { withAnalytics } from "./with-analytics";
|
||||||
|
|||||||
55
packages/core-analytics/src/with-analytics.test.ts
Normal file
55
packages/core-analytics/src/with-analytics.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
36
packages/core-analytics/src/with-analytics.ts
Normal file
36
packages/core-analytics/src/with-analytics.ts
Normal 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>>;
|
||||||
|
}
|
||||||
4
pnpm-lock.yaml
generated
4
pnpm-lock.yaml
generated
@@ -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)
|
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:
|
packages/core-analytics:
|
||||||
|
dependencies:
|
||||||
|
"@repo/core-shared":
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../core-shared
|
||||||
devDependencies:
|
devDependencies:
|
||||||
"@repo/core-eslint":
|
"@repo/core-eslint":
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
|
|||||||
@@ -63,7 +63,14 @@ function getNewRuntimeDeps(relPath, repoRoot, baseRef) {
|
|||||||
// New file or initial commit — treat all deps as new
|
// New file or initial commit — treat all deps as new
|
||||||
}
|
}
|
||||||
const baseDeps = new Set(Object.keys(base.dependencies ?? {}));
|
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:"),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -164,6 +164,16 @@ describe("checkLibraryDecisions", () => {
|
|||||||
assert.deepEqual(checkLibraryDecisions(dir), []);
|
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", () => {
|
test("--staged-against mode: new feature-tier dep without trace → exit 1", () => {
|
||||||
const { dir, g } = makeRepo();
|
const { dir, g } = makeRepo();
|
||||||
// Baseline commit: feature package with no deps
|
// Baseline commit: feature package with no deps
|
||||||
|
|||||||
Reference in New Issue
Block a user