@gurulu/cli 0.4.1 → 0.4.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gurulu/cli",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "Gurulu.io CLI — setup analytics in seconds",
5
5
  "bin": {
6
6
  "gurulu": "bin/gurulu.js"
@@ -379,13 +379,48 @@ async function runPatchMode(repoRoot, args) {
379
379
  // When --auto-instrument is set AND --intent-result is provided, load
380
380
  // the accepted events from the Phase 18.6 intent proposal, dispatch
381
381
  // to the matching auto-instrument module, and append the resulting
382
- // route-handler changes to the existing patch-log. Failures here are
383
- // reported but do NOT unwind the script-tag patch (separate concerns).
382
+ // route-handler changes to the existing patch-log.
383
+ //
384
+ // Sprint D / D2 — auto-instrument failures (collision, apply-throw, or a
385
+ // missing intent-result file) now roll back the script-tag patch we just
386
+ // applied. Otherwise the user is left half-installed: a tracker tag in
387
+ // their HTML/layout but no route-handler instrumentation, and no signal
388
+ // that the partial state needs cleanup. We invoke `patches.rollback()` and
389
+ // emit `INSTALL_ROLLED_BACK` on stdout so install.ts (and any wrapping
390
+ // agent) can surface the rollback to the user.
384
391
  // -------------------------------------------------------------------
392
+ let autoInstrumentFailureReason = null;
393
+ function rollbackOnFailure(reason) {
394
+ if (autoInstrumentFailureReason) return; // only roll back once
395
+ autoInstrumentFailureReason = reason;
396
+ try {
397
+ const rb = patches.rollback(repoRoot);
398
+ if (rb && rb.rolledBack) {
399
+ log(`Auto-instrument failed (${reason}); rolled back ${rb.files.length} script-tag change(s).`);
400
+ process.stdout.write(
401
+ 'INSTALL_ROLLED_BACK ' +
402
+ JSON.stringify({ stage: 'auto-instrument', reason, files: rb.files }) +
403
+ '\n',
404
+ );
405
+ } else {
406
+ process.stderr.write(
407
+ `Auto-instrument failed (${reason}); rollback unavailable: ${rb && rb.reason}\n`,
408
+ );
409
+ }
410
+ } catch (err) {
411
+ process.stderr.write(
412
+ `Auto-instrument failed (${reason}); rollback threw: ${err.stack || err.message || err}\n`,
413
+ );
414
+ }
415
+ }
385
416
  if (args.autoInstrument) {
386
417
  const intentPath = args['intent-result'];
387
418
  if (!intentPath) {
388
- log('Auto-instrument: skipped (no --intent-result provided)');
419
+ // Sprint D / D2 — explicit `--auto-instrument` without `--intent-result`
420
+ // is a user error, not "skip silently". The script-tag patch we just
421
+ // applied promised the agent that route handlers would be wired, so
422
+ // unwind that partial state.
423
+ rollbackOnFailure('missing-intent-result');
389
424
  return;
390
425
  }
391
426
  let intent;
@@ -393,11 +428,14 @@ async function runPatchMode(repoRoot, args) {
393
428
  intent = JSON.parse(readFileSync(intentPath, 'utf8'));
394
429
  } catch (err) {
395
430
  process.stderr.write(`Auto-instrument: failed to read ${intentPath}: ${err.message}\n`);
431
+ rollbackOnFailure('intent-result-unreadable');
396
432
  return;
397
433
  }
398
434
  const acceptedEvents = (intent && intent.accepted && intent.accepted.events) || [];
399
435
  if (acceptedEvents.length === 0) {
400
436
  log('Auto-instrument: no accepted events to instrument');
437
+ // Empty intent file is NOT a failure — the agent legitimately had
438
+ // nothing to instrument. Leave the script-tag patch on disk.
401
439
  return;
402
440
  }
403
441
 
@@ -447,6 +485,10 @@ async function runPatchMode(repoRoot, args) {
447
485
  process.stderr.write(
448
486
  `Auto-instrument: singleton helper collision; aborting without changes.\n`,
449
487
  );
488
+ // Sprint D / D2 — collision mid-flow leaves the user with a script tag
489
+ // injected and no helper file. Roll back the script-tag patch so the
490
+ // agent can decide whether to retry after resolving the collision.
491
+ rollbackOnFailure('singleton-helper-collision');
450
492
  return;
451
493
  }
452
494
  if (!aiResult.changes || aiResult.changes.length === 0) {
@@ -477,6 +519,11 @@ async function runPatchMode(repoRoot, args) {
477
519
  );
478
520
  } catch (err) {
479
521
  process.stderr.write(`Auto-instrument: apply failed: ${err.stack || err.message || err}\n`);
522
+ // Sprint D / D2 — applyAutoInstrumentPlan threw mid-write. The patch
523
+ // log is partially written; let the rollback restore both script-tag
524
+ // and any auto-instrument files we already touched (rollback walks the
525
+ // full file list in patch-log.json).
526
+ rollbackOnFailure('apply-auto-instrument-threw');
480
527
  }
481
528
  }
482
529
  }
@@ -16,6 +16,9 @@
16
16
  // apply transforms, then run `@babel/generator` to serialize the result.
17
17
  // Parse errors are NOT swallowed — callers catch and fall back to regex.
18
18
 
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+
19
22
  const parser = require('@babel/parser');
20
23
  // `@babel/traverse` exposes its default export under `.default` in CJS.
21
24
  const traverseMod = require('@babel/traverse');
@@ -25,9 +28,93 @@ const generate = generatorMod.default || generatorMod;
25
28
  const t = require('@babel/types');
26
29
 
27
30
  const IMPORT_LINE = "import { gurulu } from '@/lib/gurulu';";
31
+ const HELPER_REL_PATH = 'src/lib/gurulu';
28
32
  const MARKER = '@gurulu-instrumented';
29
33
  const MARKER_COMMENT = `// @gurulu-instrumented`;
30
34
 
35
+ // ---------------------------------------------------------------------------
36
+ // Sprint D / D4 — tsconfig `@/*` path detection + relative-path fallback.
37
+ //
38
+ // Many auto-instrumented files want to write `import { gurulu } from
39
+ // '@/lib/gurulu'`. That works for the typical Next.js scaffold, but breaks
40
+ // in vanilla TS / Vite / Express setups whose `tsconfig.json` does not
41
+ // configure `compilerOptions.paths['@/*']`. Without the alias the inserted
42
+ // import resolves to a missing module and the patched file fails to
43
+ // compile.
44
+ //
45
+ // `resolveGuruluImportSpecifier(repoRoot, relativeRouteFile)` returns the
46
+ // import specifier the patcher should embed. When the alias is configured
47
+ // it returns `'@/lib/gurulu'`; otherwise it computes the POSIX-style
48
+ // relative path from the route file to `src/lib/gurulu` (e.g.
49
+ // `'../../lib/gurulu'`).
50
+ // ---------------------------------------------------------------------------
51
+
52
+ function stripJsonComments(text) {
53
+ // Tolerate `// ...` and `/* ... */` comments as found in tsconfig files.
54
+ return text
55
+ .replace(/\/\*[\s\S]*?\*\//g, '')
56
+ .replace(/(^|[^:\\])\/\/.*$/gm, '$1');
57
+ }
58
+
59
+ function readTsconfigPaths(repoRoot) {
60
+ const candidates = ['tsconfig.json', 'tsconfig.base.json', 'jsconfig.json'];
61
+ for (const rel of candidates) {
62
+ const abs = path.join(repoRoot, rel);
63
+ if (!fs.existsSync(abs)) continue;
64
+ try {
65
+ const raw = fs.readFileSync(abs, 'utf8');
66
+ const parsed = JSON.parse(stripJsonComments(raw));
67
+ const co = parsed && parsed.compilerOptions;
68
+ if (co && co.paths && typeof co.paths === 'object') {
69
+ return { paths: co.paths, baseUrl: co.baseUrl || '.' };
70
+ }
71
+ } catch {
72
+ // Ignore malformed tsconfig — fall through to the next candidate.
73
+ }
74
+ }
75
+ return null;
76
+ }
77
+
78
+ /**
79
+ * Returns true when tsconfig.json (or jsconfig.json) declares
80
+ * `compilerOptions.paths['@/*']`. We accept any value — most projects map it
81
+ * to `['./src/*']` but custom roots like `['./*']` also count as wired.
82
+ */
83
+ function hasAtAlias(repoRoot) {
84
+ const cfg = readTsconfigPaths(repoRoot);
85
+ if (!cfg) return false;
86
+ return Object.prototype.hasOwnProperty.call(cfg.paths, '@/*');
87
+ }
88
+
89
+ /**
90
+ * Compute a POSIX-style relative import path from `fromFileRel` (a repo
91
+ * relative path like `src/app/api/checkout/route.ts`) to the singleton
92
+ * helper at `src/lib/gurulu`. The returned path is suitable for use as an
93
+ * ES module import specifier: it always starts with `./` or `../` and never
94
+ * carries a file extension.
95
+ */
96
+ function relativeHelperSpecifier(fromFileRel) {
97
+ const fromDir = path.posix.dirname(fromFileRel.split(path.sep).join('/'));
98
+ let rel = path.posix.relative(fromDir, HELPER_REL_PATH);
99
+ if (!rel) rel = '.';
100
+ if (!rel.startsWith('.')) rel = `./${rel}`;
101
+ return rel;
102
+ }
103
+
104
+ /**
105
+ * Pick the right import specifier for `import { gurulu } from <here>`
106
+ * given the repo root and the file the import will be written into.
107
+ *
108
+ * - When `@/*` is configured in tsconfig, prefer the alias for clean diffs
109
+ * that match the singleton helper documentation.
110
+ * - Otherwise fall back to a relative import computed from the route file.
111
+ */
112
+ function resolveGuruluImportSpecifier(repoRoot, fromFileRel) {
113
+ if (repoRoot && hasAtAlias(repoRoot)) return '@/lib/gurulu';
114
+ if (fromFileRel) return relativeHelperSpecifier(fromFileRel);
115
+ return '@/lib/gurulu';
116
+ }
117
+
31
118
  const DEFAULT_PARSE_PLUGINS = [
32
119
  'typescript',
33
120
  'jsx',
@@ -96,12 +183,62 @@ function tagInstrumented(node) {
96
183
  t.addComment(node, 'leading', ` @gurulu-instrumented`, true);
97
184
  }
98
185
 
186
+ // Sprint D / D1 — typed-helper selector. Lives in its own CJS module so the
187
+ // auto-instrumenter can swap `gurulu.track('$purchase', {...})` for the
188
+ // canonical `gurulu.purchase({...})` whenever the LLM-extracted properties
189
+ // satisfy the helper's required fields. When `selectHelper` returns null we
190
+ // fall through to the legacy generic-track shape — no behaviour change for
191
+ // custom (non-canonical) events.
192
+ const sdkHelperMap = require('./sdk-helper-map.cjs');
193
+
194
+ function safeParseExpression(text) {
195
+ return parser.parseExpression(text, { plugins: DEFAULT_PARSE_PLUGINS });
196
+ }
197
+
198
+ function buildTypedHelperCall(eventName, autoProperties) {
199
+ const helper = sdkHelperMap.selectHelper(eventName, autoProperties);
200
+ if (!helper) return null;
201
+ const argNodes = [];
202
+ for (const expr of helper.argExpressions) {
203
+ try {
204
+ argNodes.push(safeParseExpression(expr));
205
+ } catch (_) {
206
+ // Defensive: if any arg expression is malformed, abort the typed
207
+ // upgrade and let the caller emit `gurulu.track(...)` instead.
208
+ return null;
209
+ }
210
+ }
211
+ return t.expressionStatement(
212
+ t.callExpression(
213
+ t.memberExpression(t.identifier('gurulu'), t.identifier(helper.method)),
214
+ argNodes,
215
+ ),
216
+ );
217
+ }
218
+
99
219
  /**
100
220
  * Build the `gurulu.track('eventName', { ...properties })` statement. Empty
101
221
  * object when no auto-properties; otherwise an ObjectExpression with the
102
222
  * property AST nodes inlined.
223
+ *
224
+ * Sprint D / D1: when `eventName` is a canonical event ($purchase, $signup,
225
+ * etc.) AND the extracted properties supply every required field, we emit
226
+ * the typed helper instead — `gurulu.purchase({ value, currency })` rather
227
+ * than `gurulu.track('$purchase', { ... })`. The marker comment is identical
228
+ * in either form so idempotency checks still work.
103
229
  */
104
- function buildTrackStatement(eventName, autoProperties) {
230
+ function buildTrackStatement(eventName, autoProperties, opts = {}) {
231
+ // Try the typed helper first. Tests and patcher modules pass `opts.preferTyped =
232
+ // false` to opt out (e.g. when verifying the legacy shape). Default is true.
233
+ const preferTyped = opts.preferTyped !== false;
234
+ if (preferTyped) {
235
+ const typed = buildTypedHelperCall(eventName, autoProperties);
236
+ if (typed) {
237
+ t.addComment(typed, 'leading', ` @gurulu-instrumented ${eventName}`, true);
238
+ return typed;
239
+ }
240
+ }
241
+
105
242
  const args = [t.stringLiteral(eventName)];
106
243
  if (Array.isArray(autoProperties) && autoProperties.length > 0) {
107
244
  const props = [];
@@ -111,10 +248,7 @@ function buildTrackStatement(eventName, autoProperties) {
111
248
  let valueNode;
112
249
  if (p.source && typeof p.source === 'string') {
113
250
  try {
114
- const parsedExpr = parser.parseExpression(p.source, {
115
- plugins: DEFAULT_PARSE_PLUGINS,
116
- });
117
- valueNode = parsedExpr;
251
+ valueNode = safeParseExpression(p.source);
118
252
  } catch (_) {
119
253
  valueNode = t.stringLiteral(String(p.source));
120
254
  }
@@ -193,10 +327,13 @@ function injectTrackBeforeLastReturn(fn, body, trackStatements) {
193
327
  }
194
328
 
195
329
  /**
196
- * Ensure `import { gurulu } from '@/lib/gurulu'` is present. No-op when the
197
- * import already exists.
330
+ * Ensure `import { gurulu } from <specifier>` is present. No-op when an
331
+ * existing import already binds the `gurulu` named export from a path
332
+ * pointing at our singleton helper (alias OR relative). Sprint D / D4: the
333
+ * specifier is derived from tsconfig — callers may pass an explicit one.
198
334
  */
199
- function ensureGuruluImport(ast) {
335
+ function ensureGuruluImport(ast, specifierOpt) {
336
+ const specifier = specifierOpt || '@/lib/gurulu';
200
337
  let present = false;
201
338
  let lastImportIdx = -1;
202
339
  const body = ast.program.body;
@@ -204,7 +341,12 @@ function ensureGuruluImport(ast) {
204
341
  const node = body[i];
205
342
  if (t.isImportDeclaration(node)) {
206
343
  lastImportIdx = i;
207
- if (node.source && node.source.value === '@/lib/gurulu') {
344
+ const src = node.source && node.source.value;
345
+ if (
346
+ src === specifier ||
347
+ src === '@/lib/gurulu' ||
348
+ (typeof src === 'string' && /(^|\/)lib\/gurulu$/.test(src))
349
+ ) {
208
350
  const hasNamed = (node.specifiers || []).some(
209
351
  (s) => t.isImportSpecifier(s) && t.isIdentifier(s.imported) && s.imported.name === 'gurulu',
210
352
  );
@@ -215,7 +357,7 @@ function ensureGuruluImport(ast) {
215
357
  if (present) return;
216
358
  const imp = t.importDeclaration(
217
359
  [t.importSpecifier(t.identifier('gurulu'), t.identifier('gurulu'))],
218
- t.stringLiteral('@/lib/gurulu'),
360
+ t.stringLiteral(specifier),
219
361
  );
220
362
  body.splice(lastImportIdx + 1, 0, imp);
221
363
  }
@@ -323,10 +465,16 @@ module.exports = {
323
465
  hasInstrumentedMarker,
324
466
  tagInstrumented,
325
467
  buildTrackStatement,
468
+ buildTypedHelperCall,
326
469
  injectTrackBeforeLastReturn,
327
470
  ensureGuruluImport,
328
471
  findExportedFunction,
472
+ hasAtAlias,
473
+ relativeHelperSpecifier,
474
+ resolveGuruluImportSpecifier,
475
+ sdkHelperMap,
329
476
  IMPORT_LINE,
477
+ HELPER_REL_PATH,
330
478
  MARKER,
331
479
  MARKER_COMMENT,
332
480
  };
@@ -69,7 +69,7 @@ function hasDataLoadingPattern(frontmatterSource) {
69
69
  * `---` fences via the shared AST helper, injects gurulu.track() calls, and
70
70
  * reconstructs the full .astro file.
71
71
  */
72
- function astInstrumentFrontmatter(source, events) {
72
+ function astInstrumentFrontmatter(source, events, opts = {}) {
73
73
  const fmMatch = source.match(FRONTMATTER_RE);
74
74
  if (!fmMatch) return { ok: false, reason: 'no-frontmatter' };
75
75
 
@@ -123,7 +123,9 @@ function astInstrumentFrontmatter(source, events) {
123
123
  body.body.push(...stmts);
124
124
  }
125
125
 
126
- ast.ensureGuruluImport(tree);
126
+ // Sprint D / D4 — alias-aware import.
127
+ const fmSpecifier = ast.resolveGuruluImportSpecifier(opts.repoRoot, opts.relPath);
128
+ ast.ensureGuruluImport(tree, fmSpecifier);
127
129
  const newFrontmatter = ast.generateSource(tree, frontmatter);
128
130
 
129
131
  // Reconstruct the full .astro file
@@ -140,7 +142,7 @@ function astInstrumentFrontmatter(source, events) {
140
142
  };
141
143
  }
142
144
 
143
- function astInstrumentFile(source, method, events) {
145
+ function astInstrumentFile(source, method, events, opts = {}) {
144
146
  const tree = ast.parseSource(source);
145
147
  const fns = ast.findExportedFunction(tree, method);
146
148
  if (fns.length === 0) return { ok: false, reason: `${method}-not-found` };
@@ -156,7 +158,9 @@ function astInstrumentFile(source, method, events) {
156
158
  }
157
159
  const stmts = events.map((e) => ast.buildTrackStatement(e.name, e.autoProperties));
158
160
  ast.injectTrackBeforeLastReturn(target.fn, target.body, stmts);
159
- ast.ensureGuruluImport(tree);
161
+ // Sprint D / D4 — alias-aware import.
162
+ const specifier = ast.resolveGuruluImportSpecifier(opts.repoRoot, opts.relPath);
163
+ ast.ensureGuruluImport(tree, specifier);
160
164
  const after = ast.generateSource(tree, source);
161
165
  return {
162
166
  ok: true,
@@ -208,12 +212,14 @@ function instrumentEvents(ctx, events) {
208
212
  for (const group of groups.values()) {
209
213
  const abs = path.join(ctx.repoRoot, group.relPath);
210
214
  const before = fs.readFileSync(abs, 'utf8');
215
+ // Sprint D / D4 — pass repoRoot + relPath to the import resolver.
216
+ const fileOpts = { repoRoot: (ctx && ctx.repoRoot) || null, relPath: group.relPath };
211
217
  let res;
212
218
  try {
213
219
  const isAstroComponent = group.relPath.endsWith('.astro');
214
220
  res = isAstroComponent
215
- ? astInstrumentFrontmatter(before, group.events)
216
- : astInstrumentFile(before, group.method, group.events);
221
+ ? astInstrumentFrontmatter(before, group.events, fileOpts)
222
+ : astInstrumentFile(before, group.method, group.events, fileOpts);
217
223
  } catch (err) {
218
224
  const msg = (err && err.message) || String(err);
219
225
  // eslint-disable-next-line no-console
@@ -91,7 +91,7 @@ function findRouteHandlers(tree, method, urlPath) {
91
91
  return results;
92
92
  }
93
93
 
94
- function astInstrumentFile(source, method, urlPath, events) {
94
+ function astInstrumentFile(source, method, urlPath, events, opts = {}) {
95
95
  const tree = ast.parseSource(source);
96
96
  const handlers = findRouteHandlers(tree, method, urlPath);
97
97
  if (handlers.length === 0) {
@@ -109,7 +109,9 @@ function astInstrumentFile(source, method, urlPath, events) {
109
109
  }
110
110
  const stmts = events.map((e) => ast.buildTrackStatement(e.name, e.autoProperties));
111
111
  ast.injectTrackBeforeLastReturn(target.fn, target.body, stmts);
112
- ast.ensureGuruluImport(tree);
112
+ // Sprint D / D4 — alias-aware import (Express usually has no `@/*` alias).
113
+ const specifier = ast.resolveGuruluImportSpecifier(opts.repoRoot, opts.relPath);
114
+ ast.ensureGuruluImport(tree, specifier);
113
115
  const after = ast.generateSource(tree, source);
114
116
  return {
115
117
  ok: true,
@@ -181,10 +183,21 @@ function regexFindLastResponseCall(source, start, end) {
181
183
  return { insertAt: lineStart, indent };
182
184
  }
183
185
 
184
- function regexEnsureImport(source) {
186
+ function regexEnsureImport(source, opts = {}) {
185
187
  if (source.includes(IMPORT_LINE_ESM) || source.includes(IMPORT_LINE_CJS)) return source;
188
+ // Sprint D / D4 — alias-aware specifier. ESM-only branch picks `@/lib/gurulu`
189
+ // when tsconfig declares it, otherwise a relative import. The CJS branch
190
+ // continues to use `./lib/gurulu` since CommonJS does not honor TS paths.
186
191
  const isCjs = /\brequire\s*\(/.test(source) && !/^import\s/m.test(source);
187
- const line = isCjs ? IMPORT_LINE_CJS : IMPORT_LINE_ESM;
192
+ let line;
193
+ if (isCjs) {
194
+ line = IMPORT_LINE_CJS;
195
+ } else if (opts.repoRoot) {
196
+ const specifier = ast.resolveGuruluImportSpecifier(opts.repoRoot, opts.relPath);
197
+ line = `import { gurulu } from '${specifier}';`;
198
+ } else {
199
+ line = IMPORT_LINE_ESM;
200
+ }
188
201
  const importRegex = /^(?:import[\s\S]*?;\s*\n)+/m;
189
202
  const requireRegex = /^(?:const\s[\s\S]*?require\([\s\S]*?\);\s*\n)+/m;
190
203
  const m = source.match(importRegex) || source.match(requireRegex);
@@ -200,7 +213,7 @@ function regexBuildTrackCall(eventName, indent) {
200
213
  return `${indent}${MARKER} ${eventName}\n${indent}gurulu.track(${safeName}, {});\n`;
201
214
  }
202
215
 
203
- function regexInstrumentFile(before, method, urlPath, events) {
216
+ function regexInstrumentFile(before, method, urlPath, events, opts = {}) {
204
217
  const body = regexFindHandlerStart(before, method, urlPath);
205
218
  if (!body) return { ok: false, reason: 'handler-not-found' };
206
219
  const snippet = before.slice(body.start, body.end);
@@ -218,7 +231,7 @@ function regexInstrumentFile(before, method, urlPath, events) {
218
231
  if (!ret) return { ok: false, reason: 'no-response-found' };
219
232
  const block = needed.map((e) => regexBuildTrackCall(e.name, ret.indent)).join('');
220
233
  let after = before.slice(0, ret.insertAt) + block + before.slice(ret.insertAt);
221
- after = regexEnsureImport(after);
234
+ after = regexEnsureImport(after, opts);
222
235
  return {
223
236
  ok: true,
224
237
  after,
@@ -305,15 +318,17 @@ function instrumentEvents(ctx, events) {
305
318
  for (const group of groups.values()) {
306
319
  const abs = path.join(ctx.repoRoot, group.relPath);
307
320
  const before = fs.readFileSync(abs, 'utf8');
321
+ // Sprint D / D4 — thread repoRoot + relPath into the import resolver.
322
+ const fileOpts = { repoRoot: (ctx && ctx.repoRoot) || null, relPath: group.relPath };
308
323
  let res;
309
324
  try {
310
- res = astInstrumentFile(before, group.method, group.urlPath, group.events);
325
+ res = astInstrumentFile(before, group.method, group.urlPath, group.events, fileOpts);
311
326
  } catch (err) {
312
327
  const msg = (err && err.message) || String(err);
313
328
  // eslint-disable-next-line no-console
314
329
  console.warn(`[auto-instrument] patch.fallback ${group.relPath}: ${msg}`);
315
330
  notes.push(`patch.fallback:${group.relPath}:${msg}`);
316
- res = regexInstrumentFile(before, group.method, group.urlPath, group.events);
331
+ res = regexInstrumentFile(before, group.method, group.urlPath, group.events, fileOpts);
317
332
  }
318
333
  if (!res.ok) {
319
334
  for (const e of group.events) {
@@ -104,7 +104,7 @@ function findFastifyHandlers(tree, method, urlPath) {
104
104
  return results;
105
105
  }
106
106
 
107
- function astInstrumentFile(source, method, urlPath, events) {
107
+ function astInstrumentFile(source, method, urlPath, events, opts = {}) {
108
108
  const tree = ast.parseSource(source);
109
109
  const handlers = findFastifyHandlers(tree, method, urlPath);
110
110
  if (handlers.length === 0) return { ok: false, reason: 'handler-not-found' };
@@ -120,7 +120,9 @@ function astInstrumentFile(source, method, urlPath, events) {
120
120
  }
121
121
  const stmts = events.map((e) => ast.buildTrackStatement(e.name, e.autoProperties));
122
122
  ast.injectTrackBeforeLastReturn(target.fn, target.body, stmts);
123
- ast.ensureGuruluImport(tree);
123
+ // Sprint D / D4 — alias-aware import.
124
+ const specifier = ast.resolveGuruluImportSpecifier(opts.repoRoot, opts.relPath);
125
+ ast.ensureGuruluImport(tree, specifier);
124
126
  const after = ast.generateSource(tree, source);
125
127
  return {
126
128
  ok: true,
@@ -197,9 +199,11 @@ function instrumentEvents(ctx, events) {
197
199
  for (const group of groups.values()) {
198
200
  const abs = path.join(ctx.repoRoot, group.relPath);
199
201
  const before = fs.readFileSync(abs, 'utf8');
202
+ // Sprint D / D4 — pass repoRoot + relPath to the import resolver.
203
+ const fileOpts = { repoRoot: (ctx && ctx.repoRoot) || null, relPath: group.relPath };
200
204
  let res;
201
205
  try {
202
- res = astInstrumentFile(before, group.method, group.urlPath, group.events);
206
+ res = astInstrumentFile(before, group.method, group.urlPath, group.events, fileOpts);
203
207
  } catch (err) {
204
208
  const msg = (err && err.message) || String(err);
205
209
  // eslint-disable-next-line no-console
@@ -106,7 +106,7 @@ function findHonoHandlers(tree, method, urlPath) {
106
106
  return results;
107
107
  }
108
108
 
109
- function astInstrumentFile(source, method, urlPath, events) {
109
+ function astInstrumentFile(source, method, urlPath, events, opts = {}) {
110
110
  const tree = ast.parseSource(source);
111
111
  const handlers = findHonoHandlers(tree, method, urlPath);
112
112
  if (handlers.length === 0) {
@@ -124,7 +124,9 @@ function astInstrumentFile(source, method, urlPath, events) {
124
124
  }
125
125
  const stmts = events.map((e) => ast.buildTrackStatement(e.name, e.autoProperties));
126
126
  ast.injectTrackBeforeLastReturn(target.fn, target.body, stmts);
127
- ast.ensureGuruluImport(tree);
127
+ // Sprint D / D4 — alias-aware import.
128
+ const specifier = ast.resolveGuruluImportSpecifier(opts.repoRoot, opts.relPath);
129
+ ast.ensureGuruluImport(tree, specifier);
128
130
  const after = ast.generateSource(tree, source);
129
131
  return {
130
132
  ok: true,
@@ -199,15 +201,22 @@ function regexFindLastResponseCall(source, start, end) {
199
201
  return { insertAt: lineStart, indent };
200
202
  }
201
203
 
202
- function regexEnsureImport(source) {
204
+ function regexEnsureImport(source, opts = {}) {
203
205
  if (source.includes(IMPORT_LINE_ESM)) return source;
206
+ // Sprint D / D4 — alias-aware specifier.
207
+ const specifier =
208
+ opts.repoRoot
209
+ ? ast.resolveGuruluImportSpecifier(opts.repoRoot, opts.relPath)
210
+ : '@/lib/gurulu';
211
+ const importLine = `import { gurulu } from '${specifier}';`;
212
+ if (source.includes(importLine)) return source;
204
213
  const importRegex = /^(?:import[\s\S]*?;\s*\n)+/m;
205
214
  const m = source.match(importRegex);
206
215
  if (m) {
207
216
  const end = m.index + m[0].length;
208
- return source.slice(0, end) + IMPORT_LINE_ESM + '\n' + source.slice(end);
217
+ return source.slice(0, end) + importLine + '\n' + source.slice(end);
209
218
  }
210
- return IMPORT_LINE_ESM + '\n' + source;
219
+ return importLine + '\n' + source;
211
220
  }
212
221
 
213
222
  function regexBuildTrackCall(eventName, indent) {
@@ -215,7 +224,7 @@ function regexBuildTrackCall(eventName, indent) {
215
224
  return `${indent}${MARKER} ${eventName}\n${indent}gurulu.track(${safeName}, {});\n`;
216
225
  }
217
226
 
218
- function regexInstrumentFile(before, method, urlPath, events) {
227
+ function regexInstrumentFile(before, method, urlPath, events, opts = {}) {
219
228
  const body = regexFindHandlerStart(before, method, urlPath);
220
229
  if (!body) return { ok: false, reason: 'handler-not-found' };
221
230
  const snippet = before.slice(body.start, body.end);
@@ -233,7 +242,7 @@ function regexInstrumentFile(before, method, urlPath, events) {
233
242
  if (!ret) return { ok: false, reason: 'no-response-found' };
234
243
  const block = needed.map((e) => regexBuildTrackCall(e.name, ret.indent)).join('');
235
244
  let after = before.slice(0, ret.insertAt) + block + before.slice(ret.insertAt);
236
- after = regexEnsureImport(after);
245
+ after = regexEnsureImport(after, opts);
237
246
  return {
238
247
  ok: true,
239
248
  after,
@@ -320,15 +329,17 @@ function instrumentEvents(ctx, events) {
320
329
  for (const group of groups.values()) {
321
330
  const abs = path.join(ctx.repoRoot, group.relPath);
322
331
  const before = fs.readFileSync(abs, 'utf8');
332
+ // Sprint D / D4 — thread repoRoot + relPath into the import resolver.
333
+ const fileOpts = { repoRoot: (ctx && ctx.repoRoot) || null, relPath: group.relPath };
323
334
  let res;
324
335
  try {
325
- res = astInstrumentFile(before, group.method, group.urlPath, group.events);
336
+ res = astInstrumentFile(before, group.method, group.urlPath, group.events, fileOpts);
326
337
  } catch (err) {
327
338
  const msg = (err && err.message) || String(err);
328
339
  // eslint-disable-next-line no-console
329
340
  console.warn(`[auto-instrument] patch.fallback ${group.relPath}: ${msg}`);
330
341
  notes.push(`patch.fallback:${group.relPath}:${msg}`);
331
- res = regexInstrumentFile(before, group.method, group.urlPath, group.events);
342
+ res = regexInstrumentFile(before, group.method, group.urlPath, group.events, fileOpts);
332
343
  }
333
344
  if (!res.ok) {
334
345
  for (const e of group.events) {
@@ -119,7 +119,7 @@ function collectControllerMethods(tree) {
119
119
  return results;
120
120
  }
121
121
 
122
- function astInstrumentFile(source, targets) {
122
+ function astInstrumentFile(source, targets, opts = {}) {
123
123
  // targets: [{ httpMethod, urlPath, events: [] }]
124
124
  const tree = ast.parseSource(source);
125
125
  const methods = collectControllerMethods(tree);
@@ -149,7 +149,9 @@ function astInstrumentFile(source, targets) {
149
149
  if (!changed) {
150
150
  return { ok: true, after: source, instrumented: [], skipped, changed: false };
151
151
  }
152
- ast.ensureGuruluImport(tree);
152
+ // Sprint D / D4 — alias-aware import.
153
+ const specifier = ast.resolveGuruluImportSpecifier(opts.repoRoot, opts.relPath);
154
+ ast.ensureGuruluImport(tree, specifier);
153
155
  const after = ast.generateSource(tree, source);
154
156
  return { ok: true, after, instrumented: instrumentedNames, skipped, changed: true };
155
157
  }
@@ -223,9 +225,11 @@ function instrumentEvents(ctx, events) {
223
225
  const abs = path.join(ctx.repoRoot, rel);
224
226
  const before = fs.readFileSync(abs, 'utf8');
225
227
  const targets = Array.from(perFile.values());
228
+ // Sprint D / D4 — pass repoRoot + relPath to the import resolver.
229
+ const fileOpts = { repoRoot: (ctx && ctx.repoRoot) || null, relPath: rel };
226
230
  let res;
227
231
  try {
228
- res = astInstrumentFile(before, targets);
232
+ res = astInstrumentFile(before, targets, fileOpts);
229
233
  } catch (err) {
230
234
  const msg = (err && err.message) || String(err);
231
235
  // eslint-disable-next-line no-console
@@ -76,7 +76,10 @@ function astInstrumentFile(source, method, events, opts = {}) {
76
76
  if (!injected) {
77
77
  return { ok: false, reason: 'inject-failed' };
78
78
  }
79
- ast.ensureGuruluImport(tree);
79
+ // Sprint D / D4 — pick `@/lib/gurulu` only when tsconfig is wired,
80
+ // otherwise fall back to a relative import from the route file.
81
+ const specifier = ast.resolveGuruluImportSpecifier(opts.repoRoot, opts.relPath);
82
+ ast.ensureGuruluImport(tree, specifier);
80
83
  const after = ast.generateSource(tree, source);
81
84
  return {
82
85
  ok: true,
@@ -92,15 +95,27 @@ function astInstrumentFile(source, method, events, opts = {}) {
92
95
  // scope for graceful degradation.
93
96
  // ---------------------------------------------------------------------------
94
97
 
95
- function regexEnsureImport(source) {
96
- if (source.includes(IMPORT_LINE)) return { source, added: false };
98
+ function regexEnsureImport(source, opts = {}) {
99
+ // Sprint D / D4 same `@/lib/gurulu` vs relative-path detection as the
100
+ // AST path. Falls back to the alias form for older test fixtures that
101
+ // don't pass a repoRoot.
102
+ const specifier =
103
+ opts.repoRoot
104
+ ? ast.resolveGuruluImportSpecifier(opts.repoRoot, opts.relPath)
105
+ : '@/lib/gurulu';
106
+ const importLine = `import { gurulu } from '${specifier}';`;
107
+ if (source.includes(importLine)) return { source, added: false };
108
+ // Don't double-import: if the legacy alias line exists, leave it alone.
109
+ if (specifier !== '@/lib/gurulu' && source.includes(IMPORT_LINE)) {
110
+ return { source, added: false };
111
+ }
97
112
  const importRegex = /^(?:import[\s\S]*?;\s*\n)+/m;
98
113
  const m = source.match(importRegex);
99
114
  if (m) {
100
115
  const end = m.index + m[0].length;
101
- return { source: source.slice(0, end) + IMPORT_LINE + '\n' + source.slice(end), added: true };
116
+ return { source: source.slice(0, end) + importLine + '\n' + source.slice(end), added: true };
102
117
  }
103
- return { source: IMPORT_LINE + '\n' + source, added: true };
118
+ return { source: importLine + '\n' + source, added: true };
104
119
  }
105
120
 
106
121
  function regexFindMethodBody(source, method) {
@@ -164,7 +179,7 @@ function regexBuildTrackCall(eventName, indent, extractedProperties) {
164
179
  );
165
180
  }
166
181
 
167
- function regexInstrumentFile(before, method, events) {
182
+ function regexInstrumentFile(before, method, events, opts = {}) {
168
183
  const body = regexFindMethodBody(before, method);
169
184
  if (!body) {
170
185
  return { ok: false, reason: `method-${method}-not-found` };
@@ -184,7 +199,7 @@ function regexInstrumentFile(before, method, events) {
184
199
  if (!ret) return { ok: false, reason: 'no-return-found' };
185
200
  const block = needed.map((e) => regexBuildTrackCall(e.name, ret.indent, e.extractedProperties)).join('');
186
201
  let after = before.slice(0, ret.insertAt) + block + before.slice(ret.insertAt);
187
- after = regexEnsureImport(after).source;
202
+ after = regexEnsureImport(after, opts).source;
188
203
  return {
189
204
  ok: true,
190
205
  after,
@@ -205,15 +220,18 @@ function instrumentRouteFile(ctx, relPath, method, events) {
205
220
  }
206
221
  const before = fs.readFileSync(abs, 'utf8');
207
222
  const notes = [];
223
+ // Sprint D / D4 — thread repoRoot + relPath through to the import
224
+ // resolver so `@/*` and relative-path setups pick the right specifier.
225
+ const fileOpts = { repoRoot: (ctx && ctx.repoRoot) || null, relPath };
208
226
  let res;
209
227
  try {
210
- res = astInstrumentFile(before, method, events, ctx || {});
228
+ res = astInstrumentFile(before, method, events, { ...(ctx || {}), ...fileOpts });
211
229
  } catch (err) {
212
230
  const msg = (err && err.message) || String(err);
213
231
  // eslint-disable-next-line no-console
214
232
  console.warn(`[auto-instrument] patch.fallback ${relPath}: ${msg}`);
215
233
  notes.push(`patch.fallback:${relPath}:${msg}`);
216
- res = regexInstrumentFile(before, method, events);
234
+ res = regexInstrumentFile(before, method, events, fileOpts);
217
235
  }
218
236
  if (!res.ok) {
219
237
  return {
@@ -89,7 +89,7 @@ function matchesMethodTest(node, method) {
89
89
  return false;
90
90
  }
91
91
 
92
- function astInstrumentFile(source, method, events) {
92
+ function astInstrumentFile(source, method, events, opts = {}) {
93
93
  const tree = ast.parseSource(source);
94
94
  const fns = ast.findExportedFunction(tree, 'default', { defaultExport: true });
95
95
  if (fns.length === 0) {
@@ -109,7 +109,9 @@ function astInstrumentFile(source, method, events) {
109
109
  const injectBody = branch || target.body;
110
110
  const stmts = events.map((e) => ast.buildTrackStatement(e.name, e.autoProperties));
111
111
  ast.injectTrackBeforeLastReturn(target.fn, injectBody, stmts);
112
- ast.ensureGuruluImport(tree);
112
+ // Sprint D / D4 — alias-aware import.
113
+ const specifier = ast.resolveGuruluImportSpecifier(opts.repoRoot, opts.relPath);
114
+ ast.ensureGuruluImport(tree, specifier);
113
115
  const after = ast.generateSource(tree, source);
114
116
  return {
115
117
  ok: true,
@@ -124,15 +126,24 @@ function astInstrumentFile(source, method, events) {
124
126
  // Regex fallback
125
127
  // ---------------------------------------------------------------------------
126
128
 
127
- function regexEnsureImport(source) {
128
- if (source.includes(IMPORT_LINE)) return source;
129
+ function regexEnsureImport(source, opts = {}) {
130
+ // Sprint D / D4 — alias-aware specifier.
131
+ const specifier =
132
+ opts.repoRoot
133
+ ? ast.resolveGuruluImportSpecifier(opts.repoRoot, opts.relPath)
134
+ : '@/lib/gurulu';
135
+ const importLine = `import { gurulu } from '${specifier}';`;
136
+ if (source.includes(importLine)) return source;
137
+ if (specifier !== '@/lib/gurulu' && source.includes(IMPORT_LINE)) {
138
+ return source;
139
+ }
129
140
  const importRegex = /^(?:import[\s\S]*?;\s*\n)+/m;
130
141
  const m = source.match(importRegex);
131
142
  if (m) {
132
143
  const end = m.index + m[0].length;
133
- return source.slice(0, end) + IMPORT_LINE + '\n' + source.slice(end);
144
+ return source.slice(0, end) + importLine + '\n' + source.slice(end);
134
145
  }
135
- return IMPORT_LINE + '\n' + source;
146
+ return importLine + '\n' + source;
136
147
  }
137
148
 
138
149
  function regexFindHandlerBody(source) {
@@ -191,7 +202,7 @@ function regexBuildTrackCall(eventName, indent) {
191
202
  return `${indent}${MARKER} ${eventName}\n${indent}gurulu.track(${safeName}, {});\n`;
192
203
  }
193
204
 
194
- function regexInstrumentFile(before, method, events) {
205
+ function regexInstrumentFile(before, method, events, opts = {}) {
195
206
  const body = regexFindHandlerBody(before);
196
207
  if (!body) return { ok: false, reason: 'handler-not-found' };
197
208
  const snippet = before.slice(body.start, body.end);
@@ -209,7 +220,7 @@ function regexInstrumentFile(before, method, events) {
209
220
  if (!ret) return { ok: false, reason: 'no-response-found' };
210
221
  const block = needed.map((e) => regexBuildTrackCall(e.name, ret.indent)).join('');
211
222
  let after = before.slice(0, ret.insertAt) + block + before.slice(ret.insertAt);
212
- after = regexEnsureImport(after);
223
+ after = regexEnsureImport(after, opts);
213
224
  return {
214
225
  ok: true,
215
226
  after,
@@ -230,15 +241,17 @@ function instrumentRouteFile(ctx, relPath, method, events) {
230
241
  }
231
242
  const before = fs.readFileSync(abs, 'utf8');
232
243
  const notes = [];
244
+ // Sprint D / D4 — thread repoRoot + relPath through to the import resolver.
245
+ const fileOpts = { repoRoot: (ctx && ctx.repoRoot) || null, relPath };
233
246
  let res;
234
247
  try {
235
- res = astInstrumentFile(before, method, events);
248
+ res = astInstrumentFile(before, method, events, fileOpts);
236
249
  } catch (err) {
237
250
  const msg = (err && err.message) || String(err);
238
251
  // eslint-disable-next-line no-console
239
252
  console.warn(`[auto-instrument] patch.fallback ${relPath}: ${msg}`);
240
253
  notes.push(`patch.fallback:${relPath}:${msg}`);
241
- res = regexInstrumentFile(before, method, events);
254
+ res = regexInstrumentFile(before, method, events, fileOpts);
242
255
  }
243
256
  if (!res.ok) {
244
257
  return {
@@ -40,7 +40,7 @@ function routeToCandidates(urlPath) {
40
40
  return out;
41
41
  }
42
42
 
43
- function astInstrumentFile(source, method, events) {
43
+ function astInstrumentFile(source, method, events, opts = {}) {
44
44
  const tree = ast.parseSource(source);
45
45
  // Reads → `loader`, writes → `action`. Per Remix convention.
46
46
  const exportName = method === 'GET' || method === 'HEAD' ? 'loader' : 'action';
@@ -58,7 +58,9 @@ function astInstrumentFile(source, method, events) {
58
58
  }
59
59
  const stmts = events.map((e) => ast.buildTrackStatement(e.name, e.autoProperties));
60
60
  ast.injectTrackBeforeLastReturn(target.fn, target.body, stmts);
61
- ast.ensureGuruluImport(tree);
61
+ // Sprint D / D4 — alias-aware import.
62
+ const specifier = ast.resolveGuruluImportSpecifier(opts.repoRoot, opts.relPath);
63
+ ast.ensureGuruluImport(tree, specifier);
62
64
  const after = ast.generateSource(tree, source);
63
65
  return {
64
66
  ok: true,
@@ -110,9 +112,11 @@ function instrumentEvents(ctx, events) {
110
112
  for (const group of groups.values()) {
111
113
  const abs = path.join(ctx.repoRoot, group.relPath);
112
114
  const before = fs.readFileSync(abs, 'utf8');
115
+ // Sprint D / D4 — pass repoRoot + relPath to the import resolver.
116
+ const fileOpts = { repoRoot: (ctx && ctx.repoRoot) || null, relPath: group.relPath };
113
117
  let res;
114
118
  try {
115
- res = astInstrumentFile(before, group.method, group.events);
119
+ res = astInstrumentFile(before, group.method, group.events, fileOpts);
116
120
  } catch (err) {
117
121
  const msg = (err && err.message) || String(err);
118
122
  // eslint-disable-next-line no-console
@@ -0,0 +1,241 @@
1
+ // scripts/patches/auto-instrument/sdk-helper-map.cjs — Sprint D / D1.
2
+ //
3
+ // CommonJS port of the canonical event → typed SDK helper map. Mirrors a
4
+ // subset of `src/lib/intelligence/sdk-helper-mapping.ts` so the auto-
5
+ // instrumenter (a pure-Node CJS pipeline) can pick a typed helper like
6
+ // `gurulu.purchase({...})` instead of the generic `gurulu.track('$purchase',
7
+ // {...})` when an event matches a known canonical name.
8
+ //
9
+ // We intentionally only encode the *server* mapping here — the auto-
10
+ // instrumenter writes Node/server-side route handlers, never web/iOS/Android
11
+ // code. The TS file remains the source of truth for the multi-platform
12
+ // onboarding UI.
13
+ //
14
+ // Schema:
15
+ // eventName → {
16
+ // method: 'purchase' // bare method on `gurulu` singleton
17
+ // args: [arg-spec, ...] // ordered positional arg specs
18
+ // }
19
+ //
20
+ // Each arg-spec is either:
21
+ // { kind: 'object', props: [{ name, propName, required, default? }] }
22
+ // → emits `{ propName: <expr from ctx[name]>, ... }`
23
+ // { kind: 'value', name }
24
+ // → emits the JS expression bound to property `name` (or undefined)
25
+ //
26
+ // `selectHelper(eventName, propMap)` returns null when no typed helper
27
+ // applies to the given event/properties. Callers fall back to the generic
28
+ // `gurulu.track(name, {...})` form. This function is pure — it does not
29
+ // require any AST/Babel APIs.
30
+
31
+ // Common property aliases. The `propMap` callers feed us is keyed by the
32
+ // extracted property `name`, but typed helpers expect canonical fields like
33
+ // `value`, `currency`, `transactionId`. We accept any of the listed aliases.
34
+ const PROP_ALIASES = {
35
+ value: ['value', 'amount', 'total', 'price', 'revenue'],
36
+ currency: ['currency', 'currency_code', 'currencyCode'],
37
+ transactionId: ['transactionId', 'transaction_id', 'orderId', 'order_id', 'id'],
38
+ orderId: ['orderId', 'order_id'],
39
+ userId: ['userId', 'user_id', 'uid'],
40
+ email: ['email', 'user_email'],
41
+ method: ['method', 'auth_method', 'provider'],
42
+ plan: ['plan', 'plan_id', 'planId', 'subscription_plan'],
43
+ query: ['query', 'q', 'search_term', 'searchTerm'],
44
+ };
45
+
46
+ // Server-side typed helpers. Each entry's `method` is the bare method name
47
+ // on the `gurulu` singleton (`gurulu.<method>(...)`). When `args` is a single
48
+ // object spec we emit `gurulu.<method>({ ... })`. Multiple arg-specs would
49
+ // emit positional args, but no current helper needs that shape.
50
+ const HELPER_MAP = {
51
+ $purchase: {
52
+ method: 'purchase',
53
+ args: [
54
+ {
55
+ kind: 'object',
56
+ props: [
57
+ { name: 'value', propName: 'value', required: true },
58
+ { name: 'currency', propName: 'currency', required: true },
59
+ { name: 'transactionId', propName: 'transaction_id', required: false },
60
+ ],
61
+ },
62
+ ],
63
+ },
64
+ $payment_succeeded: {
65
+ method: 'paymentSucceeded',
66
+ args: [
67
+ {
68
+ kind: 'object',
69
+ props: [
70
+ { name: 'userId', propName: 'userId', required: false },
71
+ { name: 'value', propName: 'amount', required: true },
72
+ { name: 'currency', propName: 'currency', required: true },
73
+ { name: 'orderId', propName: 'orderId', required: false },
74
+ ],
75
+ },
76
+ ],
77
+ },
78
+ $order_placed: {
79
+ method: 'orderPlaced',
80
+ args: [
81
+ {
82
+ kind: 'object',
83
+ props: [
84
+ { name: 'userId', propName: 'userId', required: false },
85
+ { name: 'orderId', propName: 'orderId', required: true },
86
+ { name: 'value', propName: 'total', required: true },
87
+ { name: 'currency', propName: 'currency', required: true },
88
+ ],
89
+ },
90
+ ],
91
+ },
92
+ $subscription_started: {
93
+ method: 'subscriptionStarted',
94
+ args: [
95
+ {
96
+ kind: 'object',
97
+ props: [
98
+ { name: 'userId', propName: 'userId', required: false },
99
+ { name: 'plan', propName: 'plan', required: true },
100
+ { name: 'value', propName: 'amount', required: false },
101
+ { name: 'currency', propName: 'currency', required: false },
102
+ ],
103
+ },
104
+ ],
105
+ },
106
+ $subscription_created: {
107
+ method: 'subscriptionStarted',
108
+ args: [
109
+ {
110
+ kind: 'object',
111
+ props: [
112
+ { name: 'userId', propName: 'userId', required: false },
113
+ { name: 'plan', propName: 'plan', required: true },
114
+ { name: 'value', propName: 'amount', required: false },
115
+ { name: 'currency', propName: 'currency', required: false },
116
+ ],
117
+ },
118
+ ],
119
+ },
120
+ $signup: {
121
+ method: 'userCreated',
122
+ args: [
123
+ {
124
+ kind: 'object',
125
+ props: [
126
+ { name: 'userId', propName: 'userId', required: true },
127
+ { name: 'email', propName: 'email', required: false },
128
+ ],
129
+ },
130
+ ],
131
+ },
132
+ $signup_completed: {
133
+ method: 'userCreated',
134
+ args: [
135
+ {
136
+ kind: 'object',
137
+ props: [
138
+ { name: 'userId', propName: 'userId', required: true },
139
+ { name: 'email', propName: 'email', required: false },
140
+ ],
141
+ },
142
+ ],
143
+ },
144
+ $account_created: {
145
+ method: 'userCreated',
146
+ args: [
147
+ {
148
+ kind: 'object',
149
+ props: [
150
+ { name: 'userId', propName: 'userId', required: true },
151
+ { name: 'email', propName: 'email', required: false },
152
+ ],
153
+ },
154
+ ],
155
+ },
156
+ };
157
+
158
+ /**
159
+ * Given an extractedProperties array (each `{ name, source }` where `source`
160
+ * is a JS expression string), build a lookup of `canonicalName → expression`
161
+ * resolving the alias table. Unknown names pass through under their raw key.
162
+ */
163
+ function buildPropertyLookup(extractedProperties) {
164
+ const raw = {};
165
+ const resolved = {};
166
+ if (!Array.isArray(extractedProperties)) {
167
+ return { raw, resolved };
168
+ }
169
+ for (const prop of extractedProperties) {
170
+ if (!prop || !prop.name || !prop.source) continue;
171
+ raw[prop.name] = prop.source;
172
+ }
173
+ // Resolve aliases — for each canonical key, the first alias hit wins.
174
+ for (const [canonical, aliases] of Object.entries(PROP_ALIASES)) {
175
+ for (const alias of aliases) {
176
+ if (Object.prototype.hasOwnProperty.call(raw, alias)) {
177
+ resolved[canonical] = raw[alias];
178
+ break;
179
+ }
180
+ }
181
+ }
182
+ return { raw, resolved };
183
+ }
184
+
185
+ /**
186
+ * Pick a typed helper for the given event + properties. Returns:
187
+ * { method, argExpressions: ['<expr>', ...] } — when the helper applies
188
+ * null — when no helper applies
189
+ *
190
+ * The caller is responsible for parsing each argExpression string into an AST
191
+ * node (e.g. via `parser.parseExpression`) and emitting the corresponding
192
+ * `gurulu.<method>(...)` call. We deliberately stay AST-free here so this
193
+ * module can be unit-tested without Babel.
194
+ */
195
+ function selectHelper(eventName, extractedProperties) {
196
+ const helper = HELPER_MAP[eventName];
197
+ if (!helper) return null;
198
+
199
+ const lookup = buildPropertyLookup(extractedProperties);
200
+ const argExpressions = [];
201
+
202
+ for (const argSpec of helper.args) {
203
+ if (argSpec.kind === 'object') {
204
+ const objectEntries = [];
205
+ let missingRequired = false;
206
+ for (const prop of argSpec.props) {
207
+ const expr = lookup.resolved[prop.name] || lookup.raw[prop.name];
208
+ if (expr) {
209
+ objectEntries.push(`${prop.propName}: ${expr}`);
210
+ } else if (prop.required) {
211
+ missingRequired = true;
212
+ break;
213
+ }
214
+ }
215
+ if (missingRequired) return null;
216
+ argExpressions.push(`{ ${objectEntries.join(', ')} }`);
217
+ } else if (argSpec.kind === 'value') {
218
+ const expr = lookup.resolved[argSpec.name] || lookup.raw[argSpec.name];
219
+ if (!expr) return null;
220
+ argExpressions.push(expr);
221
+ }
222
+ }
223
+
224
+ return { method: helper.method, argExpressions };
225
+ }
226
+
227
+ /**
228
+ * Returns the list of canonical event names that have typed helpers wired.
229
+ * Useful for tests and observability.
230
+ */
231
+ function getMappedEventNames() {
232
+ return Object.keys(HELPER_MAP);
233
+ }
234
+
235
+ module.exports = {
236
+ PROP_ALIASES,
237
+ HELPER_MAP,
238
+ buildPropertyLookup,
239
+ selectHelper,
240
+ getMappedEventNames,
241
+ };
@@ -35,7 +35,7 @@ function routeToCandidates(urlPath) {
35
35
  return out;
36
36
  }
37
37
 
38
- function astInstrumentFile(source, method, events) {
38
+ function astInstrumentFile(source, method, events, opts = {}) {
39
39
  const tree = ast.parseSource(source);
40
40
  const fns = ast.findExportedFunction(tree, method);
41
41
  if (fns.length === 0) return { ok: false, reason: `${method}-not-found` };
@@ -51,7 +51,9 @@ function astInstrumentFile(source, method, events) {
51
51
  }
52
52
  const stmts = events.map((e) => ast.buildTrackStatement(e.name, e.autoProperties));
53
53
  ast.injectTrackBeforeLastReturn(target.fn, target.body, stmts);
54
- ast.ensureGuruluImport(tree);
54
+ // Sprint D / D4 — alias-aware import.
55
+ const specifier = ast.resolveGuruluImportSpecifier(opts.repoRoot, opts.relPath);
56
+ ast.ensureGuruluImport(tree, specifier);
55
57
  const after = ast.generateSource(tree, source);
56
58
  return {
57
59
  ok: true,
@@ -103,9 +105,11 @@ function instrumentEvents(ctx, events) {
103
105
  for (const group of groups.values()) {
104
106
  const abs = path.join(ctx.repoRoot, group.relPath);
105
107
  const before = fs.readFileSync(abs, 'utf8');
108
+ // Sprint D / D4 — pass repoRoot + relPath to the import resolver.
109
+ const fileOpts = { repoRoot: (ctx && ctx.repoRoot) || null, relPath: group.relPath };
106
110
  let res;
107
111
  try {
108
- res = astInstrumentFile(before, group.method, group.events);
112
+ res = astInstrumentFile(before, group.method, group.events, fileOpts);
109
113
  } catch (err) {
110
114
  const msg = (err && err.message) || String(err);
111
115
  // eslint-disable-next-line no-console
@@ -70,7 +70,7 @@ function findEventHandler(tree) {
70
70
  return found;
71
71
  }
72
72
 
73
- function astInstrumentFile(source, events) {
73
+ function astInstrumentFile(source, events, opts = {}) {
74
74
  const tree = ast.parseSource(source);
75
75
  const target = findEventHandler(tree);
76
76
  if (!target) return { ok: false, reason: 'event-handler-not-found' };
@@ -85,7 +85,9 @@ function astInstrumentFile(source, events) {
85
85
  }
86
86
  const stmts = events.map((e) => ast.buildTrackStatement(e.name, e.autoProperties));
87
87
  ast.injectTrackBeforeLastReturn(target.fn, target.body, stmts);
88
- ast.ensureGuruluImport(tree);
88
+ // Sprint D / D4 — alias-aware import.
89
+ const specifier = ast.resolveGuruluImportSpecifier(opts.repoRoot, opts.relPath);
90
+ ast.ensureGuruluImport(tree, specifier);
89
91
  const after = ast.generateSource(tree, source);
90
92
  return {
91
93
  ok: true,
@@ -137,9 +139,11 @@ function instrumentEvents(ctx, events) {
137
139
  for (const group of groups.values()) {
138
140
  const abs = path.join(ctx.repoRoot, group.relPath);
139
141
  const before = fs.readFileSync(abs, 'utf8');
142
+ // Sprint D / D4 — pass repoRoot + relPath to the import resolver.
143
+ const fileOpts = { repoRoot: (ctx && ctx.repoRoot) || null, relPath: group.relPath };
140
144
  let res;
141
145
  try {
142
- res = astInstrumentFile(before, group.events);
146
+ res = astInstrumentFile(before, group.events, fileOpts);
143
147
  } catch (err) {
144
148
  const msg = (err && err.message) || String(err);
145
149
  // eslint-disable-next-line no-console
@@ -42,6 +42,12 @@ const PATCHERS = [
42
42
  ];
43
43
 
44
44
  const PATCHER_BY_NAME = {
45
+ // Sprint D / D3 — `nextjs` (bare) is the alias most agents emit. Resolve it
46
+ // to the App Router patcher (the modern default). The Pages Router can
47
+ // still be selected explicitly via `nextjs-pages`. Auto-detection (in the
48
+ // PATCHERS array above) handles the case where a project actually uses
49
+ // Pages Router and the agent passed `auto`.
50
+ nextjs: nextAppRouter,
45
51
  'nextjs-app': nextAppRouter,
46
52
  'nextjs-app-router': nextAppRouter,
47
53
  'nextjs-pages': nextPages,