@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.
- package/LICENSE +105 -0
- package/README.md +109 -0
- package/bin/precedence-instrument.js +12 -0
- package/dist/discover.d.ts +10 -0
- package/dist/discover.js +95 -0
- package/dist/generate/cli.d.ts +2 -0
- package/dist/generate/cli.js +368 -0
- package/dist/generate/explain.d.ts +21 -0
- package/dist/generate/explain.js +428 -0
- package/dist/generate/instrument.d.ts +165 -0
- package/dist/generate/instrument.js +665 -0
- package/dist/generate/unplugin.d.ts +31 -0
- package/dist/generate/unplugin.js +125 -0
- package/dist/util.d.ts +2 -0
- package/dist/util.js +7 -0
- package/package.json +50 -0
|
@@ -0,0 +1,428 @@
|
|
|
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
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.explain = explain;
|
|
37
|
+
/**
|
|
38
|
+
* `precedence-instrument --explain <pm_id>` — the reverse direction of the pipeline.
|
|
39
|
+
*
|
|
40
|
+
* Every emitted event carries `pm_id` (the structural anchor id). Given one seen
|
|
41
|
+
* in the analytics dashboard, this traces it back to the exact fire site and
|
|
42
|
+
* says, per property, whether and when the value can arrive null.
|
|
43
|
+
*
|
|
44
|
+
* Static analysis only. It enumerates the STRUCTURAL reasons a value is absent:
|
|
45
|
+
* · out of lexical scope at this site → instrumenter bakes `undefined`
|
|
46
|
+
* · the branch fires precisely when the value is absent (`!user` branch)
|
|
47
|
+
* · read from an ambient store that can legitimately be empty
|
|
48
|
+
* (localStorage before first write / private mode; context outside Provider)
|
|
49
|
+
* · a nullable hop in the accessor path (`a?.b?.c`)
|
|
50
|
+
* · a state / data-hook binding that starts empty
|
|
51
|
+
* It CANNOT know runtime data quality — the API returned null, the user genuinely
|
|
52
|
+
* had none, a race cleared it. Those show up as "resolves" here.
|
|
53
|
+
*/
|
|
54
|
+
const ts = __importStar(require("typescript"));
|
|
55
|
+
const build_1 = require("@precedence-dev/cli/build");
|
|
56
|
+
const scope_sources_1 = require("@precedence-dev/cli/scope-sources");
|
|
57
|
+
const conditions_1 = require("@precedence-dev/cli/conditions");
|
|
58
|
+
const ast_1 = require("@precedence-dev/cli/ast");
|
|
59
|
+
const EMPTY_SCOPE = { data: new Set(), fn: new Set() };
|
|
60
|
+
function explain(inputs, plan, id) {
|
|
61
|
+
const { loaded } = (0, build_1.analyze)(inputs);
|
|
62
|
+
const hit = locate(loaded, id);
|
|
63
|
+
if (!hit)
|
|
64
|
+
return { id, resolved: false, reason: whyMiss(loaded, id), props: [] };
|
|
65
|
+
const { L, el, branch, scope } = hit;
|
|
66
|
+
const ev = eventFor(plan, id);
|
|
67
|
+
const anchor = anchorFor(plan, id);
|
|
68
|
+
const ambient = new Map((0, scope_sources_1.collectAmbient)(loaded.map((x) => x.sf)).map((a) => [a.name, a]));
|
|
69
|
+
const comp = nearestFn(el.scopeNode);
|
|
70
|
+
const facts = branch ? requiredFacts(branch.condModel) : [];
|
|
71
|
+
const selected = ev
|
|
72
|
+
? uniq([...(ev.properties || []), ...Object.keys(ev.accessors || {})])
|
|
73
|
+
: [];
|
|
74
|
+
const cx = { ev, anchor, ambient, scope, facts, comp, sfs: loaded.map((x) => x.sf) };
|
|
75
|
+
const props = selected.map((p) => classify(p, cx));
|
|
76
|
+
return {
|
|
77
|
+
id,
|
|
78
|
+
resolved: true,
|
|
79
|
+
event: ev?.name,
|
|
80
|
+
file: (0, build_1.norm)(L.file),
|
|
81
|
+
line: branch?.anchorLine || el.line + 1,
|
|
82
|
+
component: el.component || undefined,
|
|
83
|
+
element: el.tag,
|
|
84
|
+
firesWhen: branch ? (0, conditions_1.combinedLabel)(branch.condModel) : "the handler runs",
|
|
85
|
+
props,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function locateInElement(L, el, id) {
|
|
89
|
+
for (const h of el.handlers) {
|
|
90
|
+
if (h.actionId === id) {
|
|
91
|
+
return { L, el, handlerName: h.name, scope: L.elementScope.get(el) || EMPTY_SCOPE };
|
|
92
|
+
}
|
|
93
|
+
const an = L.handlers.get(h);
|
|
94
|
+
const b = an && findBranch(an.branches, id);
|
|
95
|
+
if (b)
|
|
96
|
+
return { L, el, handlerName: h.name, branch: b, scope: b.scope || L.elementScope.get(el) || EMPTY_SCOPE };
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
function locate(loaded, id) {
|
|
101
|
+
for (const L of loaded) {
|
|
102
|
+
for (const el of L.elements) {
|
|
103
|
+
const hit = locateInElement(L, el, id);
|
|
104
|
+
if (hit)
|
|
105
|
+
return hit;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
function findBranch(bs, id) {
|
|
111
|
+
for (const b of bs) {
|
|
112
|
+
if (b.id === id)
|
|
113
|
+
return b;
|
|
114
|
+
const c = findBranch(b.children || [], id);
|
|
115
|
+
if (c)
|
|
116
|
+
return c;
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
function eventFor(plan, id) {
|
|
121
|
+
return plan.events.find((e) => e.anchors.some((a) => a.id === id));
|
|
122
|
+
}
|
|
123
|
+
function anchorFor(plan, id) {
|
|
124
|
+
for (const e of plan.events)
|
|
125
|
+
for (const a of e.anchors)
|
|
126
|
+
if (a.id === id)
|
|
127
|
+
return a;
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
/** a short reason the id didn't resolve — the file / component / slot as far as it goes */
|
|
131
|
+
function whyMiss(loaded, id) {
|
|
132
|
+
const m = id.split("|")[0].match(/^(.+)#([^#]*)::(.+)$/);
|
|
133
|
+
if (!m)
|
|
134
|
+
return "unrecognised id — copy it from the dashboard's pm_id property verbatim";
|
|
135
|
+
const [, file, comp] = m;
|
|
136
|
+
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)));
|
|
137
|
+
if (!L)
|
|
138
|
+
return `file "${file}" is not among the scanned sources — point --dir at it`;
|
|
139
|
+
const comps = [...new Set(L.elements.map((e) => e.component || "_"))];
|
|
140
|
+
if (!comps.includes(comp))
|
|
141
|
+
return `component "${comp}" is gone from ${file} (now: ${comps.join(", ")}); it was renamed or refactored — the events on it need re-picking`;
|
|
142
|
+
return `the element/branch this id points at is gone from <${comp}>; the code moved on since the plan was authored`;
|
|
143
|
+
}
|
|
144
|
+
/** flatten a cumulative branch condition into the atoms that must hold (or must
|
|
145
|
+
* not) for the branch to run. Pushes `!` to the leaves; atoms under an `||`
|
|
146
|
+
* come back `optional` (the branch may run satisfying a different disjunct). */
|
|
147
|
+
function requiredFacts(m) {
|
|
148
|
+
const out = [];
|
|
149
|
+
(function go(node, neg, optional) {
|
|
150
|
+
if (!node)
|
|
151
|
+
return;
|
|
152
|
+
if (node.kind === "atom") {
|
|
153
|
+
out.push({ expr: node.expr, text: node.text, neg, optional });
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (node.kind === "not")
|
|
157
|
+
return go(node.inner, !neg, optional);
|
|
158
|
+
if (node.kind === "all") {
|
|
159
|
+
// ¬(a ∧ b) ≡ ¬a ∨ ¬b — negation turns the children into disjuncts
|
|
160
|
+
for (const c of node.clauses)
|
|
161
|
+
go(c, neg, optional || neg);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
// "any": a ∨ b (each optional); ¬(a ∨ b) ≡ ¬a ∧ ¬b (each required)
|
|
165
|
+
for (const c of node.clauses)
|
|
166
|
+
go(c, neg, optional || !neg);
|
|
167
|
+
})(m, false, false);
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Structural nullability verdict for one selected property, tried in order:
|
|
172
|
+
* the picker's own out-of-scope flag, a React-context accessor, an ambient
|
|
173
|
+
* store, the branch condition, plain out-of-scope, then softer path/hook hints.
|
|
174
|
+
*/
|
|
175
|
+
function classify(p, cx) {
|
|
176
|
+
const acc = cx.ev?.accessors?.[p];
|
|
177
|
+
const selPath = refPath(typeof acc === "string" ? acc : p);
|
|
178
|
+
const root = selPath.split(/[.[(]/)[0];
|
|
179
|
+
const verdict = classifyMissingProp(p, cx) ??
|
|
180
|
+
classifyContextAccessor(p, acc, cx) ??
|
|
181
|
+
classifyAmbientStore(p, acc, cx) ??
|
|
182
|
+
(root ? condVerdict(selPath, root, p, cx.facts) : null) ??
|
|
183
|
+
classifyOutOfScope(p, acc, root, cx);
|
|
184
|
+
if (verdict)
|
|
185
|
+
return verdict;
|
|
186
|
+
const reasons = softNullabilityReasons(p, acc, selPath, root, cx);
|
|
187
|
+
if (reasons.length)
|
|
188
|
+
return { prop: p, verdict: "maybe-null", reasons };
|
|
189
|
+
return mk(p, "resolves", `in scope here with no structural reason to be null — a null here would be runtime data (the source returned null / the user had none).`);
|
|
190
|
+
}
|
|
191
|
+
/** 1 — the picker already resolved this as out-of-scope at this site */
|
|
192
|
+
function classifyMissingProp(p, cx) {
|
|
193
|
+
if (!(cx.anchor?.missingProps || []).includes(p))
|
|
194
|
+
return null;
|
|
195
|
+
return mk(p, "always-null", `not in scope where this event fires — the instrumenter emits \`${p}: undefined\`, so every event from this id has \`${p}\` null.`);
|
|
196
|
+
}
|
|
197
|
+
/** 2 — a React context accessor (`{ hook, context, path }`) */
|
|
198
|
+
function classifyContextAccessor(p, acc, cx) {
|
|
199
|
+
if (!acc || typeof acc !== "object" || !acc.hook)
|
|
200
|
+
return null;
|
|
201
|
+
const reasons = [
|
|
202
|
+
`read from \`${acc.hook}()\` React context (field \`${acc.path}\`). Null for any subtree rendered outside its Provider, and on the first render before the Provider commits a value.`,
|
|
203
|
+
];
|
|
204
|
+
if (contextDefaultIsNullish(cx.sfs, acc.context)) {
|
|
205
|
+
reasons.push(`\`createContext(${acc.context})\` has no real default — outside the Provider the context value itself is null/undefined.`);
|
|
206
|
+
}
|
|
207
|
+
return { prop: p, verdict: "maybe-null", reasons };
|
|
208
|
+
}
|
|
209
|
+
/** 3 — an ambient store accessor (localStorage / sessionStorage) */
|
|
210
|
+
function classifyAmbientStore(p, acc, cx) {
|
|
211
|
+
const amb = cx.ambient.get(p);
|
|
212
|
+
const accStr = typeof acc === "string" ? acc : amb?.accessor;
|
|
213
|
+
if (!accStr || !/\b(local|session)Storage\b/.test(accStr))
|
|
214
|
+
return null;
|
|
215
|
+
const store = /sessionStorage/.test(accStr) ? "sessionStorage" : "localStorage";
|
|
216
|
+
const key = (accStr.match(/Storage\.getItem\(\s*["'`]([^"'`]+)/) || [])[1] || p;
|
|
217
|
+
const fb = (accStr.match(/\|\|\s*("(?:[^"\\]|\\.)*"|'[^']*'|`[^`]*`|\{\s*\}|\[\s*\]|null|undefined|-?\d[\d.]*)/) || [])[1];
|
|
218
|
+
// JSON.parse("null") is null; strip the quotes and judge the payload
|
|
219
|
+
const fbInner = (fb || "").replace(/^["'`]|["'`]$/g, "");
|
|
220
|
+
const realFallback = !!fb && fbInner !== "null" && fbInner !== "undefined" && fbInner !== "";
|
|
221
|
+
const reasons = realFallback
|
|
222
|
+
? [`reads \`${store}['${key}']\`, falling back to \`${fb}\` when unset — so a new user gets that fallback, not null.`]
|
|
223
|
+
: [`reads \`${store}['${key}']\` with no real fallback — null before the key is ever written (new user, right after logout, after the user clears site data), and always null where storage is blocked (Safari private mode, strict cookie settings).`];
|
|
224
|
+
if (amb?.identity)
|
|
225
|
+
reasons.push(`flagged identity/PII — confirm you want it on every event.`);
|
|
226
|
+
return { prop: p, verdict: realFallback ? "resolves" : "maybe-null", reasons };
|
|
227
|
+
}
|
|
228
|
+
/** 5 — not a lexical binding visible here at all */
|
|
229
|
+
function classifyOutOfScope(p, acc, root, cx) {
|
|
230
|
+
const known = cx.scope.data.has(root) || cx.scope.fn.has(root);
|
|
231
|
+
if (!root || known || cx.ambient.get(p) || acc !== undefined || isGlobalish(root))
|
|
232
|
+
return null;
|
|
233
|
+
return mk(p, "always-null", `\`${root}\` is not visible where this event fires — the instrumenter will emit \`${p}: undefined\`.`);
|
|
234
|
+
}
|
|
235
|
+
/** 6 + 7 — softer signals: an optional hop in the path, or a hook binding that starts empty */
|
|
236
|
+
function softNullabilityReasons(p, acc, selPath, root, cx) {
|
|
237
|
+
const reasons = [];
|
|
238
|
+
if (typeof acc === "string" && acc.includes("?.")) {
|
|
239
|
+
reasons.push(`the accessor \`${acc}\` chains through optional (\`?.\`) hops — null whenever a hop before the last is null at click time.`);
|
|
240
|
+
}
|
|
241
|
+
else if (selPath.includes(".")) {
|
|
242
|
+
reasons.push(`\`${p}\` reads a nested path (\`${selPath}\`) — null if an intermediate object is null when the event fires.`);
|
|
243
|
+
}
|
|
244
|
+
if (root && cx.comp) {
|
|
245
|
+
const init = bindingInit(cx.comp, root);
|
|
246
|
+
if (init)
|
|
247
|
+
reasons.push(init);
|
|
248
|
+
}
|
|
249
|
+
return reasons;
|
|
250
|
+
}
|
|
251
|
+
function mk(prop, verdict, reason) {
|
|
252
|
+
return { prop, verdict, reasons: [reason] };
|
|
253
|
+
}
|
|
254
|
+
/** does a required (non-optional) fact pin `selPath` absent or present? */
|
|
255
|
+
function condVerdict(selPath, root, prop, facts) {
|
|
256
|
+
for (const f of facts) {
|
|
257
|
+
if (f.optional || !f.expr)
|
|
258
|
+
continue;
|
|
259
|
+
const v = factVerdict((0, ast_1.unwrapParen)(f.expr), f, selPath, root, prop);
|
|
260
|
+
if (v)
|
|
261
|
+
return v;
|
|
262
|
+
}
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
/** one required fact → a verdict, or null if it says nothing about `selPath`. */
|
|
266
|
+
function factVerdict(e, f, selPath, root, prop) {
|
|
267
|
+
// x == null / x === undefined / x != null …
|
|
268
|
+
if (ts.isBinaryExpression(e))
|
|
269
|
+
return binaryFactVerdict(e, f, selPath, root, prop);
|
|
270
|
+
// bare truthiness: `user`, `!user`, `user.team`, `!user.team`
|
|
271
|
+
if (!ts.isIdentifier(e) && !ts.isPropertyAccessExpression(e) && !ts.isElementAccessExpression(e))
|
|
272
|
+
return null;
|
|
273
|
+
const fp = refPath((0, ast_1.txt)(e));
|
|
274
|
+
if (f.neg) {
|
|
275
|
+
// `!fp` must hold → fp is falsy. Absent for selPath only if fp is a prefix of it.
|
|
276
|
+
return isPrefix(fp, selPath) ? branchAbsent(prop, fp) : null;
|
|
277
|
+
}
|
|
278
|
+
// `fp` is truthy → every object on the way to fp is present.
|
|
279
|
+
return isPrefix(fp, selPath) || isPrefix(selPath, fp) ? branchPresent(prop, minPath(fp, selPath)) : null;
|
|
280
|
+
}
|
|
281
|
+
function binaryFactVerdict(e, f, selPath, root, prop) {
|
|
282
|
+
const l = refPath((0, ast_1.txt)(e.left)), r = refPath((0, ast_1.txt)(e.right));
|
|
283
|
+
const nullSide = /^(null|undefined)$/;
|
|
284
|
+
const refSide = nullSide.test(l) ? r : nullSide.test(r) ? l : "";
|
|
285
|
+
const nullCmp = nullComparisonVerdict(e.operatorToken.kind, f, refSide, selPath, prop);
|
|
286
|
+
if (nullCmp)
|
|
287
|
+
return nullCmp;
|
|
288
|
+
// a member comparison — `user.status === "x"` — proves `user` is present
|
|
289
|
+
if (!f.neg && (isPrefix(root, l) || isPrefix(root, r)) && (l.includes(".") || r.includes(".")))
|
|
290
|
+
return branchPresent(prop, root);
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
/** `x == null` / `x === undefined` / `x != null` against `selPath` (or a prefix). */
|
|
294
|
+
function nullComparisonVerdict(op, f, refSide, selPath, prop) {
|
|
295
|
+
if (!refSide || !isPrefix(refSide, selPath))
|
|
296
|
+
return null;
|
|
297
|
+
const eq = op === ts.SyntaxKind.EqualsEqualsEqualsToken || op === ts.SyntaxKind.EqualsEqualsToken;
|
|
298
|
+
const ne = op === ts.SyntaxKind.ExclamationEqualsEqualsToken || op === ts.SyntaxKind.ExclamationEqualsToken;
|
|
299
|
+
if ((eq && !f.neg) || (ne && f.neg))
|
|
300
|
+
return branchAbsent(prop, refSide);
|
|
301
|
+
if ((ne && !f.neg) || (eq && f.neg))
|
|
302
|
+
return branchPresent(prop, refSide);
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
function branchAbsent(prop, path) {
|
|
306
|
+
return mk(prop, "null-in-this-branch", `this branch fires precisely when \`${path}\` is falsy/absent — so \`${prop}\` is null here by construction. This is the empty-state path; if you don't want those events, drop this branch from the plan.`);
|
|
307
|
+
}
|
|
308
|
+
function branchPresent(prop, path) {
|
|
309
|
+
return mk(prop, "present-in-this-branch", `the branch condition guarantees \`${path}\` is present here — \`${prop}\` won't be null from this fire site (a null would be genuine runtime data).`);
|
|
310
|
+
}
|
|
311
|
+
/* -------------------------------------------------------------- ast probes */
|
|
312
|
+
function nearestFn(node) {
|
|
313
|
+
for (let n = node; n; n = n.parent)
|
|
314
|
+
if ((0, ast_1.isFn)(n))
|
|
315
|
+
return n;
|
|
316
|
+
return undefined;
|
|
317
|
+
}
|
|
318
|
+
/** callee name of a call expression: `foo(...)` → "foo", `a.foo(...)` → "foo", else "" */
|
|
319
|
+
function calleeName(expr) {
|
|
320
|
+
if (ts.isIdentifier(expr))
|
|
321
|
+
return expr.text;
|
|
322
|
+
if (ts.isPropertyAccessExpression(expr))
|
|
323
|
+
return expr.name.text;
|
|
324
|
+
return "";
|
|
325
|
+
}
|
|
326
|
+
const NULLISH_KIND = new Set([ts.SyntaxKind.NullKeyword, ts.SyntaxKind.UndefinedKeyword]);
|
|
327
|
+
const DATA_HOOK_RE = /(useQuery|useSuspenseQuery|useSWR|useSWRImmutable|useLazyQuery|useInfiniteQuery|useMutation|useFetch|useAsync)/;
|
|
328
|
+
/** absent, `null`, `undefined`, or the identifier `undefined` */
|
|
329
|
+
function isNullishArg(a0) {
|
|
330
|
+
return !a0 || NULLISH_KIND.has(a0.kind) || (0, ast_1.txt)(a0) === "undefined";
|
|
331
|
+
}
|
|
332
|
+
/** the narrower `useRef` test: absent or literal `null` only */
|
|
333
|
+
function isNullArg(a0) {
|
|
334
|
+
return !a0 || a0.kind === ts.SyntaxKind.NullKeyword;
|
|
335
|
+
}
|
|
336
|
+
/** `const { data: x } = useQuery()` — the renamed-`data` destructuring case */
|
|
337
|
+
function bindsDataField(nameNode, name) {
|
|
338
|
+
return ts.isObjectBindingPattern(nameNode)
|
|
339
|
+
&& nameNode.elements.some((el) => bindKey(el) === "data" && localName(el) === name);
|
|
340
|
+
}
|
|
341
|
+
/** one `const … = hook(…)` declaration → why `name` can start empty, or null */
|
|
342
|
+
function hookInitMessage(n, name) {
|
|
343
|
+
if (!n.initializer || !ts.isCallExpression(n.initializer))
|
|
344
|
+
return null;
|
|
345
|
+
const callee = calleeName(n.initializer.expression);
|
|
346
|
+
const args = n.initializer.arguments;
|
|
347
|
+
const binds = declBinds(n.name, name);
|
|
348
|
+
if (binds && callee === "useState" && isNullishArg(args[0])) {
|
|
349
|
+
return `\`${name}\` is \`useState(${args[0] ? (0, ast_1.txt)(args[0]) : ""})\` — null until its setter runs, so events that fire before then carry null.`;
|
|
350
|
+
}
|
|
351
|
+
if (binds && callee === "useRef" && isNullArg(args[0])) {
|
|
352
|
+
return `\`${name}\` is a \`useRef(null)\` — \`.current\` is null until the ref attaches.`;
|
|
353
|
+
}
|
|
354
|
+
if ((binds || bindsDataField(n.name, name)) && DATA_HOOK_RE.test(callee)) {
|
|
355
|
+
return `\`${name}\` comes from \`${callee}(…)\` — undefined until the request resolves, and again while it re-fetches or after an error.`;
|
|
356
|
+
}
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
/** the initializer story for a `const x = …` / `const [x] = …` / `const {x} = …`
|
|
360
|
+
* inside the component, when it starts empty. null if nothing notable. */
|
|
361
|
+
function bindingInit(comp, name) {
|
|
362
|
+
let msg = null;
|
|
363
|
+
(function w(n) {
|
|
364
|
+
if (msg)
|
|
365
|
+
return;
|
|
366
|
+
if (ts.isVariableDeclaration(n))
|
|
367
|
+
msg = hookInitMessage(n, name);
|
|
368
|
+
ts.forEachChild(n, w);
|
|
369
|
+
})(comp);
|
|
370
|
+
return msg;
|
|
371
|
+
}
|
|
372
|
+
function declBinds(nameNode, name) {
|
|
373
|
+
if (ts.isIdentifier(nameNode))
|
|
374
|
+
return nameNode.text === name;
|
|
375
|
+
if (ts.isArrayBindingPattern(nameNode))
|
|
376
|
+
return nameNode.elements.some((el) => !ts.isOmittedExpression(el) && ts.isIdentifier(el.name) && el.name.text === name);
|
|
377
|
+
if (ts.isObjectBindingPattern(nameNode))
|
|
378
|
+
return nameNode.elements.some((el) => localName(el) === name);
|
|
379
|
+
return false;
|
|
380
|
+
}
|
|
381
|
+
function bindKey(el) {
|
|
382
|
+
return el.propertyName && ts.isIdentifier(el.propertyName) ? el.propertyName.text
|
|
383
|
+
: ts.isIdentifier(el.name) ? el.name.text : "";
|
|
384
|
+
}
|
|
385
|
+
function localName(el) {
|
|
386
|
+
return ts.isIdentifier(el.name) ? el.name.text : "";
|
|
387
|
+
}
|
|
388
|
+
/** was `createContext` for `ctxVar` called with no meaningful default? */
|
|
389
|
+
function contextDefaultIsNullish(sfs, ctxVar) {
|
|
390
|
+
for (const sf of sfs) {
|
|
391
|
+
let hit = null;
|
|
392
|
+
(function w(n) {
|
|
393
|
+
if (hit !== null)
|
|
394
|
+
return;
|
|
395
|
+
if (ts.isVariableDeclaration(n) && ts.isIdentifier(n.name) && n.name.text === ctxVar
|
|
396
|
+
&& n.initializer && ts.isCallExpression(n.initializer)) {
|
|
397
|
+
if (calleeName(n.initializer.expression) === "createContext") {
|
|
398
|
+
const a0 = n.initializer.arguments[0];
|
|
399
|
+
hit = !a0 || a0.kind === ts.SyntaxKind.NullKeyword || (0, ast_1.txt)(a0) === "undefined"
|
|
400
|
+
|| (ts.isAsExpression(a0) && a0.expression.kind === ts.SyntaxKind.NullKeyword);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
ts.forEachChild(n, w);
|
|
404
|
+
})(sf);
|
|
405
|
+
if (hit !== null)
|
|
406
|
+
return hit;
|
|
407
|
+
}
|
|
408
|
+
return false;
|
|
409
|
+
}
|
|
410
|
+
/* -------------------------------------------------------------- path utils */
|
|
411
|
+
/** dotted path with `?.` collapsed and whitespace stripped: `a?.b .c` → `a.b.c` */
|
|
412
|
+
function refPath(s) {
|
|
413
|
+
return (s || "").replace(/\?\./g, ".").replace(/\s+/g, "").replace(/!$/, "");
|
|
414
|
+
}
|
|
415
|
+
/** is `a` the same path as `b`, or a proper prefix at a `.` boundary? */
|
|
416
|
+
function isPrefix(a, b) {
|
|
417
|
+
return a === b || b.startsWith(a + ".") || b.startsWith(a + "[");
|
|
418
|
+
}
|
|
419
|
+
function minPath(a, b) {
|
|
420
|
+
return a.length <= b.length ? a : b;
|
|
421
|
+
}
|
|
422
|
+
const GLOBALISH = new Set(["window", "document", "navigator", "location", "console", "Math", "JSON", "Date", "localStorage", "sessionStorage", "process", "globalThis"]);
|
|
423
|
+
function isGlobalish(root) {
|
|
424
|
+
return GLOBALISH.has(root);
|
|
425
|
+
}
|
|
426
|
+
function uniq(a) {
|
|
427
|
+
return [...new Set(a)];
|
|
428
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import type { BuildInput } from "@precedence-dev/cli/build";
|
|
2
|
+
import type { Fingerprint } from "@precedence-dev/cli/types";
|
|
3
|
+
export interface PlanAnchor {
|
|
4
|
+
id: string;
|
|
5
|
+
file?: string;
|
|
6
|
+
/** element line, only needed for delegated (synthetic) anchors */
|
|
7
|
+
line?: number;
|
|
8
|
+
element?: string;
|
|
9
|
+
inject?: string;
|
|
10
|
+
staticProps?: Record<string, string>;
|
|
11
|
+
missingProps?: string[];
|
|
12
|
+
/** captured at author time, the instrumenter verifies it still holds */
|
|
13
|
+
fingerprint?: Partial<Fingerprint>;
|
|
14
|
+
}
|
|
15
|
+
export interface PlanEvent {
|
|
16
|
+
name: string;
|
|
17
|
+
/** the viewer's human-written event definition; carried through untouched */
|
|
18
|
+
description?: string;
|
|
19
|
+
properties: string[];
|
|
20
|
+
outcomeProp?: string;
|
|
21
|
+
/** ambient props → how to read them: an inline expr (localStorage), or a
|
|
22
|
+
* React context field the instrumenter resolves against the component */
|
|
23
|
+
accessors?: Record<string, string | {
|
|
24
|
+
hook: string;
|
|
25
|
+
context: string;
|
|
26
|
+
path: string;
|
|
27
|
+
}>;
|
|
28
|
+
anchors: PlanAnchor[];
|
|
29
|
+
}
|
|
30
|
+
export interface Plan {
|
|
31
|
+
events: PlanEvent[];
|
|
32
|
+
}
|
|
33
|
+
export interface InstrumentOpts {
|
|
34
|
+
/**
|
|
35
|
+
* "direct" (default): inject `track("event_name", { props })` + an import.
|
|
36
|
+
* Self-contained, readable, no runtime dependency. To change anything → rebuild.
|
|
37
|
+
* "runtime": inject `globalThis.__pm?.("<anchor id>", { props })`, no import,
|
|
38
|
+
* no static/discriminator props baked in. `@precedence-dev/sdk` (installPrecedence)
|
|
39
|
+
* looks the id up in the fetched plan and shapes the event. Toggle / rename /
|
|
40
|
+
* static values / prop narrowing become plan edits, no rebuild.
|
|
41
|
+
*/
|
|
42
|
+
emit?: "direct" | "runtime";
|
|
43
|
+
/** direct mode only: "<name> from <module>" adds an import; "<name>" assumes global */
|
|
44
|
+
track?: string;
|
|
45
|
+
/** runtime mode: the global to call (default "globalThis.__pm") */
|
|
46
|
+
emitGlobal?: string;
|
|
47
|
+
/**
|
|
48
|
+
* resolve declared types via the TS checker. REQUIRED to instrument callback-edge-rule
|
|
49
|
+
* `continuation` anchors (`mutate(x, { onSuccess })`, `p.then(f, g)`) — the
|
|
50
|
+
* classification that produced them needs the checker, so the branch tree is
|
|
51
|
+
* only reproduced here when this is on. Same catalog build path (`analyze`).
|
|
52
|
+
*/
|
|
53
|
+
types?: boolean;
|
|
54
|
+
/** explicit tsconfig.json for `types` (else the nearest one is used) */
|
|
55
|
+
tsconfig?: string;
|
|
56
|
+
}
|
|
57
|
+
interface Applied {
|
|
58
|
+
event: string;
|
|
59
|
+
file: string;
|
|
60
|
+
line: number;
|
|
61
|
+
mode: string;
|
|
62
|
+
call: string;
|
|
63
|
+
drifted?: boolean;
|
|
64
|
+
unchanged?: boolean;
|
|
65
|
+
}
|
|
66
|
+
export interface Skipped {
|
|
67
|
+
event: string;
|
|
68
|
+
id?: string;
|
|
69
|
+
reason: string;
|
|
70
|
+
}
|
|
71
|
+
interface Warning {
|
|
72
|
+
event: string;
|
|
73
|
+
id: string;
|
|
74
|
+
detail: string;
|
|
75
|
+
}
|
|
76
|
+
export interface Delegation {
|
|
77
|
+
event: string;
|
|
78
|
+
element: string;
|
|
79
|
+
ref: string;
|
|
80
|
+
attrs: string[];
|
|
81
|
+
props: Record<string, string>;
|
|
82
|
+
}
|
|
83
|
+
export interface InstrumentResult {
|
|
84
|
+
files: {
|
|
85
|
+
file: string;
|
|
86
|
+
before: string;
|
|
87
|
+
after: string;
|
|
88
|
+
map?: unknown;
|
|
89
|
+
}[];
|
|
90
|
+
applied: Applied[];
|
|
91
|
+
skipped: Skipped[];
|
|
92
|
+
/** id resolved, but the code moved on since the plan, injected anyway, verify */
|
|
93
|
+
warnings: Warning[];
|
|
94
|
+
delegated: Delegation[];
|
|
95
|
+
/** a delegated click/navigate listener for every synthetic anchor, needs
|
|
96
|
+
* `data-precedence-id` stamps in the production build (browser/stamp-loader.js) */
|
|
97
|
+
runtimeModule?: string;
|
|
98
|
+
}
|
|
99
|
+
/** what a single-module transform returns; null when the plan has nothing here */
|
|
100
|
+
export interface FileInstrumentResult {
|
|
101
|
+
file: string;
|
|
102
|
+
code: string;
|
|
103
|
+
map: unknown | null;
|
|
104
|
+
applied: Applied[];
|
|
105
|
+
warnings: Warning[];
|
|
106
|
+
skipped: Skipped[];
|
|
107
|
+
delegated: Delegation[];
|
|
108
|
+
}
|
|
109
|
+
/** every distinct source file a plan references */
|
|
110
|
+
export declare function planFiles(plan: Plan): string[];
|
|
111
|
+
/** every emitted event carries the anchor id under this key, so a value seen in
|
|
112
|
+
* the dashboard can be traced back to the exact fire site (file/component/branch). */
|
|
113
|
+
export declare const PM_ID_KEY = "pm_id";
|
|
114
|
+
/**
|
|
115
|
+
* Wrap the raw tracking call so it can never affect the surrounding control
|
|
116
|
+
* flow: a synchronous throw is caught and swallowed, and if it returns a
|
|
117
|
+
* thenable, a rejection is swallowed too — fire-and-forget, provably, not just
|
|
118
|
+
* assumed. This try/catch only ever wraps code we just inserted, never
|
|
119
|
+
* anything that existed before it, so — unlike wrapping a customer's own
|
|
120
|
+
* existing logic in a new try/catch — it cannot change what any pre-existing
|
|
121
|
+
* code does; there is no prior behavior here to disturb.
|
|
122
|
+
*/
|
|
123
|
+
export declare function safeStatement(call: string): string;
|
|
124
|
+
export interface ReanchorResult {
|
|
125
|
+
plan: Plan;
|
|
126
|
+
repointed: {
|
|
127
|
+
event: string;
|
|
128
|
+
from: string;
|
|
129
|
+
to: string;
|
|
130
|
+
why: string;
|
|
131
|
+
}[];
|
|
132
|
+
unresolved: {
|
|
133
|
+
event: string;
|
|
134
|
+
id: string;
|
|
135
|
+
reason: string;
|
|
136
|
+
}[];
|
|
137
|
+
clean: number;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Repair a plan whose ids stopped resolving. The usual cause is a component
|
|
141
|
+
* rename (`#CheckoutFlow::` → `#Checkout::`), which shifts every id under it.
|
|
142
|
+
*
|
|
143
|
+
* `conditionKey` is syntax-normalised (core/logic), so a rename (of the
|
|
144
|
+
* component, or of a variable in the condition) does not change it. Each broken
|
|
145
|
+
* anchor is re-pointed to the UNIQUE current branch in the same file with the
|
|
146
|
+
* same `handler` + `conditionKey`. Anything ambiguous or unmatched is reported,
|
|
147
|
+
* never guessed. Writes a new plan for human review, never touches source.
|
|
148
|
+
*/
|
|
149
|
+
export declare function reanchor(inputs: BuildInput[], plan: Plan, opts?: {
|
|
150
|
+
types?: boolean;
|
|
151
|
+
tsconfig?: string;
|
|
152
|
+
}): ReanchorResult;
|
|
153
|
+
/** multi-file, the CLI codemod. Returns before/after per changed file. */
|
|
154
|
+
export declare function instrument(inputs: BuildInput[], plan: Plan, opts: InstrumentOpts): InstrumentResult;
|
|
155
|
+
/** one module, the bundler transform hook. null = the plan has nothing here. */
|
|
156
|
+
export declare function instrumentFile(code: string, id: string, plan: Plan, opts: InstrumentOpts): FileInstrumentResult | null;
|
|
157
|
+
/** the delegated (synthetic) anchors in a plan, straight from the plan, no
|
|
158
|
+
* analysis needed, so a bundler can build the runtime module before it has
|
|
159
|
+
* transformed every file. */
|
|
160
|
+
export declare function planDelegations(plan: Plan): Delegation[];
|
|
161
|
+
/** one document-level listener for every synthetic (link / bare-button) anchor,
|
|
162
|
+
* keyed on the `data-precedence-id="<structural id>"` the transform stamps onto the
|
|
163
|
+
* same element, one string, produced by one code path, so it can't drift. */
|
|
164
|
+
export declare function buildDelegatedModule(dels: Delegation[], track: string): string;
|
|
165
|
+
export {};
|