@ssheleg/sheleg-dev 0.5.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +347 -0
  2. package/README.md +101 -1
  3. package/SECURITY.md +190 -52
  4. package/bin/sheleg-dev.js +18 -0
  5. package/package.json +2 -2
  6. package/plugins/sheleg-dev/.claude-plugin/plugin.json +1 -1
  7. package/plugins/sheleg-dev/hooks/hooks.json +29 -0
  8. package/plugins/sheleg-dev/hooks/lib/moneygate.js +587 -0
  9. package/plugins/sheleg-dev/hooks/money-gate.js +68 -0
  10. package/plugins/sheleg-dev/skills/ad-tracking/SKILL.md +24 -75
  11. package/plugins/sheleg-dev/skills/ad-tracking/fixtures/README.md +76 -0
  12. package/plugins/sheleg-dev/skills/ad-tracking/fixtures/assert-dedup-contract.mjs +249 -0
  13. package/plugins/sheleg-dev/skills/ad-tracking/fixtures/manifest.json +75 -0
  14. package/plugins/sheleg-dev/skills/ad-tracking/fixtures/purchase-capi-from-thank-you-page.json +32 -0
  15. package/plugins/sheleg-dev/skills/ad-tracking/fixtures/purchase-capi-server.json +26 -0
  16. package/plugins/sheleg-dev/skills/ad-tracking/fixtures/purchase-pixel-browser.json +12 -0
  17. package/plugins/sheleg-dev/skills/ad-tracking/fixtures/reference-emitter.mjs +129 -0
  18. package/plugins/sheleg-dev/skills/ad-tracking/references/event-tracking.md +42 -0
  19. package/plugins/sheleg-dev/skills/ad-tracking/references/meta-linkedin.md +159 -2
  20. package/plugins/sheleg-dev/skills/crypto-payments/SKILL.md +68 -0
  21. package/plugins/sheleg-dev/skills/crypto-payments/references/heleket-provider.md +349 -10
  22. package/plugins/sheleg-dev/skills/frontend-performance/SKILL.md +31 -13
  23. package/plugins/sheleg-dev/skills/stripe-billing/SKILL.md +48 -104
  24. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/README.md +110 -0
  25. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/assert-money-invariants.mjs +404 -0
  26. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/charge-refunded-partial.json +52 -0
  27. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/charge-refunded-remainder.json +63 -0
  28. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/checkout-session-async-payment-failed.json +43 -0
  29. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/checkout-session-completed-async-unpaid.json +43 -0
  30. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/checkout-session-completed-paid.json +42 -0
  31. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/invoice-paid-subscription-cycle-next-period.json +70 -0
  32. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/invoice-paid-subscription-cycle-redelivery.json +70 -0
  33. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/invoice-paid-subscription-cycle.json +61 -0
  34. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/invoice-paid-subscription-update-proration.json +90 -0
  35. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/manifest.json +172 -0
  36. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/reference-handler.mjs +315 -0
  37. package/plugins/sheleg-dev/skills/stripe-billing/references/subscription-lifecycle.md +27 -0
  38. package/plugins/sheleg-dev/skills/stripe-billing/references/testing-and-local-dev.md +38 -2
  39. package/plugins/sheleg-dev/skills/stripe-billing/references/webhook-events.md +43 -1
@@ -220,19 +220,11 @@ fbq('track', 'PageView');
220
220
 
221
221
  ### Standard Events
222
222
 
223
- Meta defines 17 standard events. The most important for SaaS conversion funnels:
224
-
225
- | Event | When | Parameters | Notes |
226
- |---|---|---|---|
227
- | `PageView` | Every page load | (automatic from base code) | Fires from the init script |
228
- | `CompleteRegistration` | User signs up | `content_name` (method), `status: true` | Separate from GA4 `sign_up` |
229
- | `InitiateCheckout` | Checkout started | `value`, `currency`, `content_name`, `content_category`, `num_items` | |
230
- | `Purchase` | Purchase completed | `value` (required), `currency` (required), `content_name`, `content_category`, `content_type`, `contents[]` | `value` + `currency` are **mandatory** for optimisation |
231
- | `Subscribe` | Recurring subscription | `value`, `currency`, `predicted_ltv` | Distinct from one-time Purchase |
232
- | `Lead` | Lead form submitted | (optional params) | |
233
- | `ViewContent` | Key content viewed | `content_name`, `content_category` | |
234
- | `AddToCart` | Item added to cart | `content_name`, `value`, `currency` | |
235
- | `Search` | Search performed | `search_string` | |
223
+ Meta defines 17 standard events. The four that carry a SaaS funnel are
224
+ `CompleteRegistration`, `InitiateCheckout`, `Purchase` and `Subscribe`, and only
225
+ `Purchase` has mandatory parameters: `value` and `currency`, without which Meta
226
+ cannot optimise. The full table — every event, its parameters and the traps —
227
+ is in `references/meta-linkedin.md` → **Standard events**.
236
228
 
237
229
  ### Deeper Meta and LinkedIn detail
238
230
 
@@ -241,68 +233,21 @@ firing wrapper, advanced matching (hashed identifiers, and what must never be
241
233
  sent) and CAPI deduplication.
242
234
  ## LinkedIn Insight Tag
243
235
 
244
- ### Setup
245
-
246
- **Env var:** `NEXT_PUBLIC_LINKEDIN_PARTNER_ID` (numeric string)
247
-
248
- The LinkedIn Insight Tag is consent-gated (same pattern as Meta Pixel). It loads the `insight.min.js` script and includes a noscript pixel fallback.
249
-
250
- ```javascript
251
- _linkedin_partner_id = "PARTNER_ID";
252
- window._linkedin_data_partner_ids = window._linkedin_data_partner_ids || [];
253
- window._linkedin_data_partner_ids.push(_linkedin_partner_id);
254
- ```
255
-
256
- ### Conversion Tracking
257
-
258
- LinkedIn conversions are configured in Campaign Manager (not in code). The Insight Tag automatically tracks page views, and you define URL-based conversion rules in the LinkedIn Campaign Manager:
259
-
260
- 1. Campaign Manager → Analyze → Conversion tracking → Create conversion
261
- 2. Define URL rules (e.g. `/thank-you`, `/onboarding`)
262
- 3. LinkedIn Insight Tag automatically matches page loads to conversion rules
263
-
264
- For event-based conversions, use `window.lintrk('track', { conversion_id: 123456 })`.
265
-
266
- **Docs:**
267
- - [LinkedIn Insight Tag setup](https://www.linkedin.com/help/lms/answer/a418880)
268
- - [LinkedIn conversion tracking](https://www.linkedin.com/help/lms/answer/a423304)
269
-
270
- ---
236
+ Consent-gated on the same wrapper as the Meta Pixel, env var
237
+ `NEXT_PUBLIC_LINKEDIN_PARTNER_ID`. Conversions are configured in Campaign Manager
238
+ rather than in code, so there is nothing to deduplicate and nothing that fails
239
+ silently in the bundle — but a URL rule on a refreshable page counts every
240
+ refresh. Setup snippet, the Campaign Manager steps and `lintrk` are in
241
+ `references/meta-linkedin.md` → **LinkedIn conversion tracking**.
271
242
 
272
243
  ## User Identification
273
244
 
274
- ### Cross-Platform Identification Strategy
275
-
276
- | Platform | Method | When | What it enables |
277
- |---|---|---|---|
278
- | GA4 | `user_id` via `gtag('config', TAG_ID, { user_id })` | After login/onboarding | Cross-device tracking, audience building |
279
- | GA4 | Enhanced Conversions via `gtag('set', 'user_data', { email })` | After login/onboarding | Better conversion attribution |
280
- | Meta Pixel | Advanced Matching via `fbq('init', PIXEL_ID, { em, fn })` | After login/onboarding | Better conversion attribution, larger custom audiences |
281
- | Mixpanel | `alias()` + `identify()` | After login/onboarding | Merge anonymous → identified user profiles |
282
-
283
- **All identification calls should be placed in a single identification component** that runs in the dashboard/authenticated layout. This ensures:
284
- - User data is sent once per session
285
- - All platforms receive identification data at the same time
286
- - Pre-login anonymous events merge into the identified profile
287
-
288
- ### Alias vs Identify (Mixpanel)
289
-
290
- - `alias(userId, anonymousId)` — creates a permanent link between the anonymous and real user ID. Must be called **exactly once** per user, **before** `identify()`. Use a localStorage flag to prevent duplicate calls.
291
- - `identify(userId)` — sets the user ID for all subsequent events.
292
-
293
- ### Timing
294
-
295
- Fire identification in a `useEffect` in the authenticated layout:
296
-
297
- ```typescript
298
- useEffect(() => {
299
- // 1. Mixpanel alias + identify
300
- // 2. GA4 user_id + Enhanced Conversions
301
- // 3. Meta Advanced Matching
302
- }, [userId, email, name]);
303
- ```
304
-
305
- ---
245
+ One rule the body keeps, because getting it wrong corrupts attribution rather
246
+ than losing it: **identify before you track, never after**. An event fired for an
247
+ anonymous id and re-attributed later is two users to every platform that already
248
+ ingested it. The per-platform strategy, the Mixpanel `alias` vs `identify`
249
+ distinction and the timing rules are in `references/event-tracking.md` →
250
+ **User identification**.
306
251
 
307
252
  ## Event naming
308
253
 
@@ -316,9 +261,13 @@ schema and the full purchase / add_to_cart / begin_checkout set.
316
261
 
317
262
  **Deduplication is the part that silently doubles revenue.** A purchase fired
318
263
  from both the browser and the server (Conversions API, server-side GA4) must
319
- carry the SAME `event_id` / `transaction_id`, or both are counted. Test it by
320
- reloading the thank-you page: a purchase that increments twice is a
321
- deduplication bug, not a tracking success.
264
+ carry the SAME `event_id` / `transaction_id`, or both are counted. Run
265
+ `fixtures/assert-dedup-contract.mjs` rather than reloading the thank-you page:
266
+ `pixel-and-capi-carry-one-event-id` and `pixel-and-capi-carry-one-event-name` fail an
267
+ emitter that shares one field and not the other,
268
+ `no-purchase-reported-before-the-charge-cleared` fails one that reports from the redirect,
269
+ and `the-browser-event-is-kept` fails one that drops the pixel. `--self-test` deletes each
270
+ rule and watches the matching assertion go red.
322
271
 
323
272
  **A purchase is not a browser event, and that decides where it comes from.**
324
273
  Every other event on the list is something a person did in a tab, so the browser
@@ -0,0 +1,76 @@
1
+ # Deduplication fixtures and the assertion pack
2
+
3
+ **Copy this whole directory into your repository.** It is the executable half of what
4
+ `SKILL.md` → *E-commerce* and `references/meta-linkedin.md` →
5
+ *Deduplication with the Conversions API* state in prose: the three payloads one purchase
6
+ produces, six assertions a correct emitter must pass, and a self-test that breaks the
7
+ emitter one rule at a time so you can watch every assertion fail.
8
+
9
+ ```bash
10
+ node assert-dedup-contract.mjs # 6 assertions against the reference emitter
11
+ node assert-dedup-contract.mjs --self-test # break one rule at a time; every assertion must go red
12
+ ```
13
+
14
+ No dependencies, no network, **no access token anywhere in this directory** — the sink
15
+ collects what would have been sent.
16
+
17
+ ## Contents
18
+
19
+ - [The fixtures](#the-fixtures)
20
+ - [The assertions](#the-assertions)
21
+ - [What each mutant deletes](#what-each-mutant-deletes)
22
+ - [Why the browser side is read from the fixture](#why-the-browser-side-is-read-from-the-fixture)
23
+ - [Placeholders](#placeholders)
24
+
25
+ ## The fixtures
26
+
27
+ | File | What it is |
28
+ |---|---|
29
+ | `purchase-pixel-browser.json` | the four arguments of the browser call: `fbq('track', 'Purchase', custom_data, { eventID })` |
30
+ | `purchase-capi-server.json` | the Conversions API request body the **webhook** sends for the same purchase |
31
+ | `purchase-capi-from-thank-you-page.json` | the body a thank-you-page-sourced emitter sends for a session whose charge never cleared — **the payload that must never exist**, kept so the assertion can name it |
32
+
33
+ The first two carry the same `event_id` and the byte-equal `event_name`, which is the
34
+ contract. The third is the defect, recorded rather than described: it was produced by
35
+ running the reference emitter with `webhook-sourced` removed.
36
+
37
+ ## The assertions
38
+
39
+ | Assertion | What a wrong emitter does |
40
+ |---|---|
41
+ | `pixel-and-capi-carry-one-event-id` | generates an id per emission, so Meta counts one purchase twice |
42
+ | `pixel-and-capi-carry-one-event-name` | sends `Purchase` from the browser and `purchase` from the server: a shared id that deduplicates nothing |
43
+ | `no-purchase-reported-before-the-charge-cleared` | reports the purchase from the one place that cannot know the money moved |
44
+ | `the-browser-event-is-kept` | drops the browser event, losing the click id, the consent state and the session |
45
+ | `identifiers-reach-the-server-hashed` | puts the address itself in `user_data.em` |
46
+ | `the-shipped-capi-fixture-is-what-a-correct-emitter-sends` | anything that changes the body — a ratchet, so the fixtures you copy are the reference output |
47
+
48
+ ## What each mutant deletes
49
+
50
+ | Rule | The code it removes | The integration that ships without it |
51
+ |---|---|---|
52
+ | `shared-event-id` | reading the id written once at session creation | `crypto.randomUUID()` at each emission |
53
+ | `exact-event-name` | the one `event_name` constant used by both sides | `'Purchase'` in the browser, a lowercased variant on the server |
54
+ | `webhook-sourced` | the gate that keeps the thank-you page from reporting | fires the conversion where the redirect lands |
55
+ | `keep-browser-event` | the pixel call | server-only, and blind to everything the browser carries |
56
+ | `hashed-identifiers` | the SHA-256 of the identifier | hands the raw address to the wrapper, which hashes whatever it is given |
57
+
58
+ ## Why the browser side is read from the fixture
59
+
60
+ `pixel-and-capi-carry-one-event-id` and `pixel-and-capi-carry-one-event-name` compare the
61
+ server's output against `purchase-pixel-browser.json` rather than against the pixel call the
62
+ same emitter just made. That is deliberate, and it is the correction of a first pass:
63
+ reading both sides out of one emitter meant deleting `keep-browser-event` also turned those
64
+ two assertions red, so neither the id rule nor the name rule was proven on its own. Two
65
+ mechanisms that both explain a passing fixture leave both untested. The fixture is the
66
+ browser's contract, held at a boundary the server does not control.
67
+
68
+ `--self-test` fails if any rule has no fixture that isolates it, so this stays true.
69
+
70
+ ## Placeholders
71
+
72
+ `usr_PLACEHOLDER_carol`, `prod_PLACEHOLDER_pro`, `fb.1.…PLACEHOLDERCLICKID`,
73
+ `203.0.113.10` (a documentation address). The email is `placeholder@example.invalid` and
74
+ the 64-hex string is its SHA-256 — a hash of a published placeholder, not of a person. No
75
+ pixel id, no dataset id and no access token appear here: the token is a runtime secret and
76
+ `references/performance-security.md` is where it belongs.
@@ -0,0 +1,249 @@
1
+ #!/usr/bin/env node
2
+ // The assertions a correct purchase emitter must pass, and the proof that each one can
3
+ // fail.
4
+ //
5
+ // node assert-dedup-contract.mjs # run them against the reference emitter
6
+ // node assert-dedup-contract.mjs --self-test # break the emitter one rule at a time
7
+ //
8
+ // `--self-test` is what makes this evidence: for every assertion it deletes the rule whose
9
+ // removal MUST turn that assertion red, and fails if the assertion still passes. It then
10
+ // prints which fixture ISOLATES each rule — because two rules that both explain a passing
11
+ // fixture mean neither is tested.
12
+ //
13
+ // The runner is duplicated from `stripe-billing/fixtures/assert-money-invariants.mjs` on
14
+ // purpose. A reader installs one skill, not six, so a shared import would resolve to
15
+ // nothing in the common case; this pack has to run from its own directory alone.
16
+ //
17
+ // No network, no filesystem writes, no clock, and no access token anywhere in this
18
+ // directory — the sink collects what WOULD have been sent.
19
+
20
+ import { readFileSync } from 'node:fs';
21
+ import { fileURLToPath } from 'node:url';
22
+ import path from 'node:path';
23
+ import assert from 'node:assert/strict';
24
+ import { createSink, createEmitter, RULES, EVENT_NAME } from './reference-emitter.mjs';
25
+
26
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
27
+ const fixture = (name) => JSON.parse(readFileSync(path.join(HERE, name), 'utf8'));
28
+
29
+ const PIXEL_BROWSER = 'purchase-pixel-browser.json';
30
+ const CAPI_SERVER = 'purchase-capi-server.json';
31
+ const CAPI_FORBIDDEN = 'purchase-capi-from-thank-you-page.json';
32
+
33
+ /** The purchase is read OUT of the fixture, so the values have one home. */
34
+ function purchaseFrom(name, cleared) {
35
+ const event = fixture(name).data[0];
36
+ return {
37
+ conversionEventId: event.event_id,
38
+ value: event.custom_data.value,
39
+ currency: event.custom_data.currency,
40
+ contentIds: event.custom_data.content_ids,
41
+ numItems: event.custom_data.num_items,
42
+ externalId: event.user_data.external_id[0],
43
+ emailSha256: event.user_data.em[0],
44
+ // The raw address a wrong emitter would send instead. sha256 of this string is the
45
+ // `em` above, so the two fixtures are consistent and neither is a real person.
46
+ email: 'placeholder@example.invalid',
47
+ clientIpAddress: event.user_data.client_ip_address,
48
+ clientUserAgent: event.user_data.client_user_agent,
49
+ fbp: event.user_data.fbp,
50
+ fbc: event.user_data.fbc,
51
+ eventTime: event.event_time,
52
+ eventSourceUrl: event.event_source_url,
53
+ cleared,
54
+ };
55
+ }
56
+
57
+ /** Carol paid by card: the charge cleared, and the webhook says so. */
58
+ const CLEARED = () => purchaseFrom(CAPI_SERVER, true);
59
+ /** Bob chose a bank debit: the session completed, the money has not moved and may not. */
60
+ const NOT_CLEARED = () => purchaseFrom(CAPI_FORBIDDEN, false);
61
+
62
+ function harness(without = []) {
63
+ const sink = createSink();
64
+ return { sink, emitter: createEmitter(sink, { without }) };
65
+ }
66
+
67
+ /** The real sequence: the browser fires on the thank-you page, the webhook lands when it lands. */
68
+ function runPurchase({ sink, emitter }, purchase) {
69
+ emitter.onThankYouPage(purchase);
70
+ if (purchase.cleared) emitter.onChargeCleared(purchase);
71
+ return sink;
72
+ }
73
+
74
+ export const INVARIANTS = [
75
+ {
76
+ id: 'pixel-and-capi-carry-one-event-id',
77
+ fixtures: [PIXEL_BROWSER, CAPI_SERVER],
78
+ states: 'one id, generated once and used by both sides, or the revenue counts twice',
79
+ breaks: ['shared-event-id'],
80
+ run({ sink, emitter }) {
81
+ runPurchase({ sink, emitter }, CLEARED());
82
+ // The browser side is read from the SHIPPED fixture rather than from the sink. That
83
+ // is deliberate: it keeps this assertion independent of whether the browser event
84
+ // fired at all, which is a different rule with its own fixture. Reading both sides
85
+ // out of one emitter is how the two got tangled on the first pass.
86
+ const pixelId = fixture(PIXEL_BROWSER)[3].eventID;
87
+ const serverId = sink.capi[0].data[0].event_id;
88
+ assert.equal(serverId, pixelId,
89
+ `the pixel carries ${pixelId} and the server sent ${serverId} — nothing will ever `
90
+ + 'deduplicate these two, and one purchase is two conversions');
91
+ },
92
+ },
93
+ {
94
+ id: 'pixel-and-capi-carry-one-event-name',
95
+ fixtures: [PIXEL_BROWSER, CAPI_SERVER],
96
+ states: 'Meta compares the name as well as the id, and the name is the half that is missed',
97
+ breaks: ['exact-event-name'],
98
+ run({ sink, emitter }) {
99
+ runPurchase({ sink, emitter }, CLEARED());
100
+ const pixelName = fixture(PIXEL_BROWSER)[1]; // the browser contract, not the sink
101
+ const serverName = sink.capi[0].data[0].event_name;
102
+ assert.equal(serverName, pixelName,
103
+ `'${pixelName}' in the browser and '${serverName}' on the server: a shared id `
104
+ + 'deduplicates nothing when the names differ');
105
+ assert.equal(serverName, EVENT_NAME);
106
+ },
107
+ },
108
+ {
109
+ id: 'no-purchase-reported-before-the-charge-cleared',
110
+ fixtures: [CAPI_FORBIDDEN],
111
+ states: 'the thank-you page has no idea whether the money moved, so it reports nothing',
112
+ breaks: ['webhook-sourced'],
113
+ run({ sink, emitter }) {
114
+ runPurchase({ sink, emitter }, NOT_CLEARED());
115
+ assert.equal(sink.capi.length, 0,
116
+ 'a Conversions API purchase was sent for a charge that has not cleared — this is '
117
+ + 'the event that exists for every session that reached the page and for no refund');
118
+ assert.equal(sink.pixel.length, 0,
119
+ 'a Purchase pixel fired for a session whose payment is still pending');
120
+ assert.notDeepEqual(sink.capi[0], fixture(CAPI_FORBIDDEN));
121
+ },
122
+ },
123
+ {
124
+ id: 'the-browser-event-is-kept',
125
+ fixtures: [PIXEL_BROWSER],
126
+ states: 'the browser event stays and stays subordinate — it carries the click ids',
127
+ breaks: ['keep-browser-event'],
128
+ run({ sink, emitter }) {
129
+ runPurchase({ sink, emitter }, CLEARED());
130
+ assert.equal(sink.pixel.length, 1,
131
+ 'no browser event fired: the click id, the consent state and the session are lost, '
132
+ + 'and only the browser had them');
133
+ assert.equal(sink.pixel[0][0], 'track');
134
+ },
135
+ },
136
+ {
137
+ id: 'identifiers-reach-the-server-hashed',
138
+ fixtures: [CAPI_SERVER],
139
+ states: 'advanced matching sends hashes, never the address itself',
140
+ breaks: ['hashed-identifiers'],
141
+ run({ sink, emitter }) {
142
+ runPurchase({ sink, emitter }, CLEARED());
143
+ const userData = sink.capi[0].data[0].user_data;
144
+ for (const value of userData.em) {
145
+ assert.match(value, /^[0-9a-f]{64}$/,
146
+ `user_data.em carried ${JSON.stringify(value)} — a raw identifier, not a sha256`);
147
+ }
148
+ assert.ok(!JSON.stringify(userData).includes('@'),
149
+ 'user_data carries something with an @ in it');
150
+ },
151
+ },
152
+ {
153
+ id: 'the-shipped-capi-fixture-is-what-a-correct-emitter-sends',
154
+ fixtures: [CAPI_SERVER, PIXEL_BROWSER],
155
+ states: 'the fixtures are the reference output, byte for byte — not an illustration',
156
+ // Broad by construction: it compares the whole body, so every rule that changes any
157
+ // field turns it red. It is here so a reader can trust the fixtures they copy.
158
+ breaks: ['shared-event-id', 'exact-event-name', 'hashed-identifiers', 'keep-browser-event'],
159
+ run({ sink, emitter }) {
160
+ runPurchase({ sink, emitter }, CLEARED());
161
+ assert.deepEqual(sink.capi[0], fixture(CAPI_SERVER));
162
+ assert.deepEqual(sink.pixel[0], fixture(PIXEL_BROWSER));
163
+ },
164
+ },
165
+ ];
166
+
167
+ // ------------------------------------------------------------------------- runners
168
+
169
+ async function runOne(invariant, without) {
170
+ const context = harness(without);
171
+ try {
172
+ await invariant.run(context);
173
+ return null;
174
+ } catch (error) {
175
+ return error.message.split('\n')[0];
176
+ }
177
+ }
178
+
179
+ async function runAll() {
180
+ let failed = 0;
181
+ for (const invariant of INVARIANTS) {
182
+ const why = await runOne(invariant, []);
183
+ if (why) {
184
+ failed += 1;
185
+ console.error(`FAIL ${invariant.id}\n ${why}`);
186
+ } else {
187
+ console.log(`pass ${invariant.id} — ${invariant.states}`);
188
+ }
189
+ }
190
+ if (failed) {
191
+ console.error(`\n${failed} of ${INVARIANTS.length} deduplication assertions failed`);
192
+ return 1;
193
+ }
194
+ console.log(`\nOK: ${INVARIANTS.length} deduplication assertions hold against the emitter`);
195
+ return 0;
196
+ }
197
+
198
+ async function selfTest() {
199
+ const problems = [];
200
+ const matrix = [];
201
+ for (const invariant of INVARIANTS) {
202
+ const broke = [];
203
+ for (const rule of RULES) {
204
+ if (await runOne(invariant, [rule])) broke.push(rule);
205
+ }
206
+ matrix.push({ id: invariant.id, broke });
207
+ const expected = [...invariant.breaks].sort().join(', ');
208
+ const measured = [...broke].sort().join(', ');
209
+ if (expected !== measured) {
210
+ problems.push(`${invariant.id}: declares breaks [${expected || '-'}], measured [${measured || '-'}]`);
211
+ }
212
+ if (broke.length === 0) {
213
+ problems.push(`${invariant.id}: no mutant breaks it — the assertion cannot fail`);
214
+ }
215
+ }
216
+
217
+ const width = Math.max(...matrix.map((row) => row.id.length));
218
+ console.log(`\nassertion x mutant — ${RULES.length} mutants, `
219
+ + 'and which removed rule turns each assertion red\n');
220
+ for (const row of matrix) {
221
+ console.log(` ${row.id.padEnd(width)} ${row.broke.join(', ') || '(nothing)'}`);
222
+ }
223
+
224
+ console.log('\nisolation — the fixture that separates each rule from every other\n');
225
+ for (const rule of RULES) {
226
+ const isolating = matrix.filter((row) => row.broke.length === 1 && row.broke[0] === rule);
227
+ if (isolating.length) {
228
+ console.log(` ${rule.padEnd(20)} ${isolating.map((r) => r.id).join(', ')}`);
229
+ } else {
230
+ problems.push(`no fixture isolates ${rule} — every assertion it breaks is also broken `
231
+ + 'by another rule, so neither is proven');
232
+ }
233
+ }
234
+
235
+ if (problems.length) {
236
+ console.error(`\nFAIL: ${problems.length} problem(s) with the assertion pack itself`);
237
+ for (const p of problems) console.error(` - ${p}`);
238
+ return 1;
239
+ }
240
+ console.log(`\nOK: ${INVARIANTS.length} assertions, each watched failing; `
241
+ + `${RULES.length} rules, each isolated by a fixture`);
242
+ return 0;
243
+ }
244
+
245
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
246
+ if (isMain) {
247
+ const mode = process.argv.includes('--self-test') ? selfTest : runAll;
248
+ process.exit(await mode());
249
+ }
@@ -0,0 +1,75 @@
1
+ {
2
+ "skill": "ad-tracking",
3
+ "assertionPack": "assert-dedup-contract.mjs",
4
+ "referenceHandler": "reference-emitter.mjs",
5
+ "runbook": "README.md",
6
+ "note": "The single home of the invariant -> fixture -> document mapping. test/validate.py in this repository reads it: every fixture must be claimed by a row, every row's fixtures must exist, and every claiming document must still carry its phrase, name the invariant id, and name a path under fixtures/. A claim whose fixture is gone and a fixture nobody claims are both failures.",
7
+ "invariants": [
8
+ {
9
+ "id": "pixel-and-capi-carry-one-event-id",
10
+ "isolates": "shared-event-id",
11
+ "fixtures": ["purchase-pixel-browser.json", "purchase-capi-server.json"],
12
+ "claimedBy": [
13
+ {
14
+ "document": "SKILL.md",
15
+ "phrase": "carry the SAME `event_id` / `transaction_id`, or both are counted."
16
+ }
17
+ ]
18
+ },
19
+ {
20
+ "id": "pixel-and-capi-carry-one-event-name",
21
+ "isolates": "exact-event-name",
22
+ "fixtures": ["purchase-pixel-browser.json", "purchase-capi-server.json"],
23
+ "claimedBy": [
24
+ {
25
+ "document": "references/meta-linkedin.md",
26
+ "phrase": "**Two fields must match, not one.**"
27
+ }
28
+ ]
29
+ },
30
+ {
31
+ "id": "no-purchase-reported-before-the-charge-cleared",
32
+ "isolates": "webhook-sourced",
33
+ "fixtures": ["purchase-capi-from-thank-you-page.json"],
34
+ "claimedBy": [
35
+ {
36
+ "document": "SKILL.md",
37
+ "phrase": "**A purchase is not a browser event, and that decides where it comes from.**"
38
+ }
39
+ ]
40
+ },
41
+ {
42
+ "id": "the-browser-event-is-kept",
43
+ "isolates": "keep-browser-event",
44
+ "fixtures": ["purchase-pixel-browser.json"],
45
+ "claimedBy": [
46
+ {
47
+ "document": "SKILL.md",
48
+ "phrase": "**The browser event stays, and it stays subordinate.**"
49
+ }
50
+ ]
51
+ },
52
+ {
53
+ "id": "identifiers-reach-the-server-hashed",
54
+ "isolates": "hashed-identifiers",
55
+ "fixtures": ["purchase-capi-server.json"],
56
+ "claimedBy": [
57
+ {
58
+ "document": "references/meta-linkedin.md",
59
+ "phrase": "SHA-256 does **not** make a value safe to send"
60
+ }
61
+ ]
62
+ },
63
+ {
64
+ "id": "the-shipped-capi-fixture-is-what-a-correct-emitter-sends",
65
+ "isolates": null,
66
+ "fixtures": ["purchase-capi-server.json", "purchase-pixel-browser.json"],
67
+ "claimedBy": [
68
+ {
69
+ "document": "references/meta-linkedin.md",
70
+ "phrase": "the fixtures are the reference output rather than an illustration"
71
+ }
72
+ ]
73
+ }
74
+ ]
75
+ }
@@ -0,0 +1,32 @@
1
+ {
2
+ "data": [
3
+ {
4
+ "event_name": "Purchase",
5
+ "event_time": 1767312000,
6
+ "event_id": "evtid_PLACEHOLDER_purchase_bob",
7
+ "event_source_url": "https://example.invalid/after",
8
+ "action_source": "website",
9
+ "user_data": {
10
+ "em": [
11
+ "34aeb9fb3be8b407bdd0beb712d571974bbddbb5bfc0e41cb666331ee5fcc47b"
12
+ ],
13
+ "external_id": [
14
+ "usr_PLACEHOLDER_bob"
15
+ ],
16
+ "client_ip_address": "203.0.113.10",
17
+ "client_user_agent": "Mozilla/5.0 (PLACEHOLDER user agent)",
18
+ "fbp": "fb.1.1767312000.9876543210",
19
+ "fbc": "fb.1.1767312000.PLACEHOLDERCLICKID"
20
+ },
21
+ "custom_data": {
22
+ "value": 29,
23
+ "currency": "USD",
24
+ "content_ids": [
25
+ "prod_PLACEHOLDER_pro"
26
+ ],
27
+ "content_type": "product",
28
+ "num_items": 1
29
+ }
30
+ }
31
+ ]
32
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "data": [
3
+ {
4
+ "event_name": "Purchase",
5
+ "event_time": 1767398400,
6
+ "event_id": "evtid_PLACEHOLDER_purchase_carol",
7
+ "event_source_url": "https://example.invalid/after",
8
+ "action_source": "website",
9
+ "user_data": {
10
+ "em": ["34aeb9fb3be8b407bdd0beb712d571974bbddbb5bfc0e41cb666331ee5fcc47b"],
11
+ "external_id": ["usr_PLACEHOLDER_carol"],
12
+ "client_ip_address": "203.0.113.10",
13
+ "client_user_agent": "Mozilla/5.0 (PLACEHOLDER user agent)",
14
+ "fbp": "fb.1.1767398400.1234567890",
15
+ "fbc": "fb.1.1767398400.PLACEHOLDERCLICKID"
16
+ },
17
+ "custom_data": {
18
+ "value": 29.0,
19
+ "currency": "USD",
20
+ "content_ids": ["prod_PLACEHOLDER_pro"],
21
+ "content_type": "product",
22
+ "num_items": 1
23
+ }
24
+ }
25
+ ]
26
+ }
@@ -0,0 +1,12 @@
1
+ [
2
+ "track",
3
+ "Purchase",
4
+ {
5
+ "value": 29.0,
6
+ "currency": "USD",
7
+ "content_ids": ["prod_PLACEHOLDER_pro"],
8
+ "content_type": "product",
9
+ "num_items": 1
10
+ },
11
+ { "eventID": "evtid_PLACEHOLDER_purchase_carol" }
12
+ ]