@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,368 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
4
+ if (k2 === undefined) k2 = k;
5
+ var desc = Object.getOwnPropertyDescriptor(m, k);
6
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
7
+ desc = { enumerable: true, get: function() { return m[k]; } };
8
+ }
9
+ Object.defineProperty(o, k2, desc);
10
+ }) : (function(o, m, k, k2) {
11
+ if (k2 === undefined) k2 = k;
12
+ o[k2] = m[k];
13
+ }));
14
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
15
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
16
+ }) : function(o, v) {
17
+ o["default"] = v;
18
+ });
19
+ var __importStar = (this && this.__importStar) || (function () {
20
+ var ownKeys = function(o) {
21
+ ownKeys = Object.getOwnPropertyNames || function (o) {
22
+ var ar = [];
23
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
24
+ return ar;
25
+ };
26
+ return ownKeys(o);
27
+ };
28
+ return function (mod) {
29
+ if (mod && mod.__esModule) return mod;
30
+ var result = {};
31
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
32
+ __setModuleDefault(result, mod);
33
+ return result;
34
+ };
35
+ })();
36
+ Object.defineProperty(exports, "__esModule", { value: true });
37
+ /**
38
+ * precedence-instrument, CI transform. Splice tracking calls into the source from a
39
+ * plan (the events a PM defined in the picker / viewer "copy events" export).
40
+ *
41
+ * precedence-instrument --plan plan.json --dir src --track "track from @/lib/analytics"
42
+ * precedence-instrument --plan plan.json --dir src --track track --apply
43
+ * precedence-instrument --plan plan.json --dir src --track track --check # CI: fail if drifted
44
+ */
45
+ const fs = __importStar(require("fs"));
46
+ const path = __importStar(require("path"));
47
+ const child_process_1 = require("child_process");
48
+ const discover_1 = require("../discover");
49
+ const util_1 = require("../util");
50
+ const instrument_1 = require("./instrument");
51
+ const explain_1 = require("./explain");
52
+ const HELP = `precedence-instrument, splice tracking calls into the source from a plan
53
+
54
+ USAGE
55
+ precedence-instrument --plan plan.json --dir src --track "<name> from <module>"
56
+
57
+ OPTIONS
58
+ --plan <file> the events export from the picker / viewer (required)
59
+ --dir <path> source dir to scan (repeatable) (required)
60
+ --track <spec> direct mode: "track from @/lib/analytics" -> adds an import
61
+ "track" -> assume it's already in scope / global
62
+ (default: console.log, every event logs "[pm] <name>", no import)
63
+ --emit <mode> "direct" (default): track("name", { props }) baked in
64
+ "runtime": globalThis.__pm?.("<id>", { props }), toggle/rename
65
+ become plan edits; needs @precedence-dev/sdk at the app root
66
+ --types resolve declared types via the TS checker (needs a tsconfig).
67
+ Required for callback-edge-rule continuation anchors (mutate onSuccess/
68
+ onError, .then(f,g)).
69
+ --tsconfig <path> explicit tsconfig.json for --types
70
+ --apply write the changes to disk (default: dry-run diff to stdout)
71
+ refuses if git has uncommitted changes, so the injection
72
+ stays reviewable in a diff
73
+ --allow-dirty let --apply run against a dirty git tree anyway
74
+ --out-dir <dir> write changed files here instead of in place
75
+ --runtime <file> write the delegated click/navigate listener here (for synthetic anchors)
76
+ --check exit 1 if any file would change (for CI verification)
77
+ --json machine-readable report to stdout
78
+ --reanchor don't instrument, repair a plan whose ids stopped resolving
79
+ (e.g. a component was renamed) by matching on fingerprint,
80
+ write the fixed plan to --out for review
81
+ --out <file> where --reanchor writes the repaired plan (default: <plan>.reanchored.json)
82
+ --explain <pm_id> don't instrument, trace one pm_id (as seen in the dashboard)
83
+ back to its fire site and report, per property, when it can
84
+ arrive null. Pair with --json for the structured report.
85
+ -h, --help
86
+
87
+ EXIT
88
+ 0 nothing to do / applied / reanchor clean
89
+ 1 --check and files would change / bad plan / reanchor left anchors unresolved
90
+ 2 bad usage
91
+ `;
92
+ function fail(m) { process.stderr.write(`error: ${m}\n\n${HELP}`); process.exit(2); }
93
+ const OPTIONS = new Map([
94
+ ["--plan", (o, next) => { o.plan = next(); }],
95
+ ["--dir", (o, next) => { o.paths.push(next()); }],
96
+ ["--track", (o, next) => { o.track = next(); }],
97
+ ["--emit", (o, next) => {
98
+ const v = next();
99
+ if (v !== "direct" && v !== "runtime")
100
+ fail("--emit must be direct|runtime");
101
+ o.emit = v;
102
+ }],
103
+ ["--apply", (o) => { o.apply = true; }],
104
+ ["--allow-dirty", (o) => { o.allowDirty = true; }],
105
+ ["--types", (o) => { o.types = true; }],
106
+ ["--tsconfig", (o, next) => { o.tsconfig = next(); }],
107
+ ["--check", (o) => { o.check = true; }],
108
+ ["--out-dir", (o, next) => { o.outDir = next(); }],
109
+ ["--runtime", (o, next) => { o.runtime = next(); }],
110
+ ["--json", (o) => { o.json = true; }],
111
+ ["--reanchor", (o) => { o.reanchor = true; }],
112
+ ["--out", (o, next) => { o.out = next(); }],
113
+ ["--explain", (o, next) => { o.explain = next(); }],
114
+ ]);
115
+ function parseArgs(argv) {
116
+ const o = { plan: "", paths: [], track: "", emit: "direct", apply: false, allowDirty: false, check: false, types: false, tsconfig: "", outDir: "", json: false, runtime: "", reanchor: false, out: "", explain: "" };
117
+ const cur = { i: 0 };
118
+ const next = (a) => { const v = argv[++cur.i]; if (v === undefined)
119
+ fail(`missing value for ${a}`); return v; };
120
+ for (cur.i = 0; cur.i < argv.length; cur.i++) {
121
+ const a = argv[cur.i];
122
+ if (a === "-h" || a === "--help") {
123
+ process.stdout.write(HELP);
124
+ process.exit(0);
125
+ }
126
+ const handler = OPTIONS.get(a);
127
+ if (handler)
128
+ handler(o, () => next(a));
129
+ else if (a.startsWith("--"))
130
+ fail(`unknown option: ${a}`);
131
+ else
132
+ o.paths.push(a);
133
+ }
134
+ if (!o.plan)
135
+ fail("--plan is required");
136
+ if (!o.paths.length)
137
+ fail("--dir is required");
138
+ // --track optional now: direct mode defaults to console.log (in instrument.ts)
139
+ return o;
140
+ }
141
+ /** fill an LCS length table, `dp[i][j]` = LCS of `A[i:]` and `B[j:]`. */
142
+ function lcsTable(A, B) {
143
+ const n = A.length, m = B.length;
144
+ const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
145
+ for (let i = n - 1; i >= 0; i--) {
146
+ for (let j = m - 1; j >= 0; j--) {
147
+ dp[i][j] = A[i] === B[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
148
+ }
149
+ }
150
+ return dp;
151
+ }
152
+ /** walk the LCS table into a flat context/-/+ op list. */
153
+ function diffOps(A, B) {
154
+ const dp = lcsTable(A, B);
155
+ const ops = [];
156
+ let i = 0, j = 0;
157
+ while (i < A.length && j < B.length) {
158
+ if (A[i] === B[j]) {
159
+ ops.push({ t: " ", s: A[i] });
160
+ i++;
161
+ j++;
162
+ }
163
+ else if (dp[i + 1][j] >= dp[i][j + 1]) {
164
+ ops.push({ t: "-", s: A[i] });
165
+ i++;
166
+ }
167
+ else {
168
+ ops.push({ t: "+", s: B[j] });
169
+ j++;
170
+ }
171
+ }
172
+ while (i < A.length)
173
+ ops.push({ t: "-", s: A[i++] });
174
+ while (j < B.length)
175
+ ops.push({ t: "+", s: B[j++] });
176
+ return ops;
177
+ }
178
+ /** extend a hunk forward from `start` until 2+ trailing context lines. */
179
+ function hunkEnd(ops, start) {
180
+ let end = start, ctx = 0;
181
+ while (end < ops.length && (ops[end].t !== " " || ctx < 2 || (ops[end + 1] && ops[end + 1].t !== " "))) {
182
+ ctx = ops[end].t === " " ? ctx + 1 : 0;
183
+ end++;
184
+ }
185
+ return end;
186
+ }
187
+ /** LCS line diff → unified-ish output with 2 lines of context per hunk */
188
+ function unifiedDiff(file, a, b) {
189
+ const ops = diffOps(a.split("\n"), b.split("\n"));
190
+ const out = [`--- a/${file}`, `+++ b/${file}`];
191
+ let k = 0;
192
+ while (k < ops.length) {
193
+ if (ops[k].t === " ") {
194
+ k++;
195
+ continue;
196
+ }
197
+ const from = Math.max(0, k - 2);
198
+ const end = hunkEnd(ops, k);
199
+ out.push("@@");
200
+ for (let x = from; x < end && x < ops.length; x++)
201
+ out.push(ops[x].t + ops[x].s);
202
+ k = end;
203
+ }
204
+ return out.join("\n");
205
+ }
206
+ /** `git status --porcelain`, trimmed. "" on any failure (not a repo, no git on
207
+ * PATH) — like create-react-app's eject, only block when we can positively see
208
+ * uncommitted work. */
209
+ function gitDirty() {
210
+ try {
211
+ return (0, child_process_1.execFileSync)("git", ["status", "--porcelain"], { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"] }).trim();
212
+ }
213
+ catch {
214
+ return "";
215
+ }
216
+ }
217
+ /**
218
+ * --apply rewrites source in place; keep it reviewable by refusing on a dirty
219
+ * tree (a bad run is then always one `git checkout` away). --out-dir writes
220
+ * elsewhere, so it's exempt; --allow-dirty opts out.
221
+ */
222
+ function guardCleanTree(o) {
223
+ if (!o.apply || o.allowDirty)
224
+ return;
225
+ const dirty = gitDirty();
226
+ if (!dirty)
227
+ return;
228
+ process.stderr.write("error: --apply rewrites your source, but git has uncommitted changes:\n\n" +
229
+ `${dirty.split("\n").map((l) => ` ${l}`).join("\n")}\n\n` +
230
+ "Commit or stash them first so the injection lands in its own diff, or pass --allow-dirty.\n");
231
+ process.exit(1);
232
+ }
233
+ function loadPlan(planPath) {
234
+ let plan;
235
+ try {
236
+ plan = JSON.parse(fs.readFileSync(planPath, "utf8"));
237
+ }
238
+ catch (e) {
239
+ process.stderr.write(`error: cannot read plan ${planPath}: ${(0, util_1.errMsg)(e)}\n`);
240
+ process.exit(1);
241
+ }
242
+ if (!plan || !Array.isArray(plan.events)) {
243
+ process.stderr.write(`error: ${planPath} has no "events" array\n`);
244
+ process.exit(1);
245
+ }
246
+ return plan;
247
+ }
248
+ function readInputs(paths) {
249
+ const { files, errors } = (0, discover_1.discoverSources)({ paths });
250
+ errors.forEach((e) => process.stderr.write(`warn: ${e}\n`));
251
+ const cwd = process.cwd();
252
+ return files.map((f) => ({
253
+ file: path.relative(cwd, f) || f, abs: f, source: fs.readFileSync(f, "utf8"),
254
+ }));
255
+ }
256
+ function printTextReport(r) {
257
+ const added = r.applied.filter((x) => !x.unchanged);
258
+ const unchanged = r.applied.length - added.length;
259
+ r.applied.forEach((x) => process.stdout.write(` ${x.unchanged ? "=" : x.drifted ? "±" : "+"} ${x.event} ${x.file}:${x.line} [${x.mode}]${x.unchanged ? " already instrumented" : ` ${x.call}`}\n`));
260
+ r.delegated.forEach((x) => process.stdout.write(` ~ ${x.event} <${x.element}> @ ${x.ref} → delegated listener\n`));
261
+ r.warnings.forEach((x) => process.stderr.write(` ! ${x.event} drift: ${x.detail}\n`));
262
+ r.skipped.forEach((x) => process.stderr.write(` - ${x.event} skipped: ${x.reason}${x.id ? ` (${x.id})` : ""}\n`));
263
+ process.stdout.write(`\n${added.length} call(s) in ${r.files.length} file(s)${unchanged ? `, ${unchanged} already in place` : ""}, ${r.delegated.length} delegated, ${r.warnings.length} drift, ${r.skipped.length} skipped\n`);
264
+ }
265
+ function emitRuntimeModule(r, o) {
266
+ if (o.runtime && r.runtimeModule) {
267
+ fs.mkdirSync(path.dirname(path.resolve(o.runtime)), { recursive: true });
268
+ fs.writeFileSync(o.runtime, r.runtimeModule);
269
+ process.stdout.write(` wrote ${o.runtime} (${r.delegated.length} delegated event(s), import it once at your app root)\n`);
270
+ }
271
+ else if (r.runtimeModule && !o.json) {
272
+ process.stdout.write(`\n--- delegated listener (pass --runtime <file> to write it) ---\n${r.runtimeModule}\n`);
273
+ }
274
+ }
275
+ function writeChanges(r, o) {
276
+ const changed = r.files.filter((f) => f.before !== f.after);
277
+ if (o.apply || o.outDir) {
278
+ changed.forEach((f) => {
279
+ const dest = o.outDir ? path.join(o.outDir, f.file) : path.resolve(f.file);
280
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
281
+ fs.writeFileSync(dest, f.after);
282
+ process.stdout.write(` wrote ${dest}\n`);
283
+ });
284
+ }
285
+ else if (!o.json) {
286
+ changed.forEach((f) => process.stdout.write("\n" + unifiedDiff(f.file, f.before, f.after) + "\n"));
287
+ if (changed.length)
288
+ process.stdout.write("\n(dry run, pass --apply to write)\n");
289
+ }
290
+ }
291
+ function main() {
292
+ const o = parseArgs(process.argv.slice(2));
293
+ guardCleanTree(o);
294
+ const plan = loadPlan(o.plan);
295
+ const inputs = readInputs(o.paths);
296
+ if (o.reanchor) {
297
+ doReanchor(inputs, plan, o);
298
+ return;
299
+ }
300
+ if (o.explain) {
301
+ doExplain(inputs, plan, o);
302
+ return;
303
+ }
304
+ const r = (0, instrument_1.instrument)(inputs, plan, {
305
+ track: o.track, emit: o.emit, types: o.types, tsconfig: o.tsconfig || undefined,
306
+ });
307
+ if (o.json)
308
+ process.stdout.write(JSON.stringify(r, null, 2) + "\n");
309
+ else
310
+ printTextReport(r);
311
+ const changed = r.files.filter((f) => f.before !== f.after);
312
+ // --check: the committed instrumented tree is out of date, OR the plan no
313
+ // longer resolves cleanly against the source, both are CI failures.
314
+ if (o.check)
315
+ process.exit(changed.length || r.skipped.length ? 1 : 0);
316
+ emitRuntimeModule(r, o);
317
+ writeChanges(r, o);
318
+ }
319
+ const VERDICT_MARK = {
320
+ "always-null": "✗ always null",
321
+ "null-in-this-branch": "✗ null here",
322
+ "present-in-this-branch": "✓ present",
323
+ "maybe-null": "⚠ may be null",
324
+ "resolves": "· ok",
325
+ };
326
+ function doExplain(inputs, plan, o) {
327
+ const r = (0, explain_1.explain)(inputs, plan, o.explain);
328
+ if (o.json) {
329
+ process.stdout.write(JSON.stringify(r, null, 2) + "\n");
330
+ process.exit(r.resolved ? 0 : 1);
331
+ }
332
+ if (!r.resolved) {
333
+ process.stderr.write(` ✗ ${o.explain}\n ${r.reason}\n`);
334
+ process.exit(1);
335
+ }
336
+ process.stdout.write(` ${r.event} ${r.file}:${r.line} <${r.element}> in ${r.component || "(module)"}\n`);
337
+ process.stdout.write(` fires ${r.firesWhen}\n\n`);
338
+ if (!r.props.length)
339
+ process.stdout.write(` (no properties selected for this event)\n`);
340
+ for (const p of r.props) {
341
+ process.stdout.write(` ${VERDICT_MARK[p.verdict]} ${p.prop}\n`);
342
+ for (const why of p.reasons)
343
+ process.stdout.write(` ${why}\n`);
344
+ }
345
+ process.exit(0);
346
+ }
347
+ function doReanchor(inputs, plan, o) {
348
+ const r = (0, instrument_1.reanchor)(inputs, plan, { types: o.types, tsconfig: o.tsconfig || undefined });
349
+ const dest = o.out || o.plan.replace(/\.json$/, "") + ".reanchored.json";
350
+ if (o.json) {
351
+ process.stdout.write(JSON.stringify(r, null, 2) + "\n");
352
+ }
353
+ else {
354
+ r.repointed.forEach((x) => process.stdout.write(` → ${x.event}\n ${x.from}\n ${x.to} (${x.why})\n`));
355
+ r.unresolved.forEach((x) => process.stderr.write(` ✗ ${x.event} ${x.id}\n ${x.reason}\n`));
356
+ process.stdout.write(`\n${r.clean} already fine, ${r.repointed.length} re-pointed, ${r.unresolved.length} unresolved\n`);
357
+ }
358
+ if (r.repointed.length) {
359
+ fs.writeFileSync(dest, JSON.stringify(r.plan, null, 2) + "\n");
360
+ if (!o.json)
361
+ process.stdout.write(`\n wrote ${dest}, review the id changes, then replace your plan\n`);
362
+ }
363
+ else if (!o.json) {
364
+ process.stdout.write(r.unresolved.length ? "\n nothing could be re-pointed automatically\n" : "\n plan is clean, nothing to do\n");
365
+ }
366
+ process.exit(r.unresolved.length ? 1 : 0);
367
+ }
368
+ main();
@@ -0,0 +1,21 @@
1
+ import type { BuildInput } from "@precedence-dev/cli/build";
2
+ import type { Plan } from "./instrument";
3
+ export type Verdict = "always-null" | "null-in-this-branch" | "present-in-this-branch" | "maybe-null" | "resolves";
4
+ export interface PropExplain {
5
+ prop: string;
6
+ verdict: Verdict;
7
+ reasons: string[];
8
+ }
9
+ export interface ExplainReport {
10
+ id: string;
11
+ resolved: boolean;
12
+ reason?: string;
13
+ event?: string;
14
+ file?: string;
15
+ line?: number;
16
+ component?: string;
17
+ element?: string;
18
+ firesWhen?: string;
19
+ props: PropExplain[];
20
+ }
21
+ export declare function explain(inputs: BuildInput[], plan: Plan, id: string): ExplainReport;