@ssheleg/sheleg-dev 0.6.0 → 0.8.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 (38) hide show
  1. package/CHANGELOG.md +318 -0
  2. package/README.md +115 -1
  3. package/SECURITY.md +199 -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 +7 -3
  11. package/plugins/sheleg-dev/skills/ad-tracking/fixtures/README.md +77 -0
  12. package/plugins/sheleg-dev/skills/ad-tracking/fixtures/assert-dedup-contract.mjs +397 -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/meta-linkedin.md +17 -1
  19. package/plugins/sheleg-dev/skills/crypto-payments/SKILL.md +82 -77
  20. package/plugins/sheleg-dev/skills/crypto-payments/references/callback-route-hardening.md +55 -0
  21. package/plugins/sheleg-dev/skills/crypto-payments/references/heleket-provider.md +349 -10
  22. package/plugins/sheleg-dev/skills/crypto-payments/references/testing-and-local-dev.md +60 -0
  23. package/plugins/sheleg-dev/skills/stripe-billing/SKILL.md +1 -1
  24. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/README.md +111 -0
  25. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/assert-money-invariants.mjs +564 -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/testing-and-local-dev.md +39 -2
  38. package/plugins/sheleg-dev/skills/stripe-billing/references/webhook-events.md +43 -1
@@ -0,0 +1,77 @@
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 invariants a correct emitter must hold — 12 assertions between them — and a
7
+ self-test that breaks the emitter one rule at a time so you can watch each assertion fail
8
+ on its own.
9
+
10
+ ```bash
11
+ node assert-dedup-contract.mjs # 6 invariants / 12 assertions
12
+ node assert-dedup-contract.mjs --self-test # break one rule at a time; EACH ASSERTION must go red
13
+ ```
14
+
15
+ No dependencies, no network, **no access token anywhere in this directory** — the sink
16
+ collects what would have been sent.
17
+
18
+ ## Contents
19
+
20
+ - [The fixtures](#the-fixtures)
21
+ - [The assertions](#the-assertions)
22
+ - [What each mutant deletes](#what-each-mutant-deletes)
23
+ - [Why the browser side is read from the fixture](#why-the-browser-side-is-read-from-the-fixture)
24
+ - [Placeholders](#placeholders)
25
+
26
+ ## The fixtures
27
+
28
+ | File | What it is |
29
+ |---|---|
30
+ | `purchase-pixel-browser.json` | the four arguments of the browser call: `fbq('track', 'Purchase', custom_data, { eventID })` |
31
+ | `purchase-capi-server.json` | the Conversions API request body the **webhook** sends for the same purchase |
32
+ | `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 |
33
+
34
+ The first two carry the same `event_id` and the byte-equal `event_name`, which is the
35
+ contract. The third is the defect, recorded rather than described: it was produced by
36
+ running the reference emitter with `webhook-sourced` removed.
37
+
38
+ ## The assertions
39
+
40
+ | Assertion | What a wrong emitter does |
41
+ |---|---|
42
+ | `pixel-and-capi-carry-one-event-id` | generates an id per emission, so Meta counts one purchase twice |
43
+ | `pixel-and-capi-carry-one-event-name` | sends `Purchase` from the browser and `purchase` from the server: a shared id that deduplicates nothing |
44
+ | `no-purchase-reported-before-the-charge-cleared` | reports the purchase from the one place that cannot know the money moved |
45
+ | `the-browser-event-is-kept` | drops the browser event, losing the click id, the consent state and the session |
46
+ | `identifiers-reach-the-server-hashed` | puts the address itself in `user_data.em` |
47
+ | `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 |
48
+
49
+ ## What each mutant deletes
50
+
51
+ | Rule | The code it removes | The integration that ships without it |
52
+ |---|---|---|
53
+ | `shared-event-id` | reading the id written once at session creation | `crypto.randomUUID()` at each emission |
54
+ | `exact-event-name` | the one `event_name` constant used by both sides | `'Purchase'` in the browser, a lowercased variant on the server |
55
+ | `webhook-sourced` | the gate that keeps the thank-you page from reporting | fires the conversion where the redirect lands |
56
+ | `keep-browser-event` | the pixel call | server-only, and blind to everything the browser carries |
57
+ | `hashed-identifiers` | the SHA-256 of the identifier | hands the raw address to the wrapper, which hashes whatever it is given |
58
+
59
+ ## Why the browser side is read from the fixture
60
+
61
+ `pixel-and-capi-carry-one-event-id` and `pixel-and-capi-carry-one-event-name` compare the
62
+ server's output against `purchase-pixel-browser.json` rather than against the pixel call the
63
+ same emitter just made. That is deliberate, and it is the correction of a first pass:
64
+ reading both sides out of one emitter meant deleting `keep-browser-event` also turned those
65
+ two assertions red, so neither the id rule nor the name rule was proven on its own. Two
66
+ mechanisms that both explain a passing fixture leave both untested. The fixture is the
67
+ browser's contract, held at a boundary the server does not control.
68
+
69
+ `--self-test` fails if any rule has no fixture that isolates it, so this stays true.
70
+
71
+ ## Placeholders
72
+
73
+ `usr_PLACEHOLDER_carol`, `prod_PLACEHOLDER_pro`, `fb.1.…PLACEHOLDERCLICKID`,
74
+ `203.0.113.10` (a documentation address). The email is `placeholder@example.invalid` and
75
+ the 64-hex string is its SHA-256 — a hash of a published placeholder, not of a person. No
76
+ pixel id, no dataset id and no access token appear here: the token is a runtime secret and
77
+ `references/performance-security.md` is where it belongs.
@@ -0,0 +1,397 @@
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, assert }) {
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, assert }) {
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, assert }) {
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, assert }) {
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
+ // `|| []` on purpose: with no browser event at all this must FAIL rather than throw a
134
+ // TypeError, because an assertion the runner never reached is one no mutant has been
135
+ // watched breaking — which is how it sat unmeasured until 2026-08-20.
136
+ assert.equal((sink.pixel[0] || [])[0], 'track',
137
+ 'the browser event is not a `track` call — the pixel contract is `fbq(\'track\', …)`');
138
+ },
139
+ },
140
+ {
141
+ id: 'identifiers-reach-the-server-hashed',
142
+ fixtures: [CAPI_SERVER],
143
+ states: 'advanced matching sends hashes, never the address itself',
144
+ breaks: ['hashed-identifiers'],
145
+ run({ sink, emitter, assert }) {
146
+ runPurchase({ sink, emitter }, CLEARED());
147
+ const userData = sink.capi[0].data[0].user_data;
148
+ for (const value of userData.em) {
149
+ assert.match(value, /^[0-9a-f]{64}$/,
150
+ `user_data.em carried ${JSON.stringify(value)} — a raw identifier, not a sha256`);
151
+ }
152
+ assert.ok(!JSON.stringify(userData).includes('@'),
153
+ 'user_data carries something with an @ in it');
154
+ },
155
+ },
156
+ {
157
+ id: 'the-shipped-capi-fixture-is-what-a-correct-emitter-sends',
158
+ fixtures: [CAPI_SERVER, PIXEL_BROWSER],
159
+ states: 'the fixtures are the reference output, byte for byte — not an illustration',
160
+ // Broad by construction: it compares the whole body, so every rule that changes any
161
+ // field turns it red. It is here so a reader can trust the fixtures they copy.
162
+ breaks: ['shared-event-id', 'exact-event-name', 'hashed-identifiers', 'keep-browser-event'],
163
+ run({ sink, emitter, assert }) {
164
+ runPurchase({ sink, emitter }, CLEARED());
165
+ assert.deepEqual(sink.capi[0], fixture(CAPI_SERVER));
166
+ assert.deepEqual(sink.pixel[0], fixture(PIXEL_BROWSER));
167
+ },
168
+ },
169
+ ];
170
+
171
+ // ------------------------------------------------- one assertion, one measurement
172
+ //
173
+ // Why the assertions are not called straight through `node:assert`. Until 2026-08-20 the
174
+ // runner returned on the FIRST throw and the matrix compared one row per invariant, so an
175
+ // invariant was measured as a single unit: any assertion that was not the sole
176
+ // discriminator could be replaced by `assert.ok(true)` and nothing went red. Measured that
177
+ // day in this pack too — the PII guard below (*"user_data carries something with an @ in
178
+ // it"*, advertised by name at `references/meta-linkedin.md`) was neutered and
179
+ // `--self-test` stayed at exit 0, because the `assert.match` above it discriminated the
180
+ // same rule.
181
+ //
182
+ // So the `assert` an invariant receives is a RECORDER: same API, but a failure is
183
+ // remembered instead of thrown and the assertions after it still run. Each call site is one
184
+ // assertion with its own identity — its line in this file — and `--self-test` asks the
185
+ // question per assertion rather than per invariant.
186
+ //
187
+ // `assert.unmutated.equal(...)` marks an assertion NO mutant in this pack varies: it pins
188
+ // something the rules do not touch, so it has never been watched failing and the verdict
189
+ // counts it apart rather than as evidence. Measured, not asserted — the self-test fails
190
+ // both when a plain assertion turns out unbreakable and when an `unmutated` one turns out
191
+ // breakable.
192
+ //
193
+ // Duplicated from `stripe-billing/fixtures/assert-money-invariants.mjs` for the reason
194
+ // given at the top: a reader installs one skill, not six.
195
+
196
+ const SELF = import.meta.url;
197
+ const METHODS = ['equal', 'notEqual', 'deepEqual', 'notDeepEqual', 'ok', 'match', 'doesNotMatch'];
198
+
199
+ /**
200
+ * The line in THIS file that called the recorder.
201
+ *
202
+ * `captureStackTrace(holder, boundary)` drops `boundary` and everything above it, so the
203
+ * top frame is the assertion's own line — no frame counting, which is what makes the
204
+ * identity survive a refactor of the recorder itself.
205
+ */
206
+ function siteOf(boundary) {
207
+ const holder = {};
208
+ Error.captureStackTrace(holder, boundary);
209
+ const frame = (holder.stack || '').split('\n').slice(1).find((f) => f.includes(SELF));
210
+ const m = frame && frame.match(/:(\d+):\d+\)?\s*$/);
211
+ return m ? Number(m[1]) : 0;
212
+ }
213
+
214
+ function recorder() {
215
+ const seen = new Map(); // line -> { site, unmutated, ok, why }
216
+
217
+ function record(site, unmutated, name, args) {
218
+ let ok = true;
219
+ let why = null;
220
+ try {
221
+ assert[name](...args);
222
+ } catch (error) {
223
+ ok = false;
224
+ why = error.message.split('\n')[0];
225
+ }
226
+ const prev = seen.get(site);
227
+ if (prev) {
228
+ prev.ok = prev.ok && ok;
229
+ prev.why = prev.why || why;
230
+ } else {
231
+ seen.set(site, { site, unmutated, ok, why });
232
+ }
233
+ }
234
+
235
+ const bind = (unmutated) => {
236
+ const api = {};
237
+ for (const name of METHODS) {
238
+ const call = (...args) => record(siteOf(call), unmutated, name, args);
239
+ api[name] = call;
240
+ }
241
+ return api;
242
+ };
243
+
244
+ const api = bind(false);
245
+ api.unmutated = bind(true);
246
+ return { api, seen };
247
+ }
248
+
249
+ // ------------------------------------------------------------------------- runners
250
+
251
+ async function runOne(invariant, without) {
252
+ const { api, seen } = recorder();
253
+ const { sink, emitter } = harness(without);
254
+ let threw = null;
255
+ try {
256
+ await invariant.run({ sink, emitter, assert: api });
257
+ } catch (error) {
258
+ // A real error — a read off a row the mutant never wrote, say. It aborts the remaining
259
+ // assertions, and it is still the invariant going red.
260
+ threw = error.message.split('\n')[0];
261
+ }
262
+ const sites = [...seen.values()];
263
+ return {
264
+ threw,
265
+ sites,
266
+ failed: sites.filter((s) => !s.ok),
267
+ broke: Boolean(threw) || sites.some((s) => !s.ok),
268
+ };
269
+ }
270
+
271
+ async function runAll() {
272
+ let failed = 0;
273
+ let asserted = 0;
274
+ for (const invariant of INVARIANTS) {
275
+ const result = await runOne(invariant, []);
276
+ asserted += result.sites.length;
277
+ if (result.broke) {
278
+ failed += 1;
279
+ console.error(`FAIL ${invariant.id}`);
280
+ for (const s of result.failed) console.error(` :${s.site} ${s.why}`);
281
+ if (result.threw) console.error(` threw: ${result.threw}`);
282
+ } else {
283
+ console.log(`pass ${invariant.id} (${result.sites.length} assertions) — ${invariant.states}`);
284
+ }
285
+ }
286
+ if (failed) {
287
+ console.error(`\n${failed} of ${INVARIANTS.length} deduplication assertions failed`);
288
+ return 1;
289
+ }
290
+ console.log(`\nOK: ${INVARIANTS.length} deduplication invariants hold against the emitter `
291
+ + `(${asserted} assertions)`);
292
+ return 0;
293
+ }
294
+
295
+ async function selfTest() {
296
+ const problems = [];
297
+ const matrix = [];
298
+ const coverage = []; // one row per ASSERTION: which mutants turn that call site red
299
+ for (const invariant of INVARIANTS) {
300
+ // The clean run is the census: it names every assertion this invariant executes. One
301
+ // that records nothing has a run() which never took the recorder, so nothing about it
302
+ // is measured — the failure mode of the injection itself.
303
+ const clean = await runOne(invariant, []);
304
+ if (clean.broke) {
305
+ problems.push(`${invariant.id}: fails against the unmutated emitter `
306
+ + `(${(clean.failed[0] && `:${clean.failed[0].site} ${clean.failed[0].why}`) || clean.threw})`);
307
+ }
308
+ if (!clean.sites.length) {
309
+ problems.push(`${invariant.id}: recorded no assertion — its run() does not take the `
310
+ + '`assert` the runner passes it, so nothing about it is measured');
311
+ }
312
+ const broken = new Map(clean.sites.map((s) => [s.site, []]));
313
+
314
+ const broke = [];
315
+ for (const rule of RULES) {
316
+ const r = await runOne(invariant, [rule]);
317
+ if (r.broke) broke.push(rule);
318
+ for (const s of r.failed) if (broken.has(s.site)) broken.get(s.site).push(rule);
319
+ }
320
+ matrix.push({ id: invariant.id, broke });
321
+
322
+ for (const site of clean.sites) {
323
+ coverage.push({
324
+ invariant: invariant.id,
325
+ site: site.site,
326
+ unmutated: site.unmutated,
327
+ by: broken.get(site.site) || [],
328
+ });
329
+ }
330
+
331
+ const expected = [...invariant.breaks].sort().join(', ');
332
+ const measured = [...broke].sort().join(', ');
333
+ if (expected !== measured) {
334
+ problems.push(`${invariant.id}: declares breaks [${expected || '-'}], measured [${measured || '-'}]`);
335
+ }
336
+ if (broke.length === 0) {
337
+ problems.push(`${invariant.id}: no mutant breaks it — the assertion cannot fail`);
338
+ }
339
+ }
340
+
341
+ const width = Math.max(...matrix.map((row) => row.id.length));
342
+ console.log(`\ninvariant x mutant — ${RULES.length} mutants, `
343
+ + 'and which removed rule turns each invariant red\n');
344
+ for (const row of matrix) {
345
+ console.log(` ${row.id.padEnd(width)} ${row.broke.join(', ') || '(nothing)'}`);
346
+ }
347
+
348
+ // The per-ASSERTION half, and the one the table above cannot give: an invariant goes red
349
+ // as a unit, so a dead assertion inside a live one is invisible there.
350
+ console.log('\nassertion x mutant — every call site, and what turns IT red\n');
351
+ for (const row of coverage) {
352
+ const label = `${row.invariant}:${row.site}`.padEnd(width + 6);
353
+ if (row.unmutated) {
354
+ console.log(` ${label} unmutated — no rule varies what it checks`);
355
+ if (row.by.length) {
356
+ problems.push(`${row.invariant} :${row.site} is declared assert.unmutated and `
357
+ + `[${row.by.join(', ')}] breaks it — it discriminates, so drop the .unmutated`);
358
+ }
359
+ } else {
360
+ console.log(` ${label} ${row.by.join(', ') || '(nothing)'}`);
361
+ if (!row.by.length) {
362
+ problems.push(`${row.invariant} :${row.site} is broken by no mutant — the assertion `
363
+ + 'cannot fail. Either it is not testing anything, or no rule varies what it '
364
+ + 'checks and it belongs on assert.unmutated, which the verdict counts apart');
365
+ }
366
+ }
367
+ }
368
+
369
+ console.log('\nisolation — the fixture that separates each rule from every other\n');
370
+ for (const rule of RULES) {
371
+ const isolating = matrix.filter((row) => row.broke.length === 1 && row.broke[0] === rule);
372
+ if (isolating.length) {
373
+ console.log(` ${rule.padEnd(20)} ${isolating.map((r) => r.id).join(', ')}`);
374
+ } else {
375
+ problems.push(`no fixture isolates ${rule} — every assertion it breaks is also broken `
376
+ + 'by another rule, so neither is proven');
377
+ }
378
+ }
379
+
380
+ if (problems.length) {
381
+ console.error(`\nFAIL: ${problems.length} problem(s) with the assertion pack itself`);
382
+ for (const p of problems) console.error(` - ${p}`);
383
+ return 1;
384
+ }
385
+ const pinned = coverage.filter((r) => r.unmutated).length;
386
+ console.log(`\nOK: ${INVARIANTS.length} invariants over ${coverage.length} assertions — `
387
+ + `${coverage.length - pinned} watched failing ONE CALL SITE AT A TIME against `
388
+ + `${RULES.length} mutants; ${pinned} declared unmutated and measured unbreakable; `
389
+ + `${RULES.length} rules, each isolated by a fixture`);
390
+ return 0;
391
+ }
392
+
393
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
394
+ if (isMain) {
395
+ const mode = process.argv.includes('--self-test') ? selfTest : runAll;
396
+ process.exit(await mode());
397
+ }
@@ -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
+ ]