Skip to content
Back to skills

Tracking Plan Scan

ASecurity

Analytics detector: event vs tracking-plan drift, name and property drift, conversions fired early, duplicate fires.

  • 2 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 20, 2026
ai-agentspythonrustgojavarubyswiftkotlinbashreactvue

Works with

  • cli
  • api

Security analysis

A100/100

Pro scans all 2 files and shows the line behind each finding

Scanned October 20, 2026

npx -y skills add adnanmokhtar/refract --skill tracking-plan-scan --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Tracking Plan Scan?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Tracking Plan Scan
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/adnanmokhtar-tracking-plan-scan/badge)](https://www.skillsdirectory.com/skills/adnanmokhtar-tracking-plan-scan)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: tracking-plan-scan
description: "Analytics detector: event vs tracking-plan drift, name and property drift, conversions fired early, duplicate fires."
kind: skill
pack: product
allowed-tools: [Read, Grep, Glob, Bash]
---

# Skill: tracking-plan-scan

## Premise

Product decisions are made on funnels, and a funnel is only as true as its events. Common breakages:

- An event is renamed in code but not in the plan, so the dashboard silently loses it.
- `Signup Completed` and `signup_completed` split one count in two.
- A property sent as `"42"` where the plan says number is dropped by the pipeline.
- `PackagePurchased` fires when the checkout URL is created, before anyone pays, so the ad platform is told about purchases that never happened.
- An impression fires twice, because its effect depends on a callback rebuilt every render.

None of these fails a build or a test that mocks the sink. All of them reach the decisions made on the data.

**Every finding cites both ends:** the call site `<file:line>`, and the plan line it disagrees with (or the plan's silence, or the `/define-success` file that declares the metric). It also gives the firing context the script read, the oracle that failed or the test that would settle it, and the fix. A script row is a candidate, never a finding by itself. "Tracking is consistent" is never printed without the class-coverage block (§ Output).

**Detect statically, prove by execution.** `scripts/event-inventory.py` builds the inventory: every call site, the project's wrapper resolved, names normalised, properties and the firing point read. It joins that against the plan. The proofs are a plan-diff exit code and a fake sink in a unit test: a failed write emits 0 outcome events and a confirmed write emits exactly 1 (`proof-oracles.md` § Product, V413–V417). A candidate with no harness is **Unconfirmed**, carrying the test that would settle it ([`refute-before-report.md`](../../../../snippets/refute-before-report.md) § 2, gate 6).

## The rule

A candidate is a finding when all four hold:

1. **It ships and fires for users.** The call is in production source, reachable from a mounted route or a server handler. Debug events behind a dev flag fail here.
2. **There is an oracle for the claim.** §1, §2 and §4 need a plan: a codegen plan, a vendor export, a typed union, a plan document, or a `/define-success` metric. Without one, they print `N/A (no plan — no oracle)`, and the run gives one Hardening inventory row ([`precedents/product.md`](../../../../snippets/precedents/product.md) § 16). §3, §5, §6 and §7 need no plan.
3. **No delivery leg or design choice explains it.** A server leg that delivers the outcome (§ 11), two events that are distinct by design (§ 12), an intent event named as one (§ 14), and a per-open impression (§ 15) all fail here. So do auto-tracked and lifecycle events (§ 10).
4. **The oracle fires, or would.** Typical oracles: the plan diff exits 2, a typed channel fails `tsc`, or a fake sink counts the wrong number of events.

## Boundaries

- **PII in event properties, and tracking before consent** → security (`data-exposure-scan`, `@data-privacy-reviewer`), when the security pack is installed. This skill lists the property names it saw, in `--inventory`, and never grades them. Raw identifiers hashed before the vendor call are not a leak (§ 13). Without security, list the rows tagged `privacy (owner pack security not installed)`.
- **One flow's funnel drop-off and onboarding completion** → business `/suggest-metrics --funnel`, when installed. It consumes the AN rows here for its "missing instrumentation" line. Without business, the AN rows stand alone.
- **Whether a launch metric returns data** (NO-DATA vs STALE at runtime) → `launch-readiness` (this pack), through its query lane. §2 here is the static half: the event has no call site at all.
- **Flag evaluations reported as events** (exposure events) → devops `progressive-delivery`, when installed. Without devops, exposure events are skipped here and named in the ledger.
- **Observability metrics and logs** (`counter.inc()`, structured log events) are not product analytics → observability `metric-hygiene-scan`, when installed. Without observability, they are out of scope here, never graded as events.

## Adapt to the codebase — the lanes

Read the SDKs **and their versions** from the manifests (grep 0a), and print the lanes in the header. The script's ledger names every lane that produced a site.

| Lane | Call shapes the script reads | Plan source it trusts | Typed-channel control |
|---|---|---|---|
| **Segment / RudderStack** | `analytics.track('X', {…})`, `rudderanalytics.track`, Node object form `analytics.track({ userId, event: 'X' })`, Python `analytics.track(uid, 'X', {…})`, Ruby `Analytics.track(event:)`, Go `analytics.Track{Event:}` | Protocols / tracking-plan export (JSON); Typewriter output (shape unverified) | Typewriter codegen |
| **Amplitude** | `amplitude.track`, `logEvent`, Python `BaseEvent(event_type=)`; Ampli `ampli.packagePurchased(…)` | `ampli.json` + generated `ampli/index.*` event classes (`event_type`) | Ampli codegen; `ampli status` exits non-zero with untracked events |
| **Mixpanel / PostHog / Heap / Umami / Plausible / Statsig** | `mixpanel.track`, `posthog.capture` (+ object form), `heap.track`, `umami.track`, `plausible('X')`, `Statsig.logEvent` | a plan document or a typed union. PostHog has no repo-side schema | a typed union on the wrapper |
| **GA4 / GTM / Meta** | `gtag('event', 'x')`, `dataLayer.push({ event: 'x' })` (`gtm.*` bootstrap skipped), `fbq('track' \| 'trackCustom', 'X')` | a plan document | — |
| **Firebase / native** | `logEvent(analytics, 'x')`, `FirebaseAnalytics.instance.logEvent(name:)`, Kotlin / Swift `logEvent("x")` | a plan document | — |
| **Snowplow** | `trackSelfDescribingEvent({ event: { schema: 'iglu:…' } })`: the schema path is the name | Iglu schemas in the repo (`**/jsonschema/**`) | Snowtype (unverified) |
| **Avo** | `Avo.packagePurchased(…)` | `avo.json` + generated `Avo.*` functions | Avo codegen; `avo status` fails on uncalled functions |
| **The project's wrapper** | `trackEvent('X', {…})` and any name in `--wrapper=`. The script finds a wrapper by itself when a function forwards its own parameter as the event name to an SDK call | the wrapper's parameter type, when it is a union or an enum | the same type |

**Vendor CLIs run only when already installed, offline, and with telemetry off.** They never log in.

- `ampli status`: the CLI (1.36.6) bundles an Amplitude client and an error reporter, so treat it as phoning home. `AMPLI_SENTRY=disabled` was seen in its source. Never pass `-u`, which uploads status.
- `avo status`: the CLI (3.7.0) runs an install-tracking postinstall. Use only a pre-installed binary, and never `npm i` into the audited repo.

A CLI that needs a login is `NOT-RUN (auth)`, and the class falls back to the script; it is never PASS. Sources: the research catalog's tools table, 2026-10-19.

## The inventory script

`scripts/event-inventory.py`, beside this file (installed as `.claude/skills/tracking-plan-scan/scripts/event-inventory.py`). It uses the Python 3.8 standard library only. It reads files and never writes into the scanned repo. Run it from the repo root:

```bash
python3 -I .claude/skills/tracking-plan-scan/scripts/event-inventory.py . --wrapper=<names from 0c> [--plan=<file>] [--success=ai/product/success] --max=40
python3 -I .claude/skills/tracking-plan-scan/scripts/event-inventory.py --self-test        # 26 checks; exit 3 on a failure
```

**What it reads.**

- **Call sites.** Every lane above, plus the wrappers: the ones it discovers and the ones named in `--wrapper=`. A generic wrapper name (`track`, `log`) counts only when its first argument looks like an event name.
- **Plans.** Each `--plan` file. Otherwise it discovers the plan: `tracking-plan*` / `analytics-events*` (JSON, a YAML subset, TS unions, Markdown tables and bullets), Ampli and Avo codegen, Iglu schemas, and the wrapper's typed union.
- **Success files.** It reads `ai/product/success/*.md` § Instrumentation for metric events.

**How it compares names.** Names are normalised by word (case, separators, tense: `Signup Completed` = `signup_completed` = `SignUpComplete`).

**Context per site.** Each site gets one of:

- `on-intent`, a JSX `onClick` or `onSubmit`;
- `on-success`, `.then` or `onSuccess`;
- `on-failure`, `catch` or `onError`;
- `effect(deps=[…])`;
- `after-await(<callee>)` or `before-await`;
- `top-level`.

Each site also gets a side (`client` / `server`) and whether a dedupe id travels with it.

**Rows:**

- **AN1:** `unplanned`, or `no-plan` (one Hardening row).
- **AN2:** `never-fired`, with `metric=yes` when a success file declares the event.
- **AN3:** `spelling` (a cluster) and `convention` (outside the dominant style once there are 5 or more names).
- **AN4:** `keys-differ`, `type-conflict`, `required-missing`, `unplanned-property`, `type-mismatch`.
- **AN5:** every outcome-named event with its context.
- **AN6:** `multi-site`, `effect-deps` / `effect-no-deps`, `two-legs` (`event_id=yes|no`).
- **AN7:** `untyped-wrapper`, `bypasses-codegen`, `bypasses-wrapper`.
- **UNRESOLVED:** names built at runtime (§ 17).

`--inventory` adds SITE, PLAN and WRAPPER rows. Exit codes: 0 means no plan-backed drift; 2 means an AN1-unplanned, AN2, AN3-spelling or AN4 row backed by a plan or success file (the V413 shape: 2 before the fix, 0 after); 1 means usage; 3 means the self-test failed. AN5, AN6, AN7 and the no-plan and convention rows are read lists and never set the exit code.

**Measured** (2026-10-19). The 23 fixture roots have no analytics call (0 sites each; `mobile-defects` declares `firebase-analytics` in Gradle and never calls it). On a real NestJS + React monorepo (read-only, 7,618 files, 21 s):

- The script found the wrapper itself: `trackEvent` in the tracking provider. It forwards one event to the Meta pixel, the GTM data layer and a server Conversions-API leg, with one shared event id, and its name is typed `string`.
- It resolved 15 call sites and 14 names, with 0 unresolved. The prototype grep counted 13 names, because it missed one wrapped call.
- No plan exists, so AN1 is one Hardening `no-plan` row, and §2 and §4 print N/A.
- AN7 gives `untyped-wrapper` (15 call sites).
- AN5 gives two rows. `CompleteRegistration` follows `await register.mutateAsync`, which is the outcome. `PackagePurchased`, carrying `value`, follows `await purchase.mutateAsync`, which only creates the payment URL. That second row is the measured candidate in § 14, unverified there.
- AN6 is quiet. `OnboardingDialogShown` runs in an effect on `[open, trackEvent]`, and the wrapper is kept stable by a ref after a recorded double count (§ 15).
- The GTM bootstrap `dataLayer.push({ event: 'gtm.js' })` was first counted as an event and a `bypasses-wrapper` row. It is now skipped as an auto event (§ 10).

On `benchmarks/fixtures/launch-gaps` (2026-10-19: wrapper found, plan of 14 events, 4 success events, 16 sites, 0 unresolved, exit 2), all 15 AN seeds print: AN1 2, AN2 3 (one `metric=yes`), AN3 2 clusters (each printed at its first spelling; the `convention` rows land on the drifted lines), AN4 2, AN5 2, AN6 2, AN7 2. The `Order Placed` decoy prints as AN5 `on-success` and the read clears it. One false row: AN4 `required-missing` on a posthog-node object call, because the object lane does not read its `properties: { … }`; two AN7 `bypasses-wrapper` rows sit on server legs, which cannot use the React wrapper.

## Pre-flight greps

Run as **one** script from the repo root. Every `rg` names its path. An `rg` exit 2 is `blocked (rg exit 2)`, never zero hits. Grep 0 is context for the script: 0c names the wrappers for `--wrapper=`, and 0b names the plan for `--plan=`.

```bash
ROOTS=(.)
X=(-g '!**/node_modules/**' -g '!**/vendor/**' -g '!**/{dist,build,out,.next,.nuxt,.output,.svelte-kit,.turbo,coverage,.venv,venv,__pycache__,generated,__generated__}/**' -g '!**/*.min.*' -g '!**/{docs,ai,.claude,.git,scripts}/**')
T=(-g '!**/{test,tests,__tests__,spec,specs,e2e,fixtures,__fixtures__,mocks,__mocks__,testdata,stories}/**' -g '!**/*.{spec,test,e2e-spec,stories,test-data}.*' -g '!**/{test_*,*_test,conftest}.*')
C=(-g '*.{ts,tsx,js,jsx,mjs,cjs,vue,svelte,py,rb,go,dart,kt,swift,java}')
NC='^[^:]+:\d+:\s*(?://|\*|/\*|#|<!--)'
g() { out=$("$@" 2>&1); ec=$?; [ $ec -eq 2 ] && { echo "blocked (rg exit 2): $out" | head -3; return; }; printf '%s\n' "$out" | rg -v --pcre2 "$NC" | head -40; }
# 0a. Analytics SDKs in the manifests (the lanes to expect).
g rg -n --no-heading --pcre2 -g '**/{package.json,requirements*.txt,pyproject.toml,Gemfile,go.mod,pubspec.yaml,build.gradle*,Podfile}' -g '!**/node_modules/**' '"(?:@segment/analytics-[\w-]+|analytics-node|@rudderstack/[\w-]+|rudder-sdk-js|@amplitude/[\w-]+|mixpanel(?:-browser)?|posthog-(?:js|node)|@heap/[\w-]+|@snowplow/[\w-]+|@statsig/[\w-]+|statsig-[\w-]+|firebase|@react-native-firebase/analytics|react-ga4|@vercel/analytics|plausible-tracker|@ampli/[\w-]+)"\s*:|^\s*(?:segment-analytics-python|analytics-python|posthog|mixpanel|amplitude-analytics)\b|^\s*gem\s+["\x27](?:analytics-ruby|mixpanel-ruby|posthog-ruby)|firebase_analytics:|mixpanel_flutter:|segment_analytics:|firebase-analytics|segmentio/analytics-go|posthog/posthog-go' "${ROOTS[@]}"
# 0b. Plan and codegen files (narrowed: `**/events*` matches domain-event specs, § 16).
g rg --files -g '**/{tracking-plan,tracking_plan,trackingPlan,analytics-events}*.{json,yml,yaml,ts,md}' -g '**/ampli.json' -g '**/avo.json' -g '**/.avo/**' -g '**/iglu/**/jsonschema/**' -g '**/ampli/index.*' -g '!**/node_modules/**' "${ROOTS[@]}"
# 0c. Wrapper definitions and typed signatures (the names for --wrapper=; the script also discovers forwarding wrappers).
g rg -n --no-heading --pcre2 "${C[@]}" "${X[@]}" "${T[@]}" '\b(?:const|let|var|function|def|fun|func)\s+(?:track|trackEvent|trackAnalytics|trackConversion|logEvent|logAnalyticsEvent|captureEvent|sendEvent|sendAnalyticsEvent|recordEvent|track_event|log_event)\b|\b(?:track|trackEvent|logEvent|captureEvent)\s*:\s*\(\s*\w+\s*\??\s*:\s*\w+' "${ROOTS[@]}"
# 0d. Direct SDK and pixel calls (cross-check of the script's lanes; a call outside the wrapper is §7 bypasses-wrapper).
g rg -n --no-heading --pcre2 "${C[@]}" "${X[@]}" "${T[@]}" '\b(?:analytics|posthog|mixpanel|amplitude|rudderanalytics|heap|umami|statsig|Analytics)\s*\.\s*(?:track|capture|logEvent|event)\s*\(|\bgtag\s*\(\s*["\x27]event|\bfbq\s*\(\s*["\x27]track|\bdataLayer\.push\s*\(|\blogEvent\s*\(' "${ROOTS[@]}"
# 0e. Launch-metric declarations the script joins as §2 even with no plan.
g rg -n --no-heading --pcre2 -g 'ai/product/success/*.md' '^#+\s*(?:Instrumentation|Events|Tracking)\b' "${ROOTS[@]}"
```

**Measured** (2026-10-19). Across the 23 fixture roots: 0 exit-2 runs. 0a found 1 line (`mobile-defects` Gradle Firebase), and 0b–0e found 0. On the real monorepo:

- 0a found 0 lines. The pixels load as scripts, not packages.
- 0b found 0. The first, wider plan glob (`**/events*`) had matched a domain-event consumer spec.
- 0c went from 45 lines (`track\w*` matched shipment-tracking code) to 2 once the name list was closed: the wrapper, and a carrier adapter's local `let track` (read and dismissed). The context type's signature spans two lines, so the one-line signature form misses it; the script reads it.
- 0d found 3: the pixel call and the data-layer push inside the wrapper, plus the GTM bootstrap.

## The 3,000-hit path

1. **Fix the inventory first.** Pass every wrapper 0c found. If `unresolved` is more than 10 % of the sites, read the dynamic names (a name map, a constant object) and add their values as a plan or a `--wrapper`. Never triage rows from a half-resolved inventory.
2. **Group.** One AN3 row is a cluster. Group AN4 rows by event. Group AN1 and AN2 by feature (the call site's directory, the plan's section). A wrapper with `name: string` is one AN7 finding, however many sites it has.
3. **Rank.** Rank first the declared launch metrics (`metric=yes`), revenue conversions sent to an ad platform with a `value` (AN5), and events on signup, checkout and onboarding. Rank renamed events last: a dashboard may still read the old name.
4. **Read the top 30 groups.** For AN5, read both the call and the awaited write.
5. **Prefer the runtime bulk oracle at scale.** One fake-sink harness around the wrapper, run through the existing component and e2e suites, records every event with its trigger. Count per test, then compare with the plan (V415, V416).
6. **Ledger the rest** as `Live, unreviewed (k groups / n rows)`, never `checked`.

## Scans for

| § | Class | Research id | Default severity |
|---|---|---|---|
| 1 | Event fired in code, absent from the plan; or no plan at all | PR-AN-1 | medium; Hardening for the no-plan row |
| 2 | Planned event never fired, including a `/define-success` metric's event | PR-AN-2 | high for a declared launch metric; medium otherwise |
| 3 | Name drift: one event, several spellings; the convention broken | PR-AN-3 | medium; low for convention-only |
| 4 | Property drift: key sets or literal types differ per site; a required property missing | PR-AN-4 | medium; high when a launch metric's breakdown property is missing |
| 5 | An outcome event fired before the outcome (on intent, before the confirming response) or on the failure path too | PR-AN-5 | high for a revenue conversion sent to an ad platform with a value; medium otherwise |
| 6 | Duplicate firing: an effect on unstable deps, an impression in render, client + server legs with no shared id | PR-AN-6 | medium |
| 7 | Untyped event channel; direct SDK calls bypassing the codegen or the wrapper | PR-AN-7 | Hardening |

### 1. Unplanned event, or no plan

**Class** PR-AN-1 · **Severity** medium; the no-plan row is Hardening.

```
BAD:   trackEvent('PurchaseFailed', { reason })          // the plan has no PurchaseFailed
       (no plan in the repo: 14 names live only in code)
GOOD:  the event is in the plan with its properties, or it is removed
```

**Read the code:**

- Is it a debug or dev-only event behind a flag?
- Is the plan filtered by source? An event planned for the iOS source is not planned for web (§ 10).
- Is it a near-spelling of a planned event? Then it is §3.

**Finding when** the plan exists and lacks the event. With no plan, report the one inventory row and its names.

**Proof:** `event-inventory.py --plan=<plan>` exits 2 listing it, and 0 after the plan or code is fixed (V413).

### 2. Planned event never fired

**Class** PR-AN-2 · **Severity** high for a declared launch metric (`metric=yes`); medium otherwise.

```
BAD:   ai/product/success/referrals.md § Instrumentation: `Referral Sent` — no call site anywhere
GOOD:  the event fires at the outcome, or the success file names the event that does
```

**Read the code:**

- Is it fired outside the repo: a server job, a reverse-ETL, a tag-manager trigger configured in the vendor UI? Ask, and cite the answer.
- Is it fired under a different spelling? Then it is §3.

**Finding when** neither the repo nor a cited outside source fires it. `metric=yes` rows feed `launch-readiness` PRR-AN-2.

**Proof:** the plan diff exits 2 for it (V413). At runtime, the query returns NO-DATA (V423, `launch-readiness`).

### 3. Name drift

**Class** PR-AN-3 · **Severity** medium; low for a convention-only row.

```
BAD:   'Signup Completed' (web) · 'signup_completed' (mobile) · 'SignUpComplete' (plan)
GOOD:  one spelling everywhere, or the plan declares the variants distinct
```

**Read the code:** Are these one trigger with the same properties under two spellings, or two events by design (`StoreConnected` vs `StoreConnectedFromOnboarding`, § 12)? Is a legacy name frozen for dashboard continuity, and declared as such?

**Finding when** one trigger is counted under two names.

**Proof:** after normalisation, each cluster holds one spelling (V414).

### 4. Property drift

**Class** PR-AN-4 · **Severity** medium; high when a launch metric loses its breakdown property.

```
BAD:   trackEvent('StoreConnected', { store_type: 'manual', count: 2 })
       trackEvent('StoreConnected', { store_type: 'shopify', count: '2' })    // number vs string; plan has no `count`
GOOD:  every site sends the plan's keys with the plan's types
```

**Read the code:** Is the property optional by design? Is a spread (`...data`) supplying the missing key? The script skips spread sites for the required check. Does the pipeline coerce types?

**Finding when** a required key is missing, or a literal type contradicts the plan or another site.

**Proof:** a typed channel fails `tsc` on the bad site, or the plan diff exits 2 (V413, V417).

### 5. Outcome fired before the outcome

**Class** PR-AN-5 · **Severity** high for a revenue conversion sent to an ad platform with a value; medium otherwise.

```
BAD:   const r = await purchase.mutateAsync(id);     // creates a checkout URL; nobody has paid
       trackEvent('PackagePurchased', { value, currency });  assign(r.paymentUrl);
       <button onClick={() => trackEvent('Signup Completed')}>          // fires on the click, before the server answers
GOOD:  the purchase event fires on the payment-confirmed callback or the webhook (server leg), and
       'Checkout Started' fires on the click
```

**Read the code:**

- What did the awaited call (`after-await(<callee>)`) actually write: the outcome, or an intent such as a checkout session?
- Does a server leg fire the confirmed outcome with the same name and id (§ 11)?
- Is the name an intent name (`…Started`, `…Clicked`, § 14)?
- Is it in both the `then` and the `catch`?

**Finding when** an outcome name fires on intent, before the confirming response, or on failure, and no leg records the confirmed outcome instead.

**Proof:** a fake-sink test: a failed write emits 0 events, and a confirmed write emits exactly 1 (V415).

### 6. Duplicate firing

**Class** PR-AN-6 · **Severity** medium.

```
BAD:   useEffect(() => { trackEvent('DialogShown') }, [onClose])   // onClose is rebuilt every render
       client pixel + server Conversions API both send 'Purchase' with no shared event_id
GOOD:  deps hold only the trigger (`[open]`), the wrapper is stable (useCallback + ref), both legs share one event id
```

**Read the code:** Is `trackEvent` stable (a `useCallback` with `[]`, a ref)? Is the impression meant per open (§ 15)? For `two-legs`, does the vendor dedupe by `event_id` (Meta) or `insert_id` (Amplitude), and does the id travel on both legs?

**Finding when** one trigger produces more events than the plan specifies.

**Proof:** a fake-sink count under StrictMode, re-render and re-open (V416).

### 7. Untyped event channel

**Class** PR-AN-7 · **Severity** Hardening; medium when §3 or §4 drift the type would have stopped.

```
BAD:   trackEvent: (eventName: string, data?: Record<string, unknown>) => void
GOOD:  trackEvent<E extends EventName>(name: E, data: EventProps[E])   — or the generated Ampli / Avo functions
```

A wrapper typed `string` turns every typo into a new event, and every property into `unknown`. A direct SDK call beside a codegen plan bypasses the plan's types.

**Finding when** the channel accepts any string. It is Hardening by default, and medium when §3 or §4 found drift that the type would have stopped.

**Proof:** a misspelt name fails `tsc` or the codegen check (V417).

## Proof

The oracles, failing first and green after the fix, are in [`proof-oracles.md`](../../../../snippets/proof-oracles.md) § Product: V413 (plan diff), V414 (name cluster), V415 (outcome firing), V416 (firing count), V417 (typed event channel).

A static fact with both ends cited needs no runtime oracle: a planned name with no call site, or a required key absent from a literal object. §5 and §6 always need the fake sink, because the firing point is behaviour.

The fix's regression set covers every variant (`/product-fix`):

- an SDK call, a wrapper call, a codegen call and a server-side call;
- case, separator and tense drift;
- a missing key, an extra key and a literal type;
- `onClick`, before the `await`, and in both `then` and `catch`;
- an effect on an unstable callback, StrictMode, and two legs with no id.

## Output

```
tracking-plan-scan — lanes: wrapper trackEvent (web/src/tracking/AnalyticsProvider.tsx:60, name: string, event id on all legs),
posthog 2 direct calls; plan: ai/product/tracking-plan.yaml (31 events); success: 2 files (5 metric events)

Findings: 2 · Unconfirmed: 1

1. web/src/features/wallet/use-wallet.ts:43                                            [report-with-fix]   high
   §5 purchase fired before payment — trackEvent('PackagePurchased', { value, currency }) after
   `await purchase.mutateAsync` (creates the checkout URL; the payment lands later via billing/webhook.ts:88, which
   fires nothing). The pixel and the Conversions API receive a purchase with a value for every checkout start.
   Proof: fake sink — payment abandoned after checkout → 1 PackagePurchased (fails; expected 0).
   Fix: fire 'Checkout Started' here; fire 'PackagePurchased' from the payment-confirmed webhook with the order's event id.
2. ai/product/success/referrals.md  `Referral Sent`                                     [report-with-fix]   high
   §2 launch metric never fired — no call site in web/ or api/; the plan lists it (tracking-plan.yaml:44).
   Proof: event-inventory.py --plan=ai/product/tracking-plan.yaml exits 2 (AN2 never-fired, metric=yes).
   Fix: fire it in api/src/referrals/send-referral.use-case.ts after the commit.
3. web/src/onboarding/Dialog.tsx:4                                                      [report-flagged]    medium
   §6 impression on an unstable dependency — useEffect(…, [onClose]). Unproven link: whether the parent rebuilds
   onClose per render. Settling test: render under StrictMode, re-render twice, expect 1 DialogShown per open.

Verification: 17 candidates → 2 findings · 1 unconfirmed · 3 hardening (untyped wrapper, 2 convention) · 11 refuted
(4 intent names, 3 server-leg confirmed outcomes, 2 per-open impressions, 2 distinct-by-design names)

Class coverage:
  §1  unplanned / no plan     checked (script: 29 names vs 31 planned → 0 unplanned)
  §2  never fired             checked (3 rows → 1 finding; 2 fired by a server job, cited)
  §3  name drift              checked (1 cluster → refuted: distinct by design)
  §4  property drift          checked (4 rows → 0 findings; 4 behind a spread)
  §5  outcome before outcome  checked (6 outcome sites → 1 finding)
  §6  duplicate firing        checked (2 rows → 1 unconfirmed)
  §7  untyped channel         checked (1 Hardening: trackEvent name: string)
```

`[report-with-fix]`: both ends are read, the oracle failed, and the fix is local. `[report-flagged]`: one link is unproven (an awaited write's meaning, a parent's render behaviour, an outside leg), and the row names it and the settling test.

The coverage block lists **all seven** classes on every run:

- `checked (k rows → m findings)` when the script ran with the lanes present;
- `N/A (no plan — no oracle)` for §1, §2 (the plan half) and §4 without a plan, and `N/A (no analytics calls)` when 0a and 0d are empty;
- `blocked (<reason>)` when the script or a grep failed;
- `NOT-RUN (auth)` for a vendor CLI lane that needs a login.

## False positives / precedents

The verifier reads [`precedents/product.md`](../../../../snippets/precedents/product.md) § 10–17 and § 36–38 and [`refute-before-report.md`](../../../../snippets/refute-before-report.md) § 4. The ones this skill meets most:

- **Auto-tracked and non-track calls** (§ 10). Page and screen views, autocapture, `identify`, the GTM `gtm.js` bootstrap.
- **A second delivery leg** (§ 11). A client pixel absent when signed in, while the server leg delivers.
- **Similar names, distinct events** (§ 12).
- **Hashing before the vendor** (§ 13). Security's class either way.
- **Intent events fire on intent** (§ 14).
- **Per-open impressions repeat** (§ 15).
- **No plan, no plan-diff** (§ 16).
- **A name built at runtime is unresolved** (§ 17).

## When to run

- On a diff that adds or changes an analytics call, the wrapper, the plan, a success file's § Instrumentation, or the write an outcome event follows.
- On every whole-repo `/audit-requirements --repo`, reporting all seven classes. `launch-readiness` reads §2 (`metric=yes`) for PRR-AN-2 and §5 for PRR-AN-5.
- When a dashboard number moves without a product change, or before an ad-platform conversion is switched on.

## Halt conditions

- Do not report a high row as confirmed until a verifier other than the run that found it has passed it through [`refute-before-report.md`](../../../../snippets/refute-before-report.md) with [`precedents/product.md`](../../../../snippets/precedents/product.md). Under `/audit-requirements --repo`, that happens in its verification phase. Run standalone, apply the protocol as a separate second pass, tag those rows `self-verified`, and list the rest as Unconfirmed, Hardening, Pre-existing or Refuted. Never drop them.
- Halt on any row without both ends: the site, and the plan line, the success file, or the plan's silence.
- Do not grade §1, §2 or §4 against an invented plan. With no plan, they are N/A, and the inventory is the deliverable.
- Do not report a dynamic name as a mismatch (§ 17), or an intent name as §5 (§ 14).
- Do not run a vendor CLI that logs in, uploads (`ampli status -u`) or installs into the repo. A tool that did not run is `NOT-RUN`, never PASS.
- Do not edit the tracking plan without the plan owner's yes. A plan change alters what dashboards count.
- Do not write into the scanned repo. The script writes nothing.
- Do not re-derive a class another skill owns (§ Boundaries). Cross-link it.

## Related

- `/audit-requirements --repo` (this pack) dispatches this skill and folds its coverage block into the class ledger. `/product-fix` closes the findings failing-first. `/define-success` (this pack) names the metric events §2 joins.
- `launch-readiness` (this pack) consumes §2 and §5 for PRR-AN-2 and PRR-AN-5. `feature-surface-scan` and `entitlement-consistency-scan` (this pack) are the other product joins.
- Business `/suggest-metrics --funnel` (when installed) consumes the AN rows per flow. Security `data-exposure-scan` and `@data-privacy-reviewer` (when installed) own PII and consent.

Files in this skill

  • SKILL.md29.9 KB
  • scripts/event-inventory.py55.3 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.