@precedence-dev/instrument 0.1.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.
@@ -0,0 +1,665 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.PM_ID_KEY = void 0;
40
+ exports.planFiles = planFiles;
41
+ exports.safeStatement = safeStatement;
42
+ exports.reanchor = reanchor;
43
+ exports.instrument = instrument;
44
+ exports.instrumentFile = instrumentFile;
45
+ exports.planDelegations = planDelegations;
46
+ exports.buildDelegatedModule = buildDelegatedModule;
47
+ /**
48
+ * The instrumenter core. Given the analysed source + a tracking plan (the events
49
+ * a PM defined in the picker / viewer), work out where a `track(…)` call goes
50
+ * and splice it in.
51
+ *
52
+ * Two entry points share one resolver:
53
+ * instrument(inputs, plan, opts) multi-file: the CLI codemod (writes to disk)
54
+ * instrumentFile(code, id, plan, opts) one module: the bundler transform (src/generate/unplugin.ts)
55
+ *
56
+ * It re-runs the same `@precedence-dev/cli` `analyze()` the catalog is built from,
57
+ * so every id / `InjectSite` / fingerprint is produced by one code path — this
58
+ * package never re-derives that analysis itself, it only asks the (private)
59
+ * analysis engine to reproduce it and applies the resulting edits. Edits go
60
+ * through MagicString, so callers get a sourcemap.
61
+ *
62
+ * Three outcomes per anchor, not two:
63
+ * applied id resolved, fingerprint matches
64
+ * applied+drift id resolved but the handler changed since the plan; injected, with a warning
65
+ * skipped id does not resolve; the message says precisely what is missing
66
+ */
67
+ const ts = __importStar(require("typescript"));
68
+ const magic_string_1 = __importDefault(require("magic-string"));
69
+ const build_1 = require("@precedence-dev/cli/build");
70
+ const ast_1 = require("@precedence-dev/cli/ast");
71
+ const parse_1 = require("@precedence-dev/cli/parse");
72
+ /** where a handler's own `track(...)` call goes, from its body shape. */
73
+ function handlerSite(body, synthetic) {
74
+ if (synthetic)
75
+ return { kind: "delegated" };
76
+ if (body && ts.isBlock(body))
77
+ return { kind: "block-prepend", block: body };
78
+ if (body)
79
+ return { kind: "wrap-expr", expr: body };
80
+ return { kind: "none" };
81
+ }
82
+ /** index every branch (recursively) under one element by its structural id. */
83
+ function indexBranches(map, L, el, bs) {
84
+ for (const b of bs) {
85
+ map.set(b.id, {
86
+ id: b.id, file: (0, build_1.norm)(L.file), line: b.anchorLine || el.line + 1, sf: L.sf,
87
+ element: el.tag, site: b.site, fingerprint: b.fingerprint, elementNode: el.scopeNode,
88
+ });
89
+ indexBranches(map, L, el, b.children || []);
90
+ }
91
+ }
92
+ function attachIndex(loaded) {
93
+ const map = new Map();
94
+ for (const L of loaded) {
95
+ for (const el of L.elements) {
96
+ for (const h of el.handlers) {
97
+ const an = L.handlers.get(h);
98
+ if (!an)
99
+ continue;
100
+ map.set(h.actionId, {
101
+ id: h.actionId, file: (0, build_1.norm)(L.file), line: el.line + 1, sf: L.sf,
102
+ element: el.tag, autoAttrs: h.autoProps || [], fingerprint: h.fingerprint,
103
+ elementNode: el.scopeNode,
104
+ site: handlerSite(h.body, h.synthetic),
105
+ });
106
+ indexBranches(map, L, el, an.branches);
107
+ }
108
+ }
109
+ }
110
+ return map;
111
+ }
112
+ /** the id shape the previous (positional) catalog produced, `…tsx:12:5|…` */
113
+ function isPositionalId(id) {
114
+ return /:\d+:\d+(\||$)/.test(id.split("|")[0]);
115
+ }
116
+ /** the source-file portion of an anchor id, `src/x.tsx#Comp::slot|…` → `src/x.tsx` */
117
+ function anchorFile(id) {
118
+ return (0, build_1.norm)(id.split(/[#|]/)[0]);
119
+ }
120
+ /** every distinct source file a plan references */
121
+ function planFiles(plan) {
122
+ const set = new Set();
123
+ for (const ev of plan.events)
124
+ for (const a of ev.anchors) {
125
+ const f = anchorFile(a.id);
126
+ if (f && !/:\d+:\d+/.test(f))
127
+ set.add(f);
128
+ }
129
+ return [...set];
130
+ }
131
+ /** which plan file (if any) a bundler module id corresponds to */
132
+ function matchPlanFile(id, plan) {
133
+ const clean = (0, build_1.norm)(id.split("?")[0]);
134
+ for (const f of planFiles(plan))
135
+ if (clean === f || clean.endsWith("/" + f))
136
+ return f;
137
+ return null;
138
+ }
139
+ /** why an id failed to resolve, walk the structural id as far as the source allows */
140
+ function diagnoseMiss(id, loaded, index) {
141
+ const [refPart, event, path] = id.split("|");
142
+ const m = refPart.match(/^(.+)#([^#]*)::(.+)$/);
143
+ if (!m)
144
+ return "unrecognised anchor id; re-export the plan from the picker";
145
+ const [, file, comp, slot] = m;
146
+ const L = loaded.find((x) => (0, build_1.norm)(x.file) === (0, build_1.norm)(file) || (0, build_1.norm)(x.file).endsWith("/" + (0, build_1.norm)(file)));
147
+ if (!L)
148
+ return `file "${file}" was not among the scanned sources`;
149
+ const comps = [...new Set(L.elements.map((e) => e.component || "_"))];
150
+ if (!comps.includes(comp))
151
+ return `component "${comp}" not in ${file} (has: ${comps.join(", ")}); if it was renamed, \`precedence-instrument --reanchor\` can repair the plan`;
152
+ const slots = L.elements.filter((e) => (e.component || "_") === comp).map((e) => e.ref.split("::")[1]);
153
+ if (!slots.includes(slot))
154
+ return `element "${slot}" not in <${comp}> (has: ${slots.join(", ") || "none"})`;
155
+ const elem = L.elements.find((e) => e.ref === refPart || (0, build_1.norm)(e.ref).endsWith("/" + refPart));
156
+ if (elem && !elem.handlers.some((h) => h.name === event))
157
+ return `no "${event}" handler on ${slot} (has: ${elem.handlers.map((h) => h.name).join(", ") || "none"})`;
158
+ if (path) {
159
+ const prefix = `${refPart}|${event}|`;
160
+ const sibs = [...index.keys()].filter((k) => k.startsWith(prefix)).map((k) => k.slice(prefix.length));
161
+ return `branch "${path}" not found (handler has: ${sibs.join(", ") || "no branches"})`;
162
+ }
163
+ return "attach point not found; the catalog is stale, regenerate it";
164
+ }
165
+ /* -------------------------------------------------------------- the call */
166
+ function obj(parts) {
167
+ return parts.length ? `, { ${parts.join(", ")} }` : "";
168
+ }
169
+ /** the nearest enclosing component function of a node */
170
+ function nearestComponent(node) {
171
+ for (let n = node?.parent; n; n = n.parent) {
172
+ if (ts.isFunctionDeclaration(n) && n.name && /^[A-Z]/.test(n.name.text))
173
+ return n;
174
+ if ((ts.isArrowFunction(n) || ts.isFunctionExpression(n)) && ts.isVariableDeclaration(n.parent)
175
+ && ts.isIdentifier(n.parent.name) && /^[A-Z]/.test(n.parent.name.text))
176
+ return n;
177
+ }
178
+ return null;
179
+ }
180
+ /** resolve a context field to an expression, using a binding the component
181
+ * already has. No auto-hoist: if the hook is not bound, warn and emit undefined. */
182
+ function resolveContextAccessor(ap, via, out, evName) {
183
+ const comp = nearestComponent(ap.elementNode);
184
+ if (!comp)
185
+ return `undefined /* no component around this anchor */`;
186
+ let bare = null;
187
+ let destructured = false;
188
+ const head = via.path.split(".")[0];
189
+ (function w(n) {
190
+ if (ts.isVariableDeclaration(n) && n.initializer && ts.isCallExpression(n.initializer)
191
+ && ts.isIdentifier(n.initializer.expression) && n.initializer.expression.text === via.hook) {
192
+ if (ts.isIdentifier(n.name))
193
+ bare = n.name.text;
194
+ else if (ts.isObjectBindingPattern(n.name)) {
195
+ destructured = destructured || n.name.elements.some((e) => {
196
+ const k = e.propertyName && ts.isIdentifier(e.propertyName) ? e.propertyName.text
197
+ : ts.isIdentifier(e.name) ? e.name.text : "";
198
+ return k === head;
199
+ });
200
+ }
201
+ }
202
+ ts.forEachChild(n, w);
203
+ })(comp);
204
+ if (destructured)
205
+ return via.path; // `isAuthed` already in scope
206
+ if (bare)
207
+ return `${bare}.${via.path}`; // `auth.isAuthed`
208
+ out.warnings.push({
209
+ event: evName, id: ap.id,
210
+ detail: `"${via.path}" needs \`${via.hook}()\` bound in the component, add \`const _ = ${via.hook}()\` there, or drop the prop`,
211
+ });
212
+ return `undefined /* ${via.hook}() not bound in this component */`;
213
+ }
214
+ /** how to render one property in the call object */
215
+ function propPart(p, ev, anchor) {
216
+ const stat = anchor.staticProps || {};
217
+ if (p in stat)
218
+ return `${p}: ${JSON.stringify(stat[p])}`;
219
+ const acc = ev.accessors && ev.accessors[p];
220
+ if (typeof acc === "string")
221
+ return acc === p ? p : `${p}: ${acc}`;
222
+ if ((anchor.missingProps || []).includes(p))
223
+ return `${p}: undefined /* not in scope here */`;
224
+ return p; // shorthand, the picker checked it resolves
225
+ }
226
+ /** every emitted event carries the anchor id under this key, so a value seen in
227
+ * the dashboard can be traced back to the exact fire site (file/component/branch). */
228
+ exports.PM_ID_KEY = "pm_id";
229
+ /** direct mode: `track("event_name", { pm_id: "<id>", …everything baked in })` */
230
+ function directCall(fnName, ev, anchor) {
231
+ const seen = new Set();
232
+ const parts = [...new Set(ev.properties)].map((p) => { seen.add(p); return propPart(p, ev, anchor); });
233
+ Object.keys(anchor.staticProps || {}).forEach((k) => { if (!seen.has(k))
234
+ parts.push(`${k}: ${JSON.stringify((anchor.staticProps || {})[k])}`); });
235
+ if (!seen.has(exports.PM_ID_KEY) && !(exports.PM_ID_KEY in (anchor.staticProps || {})))
236
+ parts.unshift(`${exports.PM_ID_KEY}: ${JSON.stringify(anchor.id)}`);
237
+ return `${fnName}(${JSON.stringify(ev.name)}${obj(parts)})`;
238
+ }
239
+ /** runtime mode: `globalThis.__pm?.("<id>", { …in-scope + ambient props })`.
240
+ * the id is arg 1 — the runtime layers it onto the payload as `pm_id` at emit
241
+ * time, along with static / discriminator props from the plan. */
242
+ function runtimeCall(g, ev, anchor) {
243
+ const skip = new Set([...Object.keys(anchor.staticProps || {}), ...(anchor.missingProps || [])]);
244
+ const parts = [...new Set(ev.properties)].filter((p) => !skip.has(p)).map((p) => propPart(p, ev, anchor));
245
+ return `${g}?.(${JSON.stringify(anchor.id)}${obj(parts)})`;
246
+ }
247
+ function emitCall(ev, anchor, opts, fnName) {
248
+ return opts.emit === "runtime"
249
+ ? runtimeCall(opts.emitGlobal || "globalThis.__pm", ev, anchor)
250
+ : directCall(fnName, ev, anchor);
251
+ }
252
+ function indentAt(sf, pos) {
253
+ const lineStart = sf.text.lastIndexOf("\n", pos - 1) + 1;
254
+ return (sf.text.slice(lineStart, pos).match(/^\s*/) || [""])[0];
255
+ }
256
+ function enclosingStatement(n) {
257
+ let x = n;
258
+ while (x.parent && !ts.isStatement(x))
259
+ x = x.parent;
260
+ return x;
261
+ }
262
+ function assertNever(x) {
263
+ throw new Error("unhandled inject site: " + JSON.stringify(x));
264
+ }
265
+ /**
266
+ * Wrap the raw tracking call so it can never affect the surrounding control
267
+ * flow: a synchronous throw is caught and swallowed, and if it returns a
268
+ * thenable, a rejection is swallowed too — fire-and-forget, provably, not just
269
+ * assumed. This try/catch only ever wraps code we just inserted, never
270
+ * anything that existed before it, so — unlike wrapping a customer's own
271
+ * existing logic in a new try/catch — it cannot change what any pre-existing
272
+ * code does; there is no prior behavior here to disturb.
273
+ */
274
+ // exported for direct unit tests (test/invariants.mjs) — the fire-and-forget
275
+ // guarantee is checked by actually running the wrapped text, not by pattern
276
+ // matching the source of a full instrument() call.
277
+ function safeStatement(call) {
278
+ // Promise.resolve covers void / Promise / thenable clients and, unlike `.catch`
279
+ // on an inferred `void`, type-checks in a strict consumer project.
280
+ return `try { void Promise.resolve(${call}).catch(() => {}); } catch (__pmE) {}`;
281
+ }
282
+ /** the same guard, as a single expression — for sites where `call` has to sit
283
+ * inside a larger expression (a comma operator) rather than stand alone. */
284
+ function safeExpr(call) {
285
+ return `(() => { ${safeStatement(call)} })()`;
286
+ }
287
+ /** the one place a syntactic context meets an AST edit; total over InjectSite,
288
+ * so a new branch shape is a compile error here until it's handled. */
289
+ function planEdit(site, call, sf) {
290
+ switch (site.kind) {
291
+ case "block-prepend": {
292
+ const open = site.block.getStart(sf) + 1;
293
+ const ind = site.block.statements.length
294
+ ? indentAt(sf, site.block.statements[0].getStart(sf))
295
+ : indentAt(sf, site.block.getStart(sf)) + " ";
296
+ return [{ start: open, end: open, text: `\n${ind}${safeStatement(call)}` }];
297
+ }
298
+ case "before-stmt": {
299
+ const start = site.stmt.getStart(sf);
300
+ const ind = indentAt(sf, start);
301
+ return [{ start, end: start, text: `${safeStatement(call)}\n${ind}` }];
302
+ }
303
+ case "synthesize-else": {
304
+ const then = site.ifStmt.thenStatement;
305
+ const ind = indentAt(sf, site.ifStmt.getStart(sf));
306
+ return [{ start: then.getEnd(), end: then.getEnd(), text: ` else {\n${ind} ${safeStatement(call)}\n${ind}}` }];
307
+ }
308
+ case "split-and": {
309
+ const bin = site.expr;
310
+ const stmt = enclosingStatement(bin);
311
+ const parts = (0, ast_1.flattenLogical)(bin, "&&");
312
+ const guards = parts.slice(0, -1).map((p) => p.getText(sf)).join(" && ");
313
+ const effect = parts[parts.length - 1].getText(sf);
314
+ const ind = indentAt(sf, stmt.getStart(sf));
315
+ return [{
316
+ start: stmt.getStart(sf), end: stmt.getEnd(),
317
+ text: `if (${guards}) {\n${ind} ${safeStatement(call)}\n${ind} ${effect};\n${ind}}`,
318
+ }];
319
+ }
320
+ case "wrap-expr": {
321
+ const t = site.expr.getText(sf);
322
+ return [{ start: site.expr.getStart(sf), end: site.expr.getEnd(), text: `(${safeExpr(call)}, ${t})` }];
323
+ }
324
+ case "delegated":
325
+ case "none":
326
+ return [];
327
+ default:
328
+ return assertNever(site);
329
+ }
330
+ }
331
+ function parseTrack(track) {
332
+ const m = track.match(/^\s*([A-Za-z_$][\w$]*)\s+from\s+(.+?)\s*$/);
333
+ if (!m)
334
+ return { fnName: track.trim() || "track", importLine: "", spec: "" };
335
+ const spec = m[2].replace(/^['"]|['"]$/g, "");
336
+ return { fnName: m[1], importLine: `import { ${m[1]} } from ${JSON.stringify(spec)};\n`, spec };
337
+ }
338
+ /** add `import { track } from "spec"` unless the file already has it */
339
+ function addImport(s, code, track) {
340
+ const { fnName, spec } = parseTrack(track);
341
+ if (!spec)
342
+ return;
343
+ const re = new RegExp(`import\\s*\\{[^}]*\\b${fnName}\\b[^}]*\\}\\s*from\\s*['"]${spec.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}['"]`);
344
+ if (re.test(code))
345
+ return;
346
+ const line = `import { ${fnName} } from ${JSON.stringify(spec)};`;
347
+ // after the last `import … "spec";` (spans multi-line lists, matches side-effect imports)
348
+ const lastImp = [...code.matchAll(/^import\b[\s\S]*?['"][^'"]*['"]\s*;?/gm)].pop();
349
+ if (lastImp && lastImp.index != null)
350
+ s.appendLeft(lastImp.index + lastImp[0].length, "\n" + line);
351
+ else
352
+ s.prepend(line + "\n");
353
+ }
354
+ /** apply edits to one file via MagicString → { code, map } */
355
+ function render(code, edits, id, opts) {
356
+ const s = new magic_string_1.default(code);
357
+ for (const e of edits) {
358
+ if (e.start === e.end)
359
+ s.appendLeft(e.start, e.text);
360
+ else
361
+ s.overwrite(e.start, e.end, e.text);
362
+ }
363
+ // direct mode needs the `track` import, unless the only edits are `data-precedence-id`
364
+ // stamps. runtime mode calls a global, so it never needs one.
365
+ if (opts.emit !== "runtime" && edits.some((e) => e.kind !== "stamp"))
366
+ addImport(s, code, opts.track || "track");
367
+ return {
368
+ code: s.toString(),
369
+ map: s.generateMap({ source: id, hires: true, includeContent: true }),
370
+ };
371
+ }
372
+ /** resolve + verify + plan-edit one anchor, appending its results to `out`. */
373
+ function collectAnchor(a, ev, index, loaded, opts, fnName, out) {
374
+ const ap = resolveAnchor(a, ev, index, loaded, out, opts);
375
+ if (!ap)
376
+ return;
377
+ const drifted = recordDrift(a, ap, ev, out);
378
+ const evR = ev.accessors ? resolveEventAccessors(ev, ap, out) : ev;
379
+ const call = emitCall(evR, a, opts, fnName);
380
+ if (ap.site.kind === "delegated")
381
+ applyDelegated(a, ev, ap, out);
382
+ else
383
+ applyInlineEdit(a, ev, ap, call, drifted, out);
384
+ }
385
+ /** resolve + verify + plan-edit every plan anchor (optionally just one file's) */
386
+ function collect(plan, index, loaded, opts, fnName, onlyFile) {
387
+ const out = { byFile: new Map(), applied: [], skipped: [], warnings: [], delegated: [] };
388
+ for (const ev of plan.events) {
389
+ for (const a of ev.anchors) {
390
+ if (!onlyFile || anchorFile(a.id) === onlyFile) {
391
+ collectAnchor(a, ev, index, loaded, opts, fnName, out);
392
+ }
393
+ }
394
+ }
395
+ return out;
396
+ }
397
+ /** an anchor id, exactly or by ref tail, or null after pushing a precise `skipped`.
398
+ * No fuzzy matching: a dead id means the code the PM pointed at moved on, and
399
+ * that's for a human (`--reanchor` for a rename, the picker for a real refactor). */
400
+ const CONTINUATION_KEY = /^(RESOLVED|REJECTED|SETTLED)$/;
401
+ function resolveAnchor(a, ev, index, loaded, out, opts) {
402
+ const ap = index.get(a.id) || resolveLoosely(a.id, index);
403
+ if (ap)
404
+ return ap;
405
+ const isContinuation = CONTINUATION_KEY.test(a.fingerprint?.conditionKey || "")
406
+ || /\|(resolved|rejected|settled)$/.test(a.id);
407
+ out.skipped.push({
408
+ event: ev.name, id: a.id,
409
+ reason: isPositionalId(a.id)
410
+ ? "plan uses old positional ids; re-export from the picker against a current catalog"
411
+ : isContinuation && !opts.types
412
+ ? "continuation outcome (the callback edge rule) — re-run with --types so the callback classification is reproduced"
413
+ : diagnoseMiss(a.id, loaded, index),
414
+ });
415
+ return null;
416
+ }
417
+ /** warn when the branch's condition changed since the plan was authored.
418
+ * returns `true` if this anchor is drifted, else `undefined` (never `false`). */
419
+ function recordDrift(a, ap, ev, out) {
420
+ const fp = a.fingerprint;
421
+ if (fp && fp.conditionKey && ap.fingerprint.conditionKey && fp.conditionKey !== ap.fingerprint.conditionKey) {
422
+ out.warnings.push({ event: ev.name, id: a.id, detail: `branch condition changed since the plan: "${fp.conditionKey}" → "${ap.fingerprint.conditionKey}"; verify ${ap.file}:${ap.line}` });
423
+ }
424
+ return out.warnings.some((w) => w.id === a.id) || undefined;
425
+ }
426
+ /** resolve this event's context-field accessors against the anchor's component -> plain exprs */
427
+ function resolveEventAccessors(ev, ap, out) {
428
+ return {
429
+ ...ev,
430
+ accessors: Object.fromEntries(Object.entries(ev.accessors).map(([p, acc]) => [p, typeof acc === "string" ? acc : resolveContextAccessor(ap, acc, out, ev.name)])),
431
+ };
432
+ }
433
+ /** a synthetic (link / bare-button) anchor: one delegated-listener row + a
434
+ * `data-precedence-id="<id>"` stamp so the listener can identify the element at runtime. */
435
+ function applyDelegated(a, ev, ap, out) {
436
+ const statKeys = new Set(Object.keys(a.staticProps || {}));
437
+ out.delegated.push({
438
+ event: ev.name, element: ap.element, ref: ap.id, // the structural id, the runtime join key
439
+ attrs: (ap.autoAttrs && ap.autoAttrs.length ? ap.autoAttrs : (ev.properties || [])).filter((p) => !statKeys.has(p)),
440
+ props: a.staticProps || {},
441
+ });
442
+ const en = ap.elementNode;
443
+ if (!en || !(ts.isJsxOpeningElement(en) || ts.isJsxSelfClosingElement(en)))
444
+ return;
445
+ const already = en.attributes.properties.some((p) => ts.isJsxAttribute(p) && p.name.getText() === "data-precedence-id");
446
+ if (already)
447
+ return;
448
+ const at = en.tagName.getEnd();
449
+ const list = out.byFile.get(ap.file) || [];
450
+ list.push({ start: at, end: at, text: ` data-precedence-id=${JSON.stringify(ap.id)}`, kind: "stamp" });
451
+ out.byFile.set(ap.file, list);
452
+ }
453
+ /** true when a previous run already spliced this anchor's call into the file.
454
+ * Direct mode writes `pm_id: "<id>"`, runtime mode passes the id as arg 1, and
455
+ * the anchor id is globally unique, so a substring hit is a safe "already done".
456
+ * An inserting codemod needs this: unlike a pattern-replacing one, an insert
457
+ * doesn't stop matching on the next pass (cf. addImport's regex guard,
458
+ * applyDelegated's data-precedence-id check, create-react-app eject's git check). */
459
+ function alreadyInjected(sf, anchorId) {
460
+ return sf.text.includes(JSON.stringify(anchorId));
461
+ }
462
+ /** an outcome anchor: splice the `track(...)` call at the branch's inject site */
463
+ function applyInlineEdit(a, ev, ap, call, drifted, out) {
464
+ if (alreadyInjected(ap.sf, a.id)) {
465
+ out.applied.push({ event: ev.name, file: ap.file, line: ap.line, mode: ap.site.kind, call, drifted, unchanged: true });
466
+ return;
467
+ }
468
+ const edits = planEdit(ap.site, call, ap.sf);
469
+ if (!edits.length) {
470
+ out.skipped.push({ event: ev.name, id: a.id, reason: `no concrete injection point (site "${ap.site.kind}")` });
471
+ return;
472
+ }
473
+ const list = out.byFile.get(ap.file) || [];
474
+ list.push(...edits);
475
+ out.byFile.set(ap.file, list);
476
+ out.applied.push({
477
+ event: ev.name, file: ap.file, mode: ap.site.kind, call,
478
+ line: ap.sf.getLineAndCharacterOfPosition(edits[0].start).line + 1,
479
+ drifted,
480
+ });
481
+ }
482
+ /** a plan built against `--dir foo` carries `foo/x.tsx#…`; the same file scanned
483
+ * another way is `x.tsx#…`. Fall back to a UNIQUE match on the `#Comp::slot|…`
484
+ * tail (the part that doesn't depend on the scan root). */
485
+ function resolveLoosely(id, index) {
486
+ const hash = id.indexOf("#");
487
+ if (hash < 0)
488
+ return null;
489
+ const tail = id.slice(hash);
490
+ const hits = [];
491
+ for (const [k, ap] of index)
492
+ if (k.endsWith(tail))
493
+ hits.push(ap);
494
+ return hits.length === 1 ? hits[0] : null;
495
+ }
496
+ function dedupeAPs(index) {
497
+ return [...new Map([...index.values()].map((ap) => [ap.id, ap])).values()];
498
+ }
499
+ /** the id is a fast path; the fingerprint is the real anchor. `conditionKey` is
500
+ * syntax-normalised (core/logic), so it holds through a component rename, a
501
+ * variable rename, or any refactor that preserves the branch's logic. UNIQUE
502
+ * match on (same file + same event + same conditionKey) or nothing; a guess
503
+ * is reported as ambiguous, never applied. */
504
+ function resolveByFingerprint(a, all) {
505
+ const fp = a.fingerprint;
506
+ if (!fp || !fp.conditionKey || !fp.handler)
507
+ return null;
508
+ const file = anchorFile(a.id);
509
+ const cands = all.filter((ap) => anchorFile(ap.id) === file &&
510
+ ap.fingerprint.handler === fp.handler &&
511
+ ap.fingerprint.conditionKey === fp.conditionKey);
512
+ if (cands.length === 1)
513
+ return { ap: cands[0], ambiguous: [] };
514
+ if (cands.length > 1)
515
+ return { ap: cands[0], ambiguous: cands };
516
+ return null;
517
+ }
518
+ /**
519
+ * Repair a plan whose ids stopped resolving. The usual cause is a component
520
+ * rename (`#CheckoutFlow::` → `#Checkout::`), which shifts every id under it.
521
+ *
522
+ * `conditionKey` is syntax-normalised (core/logic), so a rename (of the
523
+ * component, or of a variable in the condition) does not change it. Each broken
524
+ * anchor is re-pointed to the UNIQUE current branch in the same file with the
525
+ * same `handler` + `conditionKey`. Anything ambiguous or unmatched is reported,
526
+ * never guessed. Writes a new plan for human review, never touches source.
527
+ */
528
+ function reanchor(inputs, plan, opts = {}) {
529
+ const { loaded } = (0, build_1.analyze)(inputs, { types: opts.types, tsconfig: opts.tsconfig });
530
+ const index = attachIndex(loaded);
531
+ const all = dedupeAPs(index);
532
+ const res = { plan: { events: [] }, repointed: [], unresolved: [], clean: 0 };
533
+ for (const ev of plan.events) {
534
+ const anchors = ev.anchors.map((a) => {
535
+ if (index.get(a.id) || resolveLoosely(a.id, index)) {
536
+ res.clean++;
537
+ return a;
538
+ }
539
+ const hit = resolveByFingerprint(a, all);
540
+ if (hit && !hit.ambiguous.length) {
541
+ res.repointed.push({
542
+ event: ev.name, from: a.id, to: hit.ap.id,
543
+ why: `${a.fingerprint?.handler} · ${a.fingerprint?.conditionKey}`,
544
+ });
545
+ return { ...a, id: hit.ap.id, fingerprint: hit.ap.fingerprint };
546
+ }
547
+ res.unresolved.push({
548
+ event: ev.name, id: a.id,
549
+ reason: hit
550
+ ? `${hit.ambiguous.length} branches match the fingerprint, can't choose (${hit.ambiguous.map((c) => c.id).join(", ")})`
551
+ : a.fingerprint?.conditionKey
552
+ ? "no current branch has this event + condition; the logic was removed or changed"
553
+ : "plan anchor has no fingerprint to match on; re-export from the picker",
554
+ });
555
+ return a;
556
+ });
557
+ res.plan.events.push({ ...ev, anchors });
558
+ }
559
+ return res;
560
+ }
561
+ /* -------------------------------------------------------------- entry points */
562
+ /** multi-file, the CLI codemod. Returns before/after per changed file. */
563
+ function instrument(inputs, plan, opts) {
564
+ const { loaded } = (0, build_1.analyze)(inputs, { types: opts.types, tsconfig: opts.tsconfig });
565
+ const index = attachIndex(loaded);
566
+ const { fnName } = parseTrack(opts.track || "console.log");
567
+ const c = collect(plan, index, loaded, opts, fnName);
568
+ const src = new Map(loaded.map((L) => [(0, build_1.norm)(L.file), L.sf.text]));
569
+ const res = {
570
+ files: [], applied: c.applied, skipped: c.skipped, warnings: c.warnings, delegated: c.delegated,
571
+ };
572
+ for (const [file, edits] of c.byFile) {
573
+ const before = src.get(file) || "";
574
+ const r = render(before, edits, file, opts);
575
+ // never hand back a file the injection just made unparseable. Report it as a
576
+ // skip so --check fails and the CLI exits non-zero (the invariants suite
577
+ // asserts clean output; this is the same check at runtime).
578
+ const bad = (0, parse_1.parseErrors)((0, parse_1.parseFile)(r.code, file));
579
+ if (bad.length) {
580
+ res.skipped.push({
581
+ event: c.applied.find((x) => x.file === file)?.event ?? "(file)",
582
+ id: file,
583
+ reason: `injection produced ${bad.length} syntax error(s); ${file} left unchanged`,
584
+ });
585
+ res.applied = res.applied.filter((x) => x.file !== file);
586
+ continue;
587
+ }
588
+ res.files.push({ file, before, after: r.code, map: r.map });
589
+ }
590
+ // direct mode ships a generated PM_DELEGATED module; runtime mode uses @precedence-dev/sdk
591
+ if (opts.emit !== "runtime" && res.delegated.length) {
592
+ res.runtimeModule = buildDelegatedModule(res.delegated, opts.track || "track");
593
+ }
594
+ return res;
595
+ }
596
+ /** one module, the bundler transform hook. null = the plan has nothing here. */
597
+ function instrumentFile(code, id, plan, opts) {
598
+ const planFile = matchPlanFile(id, plan);
599
+ if (!planFile)
600
+ return null;
601
+ const { loaded } = (0, build_1.analyze)([{ file: planFile, abs: id, source: code }], { types: opts.types, tsconfig: opts.tsconfig });
602
+ if (!loaded.length)
603
+ return { file: planFile, code, map: null, applied: [], warnings: [], skipped: [], delegated: [] };
604
+ const index = attachIndex(loaded);
605
+ const { fnName } = parseTrack(opts.track || "console.log");
606
+ const c = collect(plan, index, loaded, opts, fnName, planFile);
607
+ const edits = c.byFile.get((0, build_1.norm)(planFile)) || [...c.byFile.values()][0] || [];
608
+ const r = edits.length ? render(code, edits, id, opts) : { code, map: null };
609
+ return {
610
+ file: planFile, code: r.code, map: r.map,
611
+ applied: c.applied, warnings: c.warnings, skipped: c.skipped, delegated: c.delegated,
612
+ };
613
+ }
614
+ /** the delegated (synthetic) anchors in a plan, straight from the plan, no
615
+ * analysis needed, so a bundler can build the runtime module before it has
616
+ * transformed every file. */
617
+ function planDelegations(plan) {
618
+ const out = [];
619
+ for (const ev of plan.events) {
620
+ for (const a of ev.anchors) {
621
+ if (a.inject !== "delegated-listener")
622
+ continue;
623
+ const statKeys = new Set(Object.keys(a.staticProps || {}));
624
+ out.push({
625
+ event: ev.name, element: a.element || "",
626
+ ref: a.id, // the structural id, matches what the transform stamps as data-precedence-id
627
+ attrs: (ev.properties || []).filter((p) => !statKeys.has(p)),
628
+ props: a.staticProps || {},
629
+ });
630
+ }
631
+ }
632
+ return out;
633
+ }
634
+ /** one document-level listener for every synthetic (link / bare-button) anchor,
635
+ * keyed on the `data-precedence-id="<structural id>"` the transform stamps onto the
636
+ * same element, one string, produced by one code path, so it can't drift. */
637
+ function buildDelegatedModule(dels, track) {
638
+ const { fnName, importLine } = parseTrack(track);
639
+ const rows = dels.map((d) => {
640
+ const readers = d.attrs.map((a) => {
641
+ const dom = a === "label" ? "getAttribute('aria-label')"
642
+ : a === "track_id" ? "getAttribute('data-track')"
643
+ : "getAttribute(" + JSON.stringify(a) + ")";
644
+ return `${JSON.stringify(a)}: el.${dom}`;
645
+ });
646
+ const stat = Object.entries(d.props).map(([k, v]) => `${JSON.stringify(k)}: ${JSON.stringify(v)}`);
647
+ const idPart = `${JSON.stringify(exports.PM_ID_KEY)}: ${JSON.stringify(d.ref)}`;
648
+ return ` ${JSON.stringify(d.ref)}: { name: ${JSON.stringify(d.event)}, props: (el: Element): Record<string, unknown> => ({ ${[idPart].concat(readers, stat).join(", ")} }) },`;
649
+ });
650
+ return `${importLine}// generated by precedence-instrument, needs data-precedence-id stamps in the production build
651
+ type PmDelegated = { name: string; props: (el: Element) => Record<string, unknown> };
652
+ const PM_DELEGATED: Record<string, PmDelegated> = {
653
+ ${rows.join("\n")}
654
+ };
655
+ if (typeof document !== "undefined") {
656
+ document.addEventListener("click", (e) => {
657
+ const el = (e.target instanceof Element) ? e.target.closest("[data-precedence-id]") : null;
658
+ if (!el) return;
659
+ const id = el.getAttribute("data-precedence-id");
660
+ const hit = id ? PM_DELEGATED[id] : undefined;
661
+ if (hit) { try { void Promise.resolve(${fnName}(hit.name, hit.props(el))).catch(() => {}); } catch (__pmE) {} }
662
+ }, true);
663
+ }
664
+ `;
665
+ }