@gurulu/cli 0.4.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/commands/attribution.d.ts +22 -0
  2. package/dist/commands/attribution.js +111 -0
  3. package/dist/commands/conversion-paths.d.ts +19 -0
  4. package/dist/commands/conversion-paths.js +55 -0
  5. package/dist/commands/errors.d.ts +27 -0
  6. package/dist/commands/errors.js +121 -0
  7. package/dist/commands/identity.d.ts +13 -0
  8. package/dist/commands/identity.js +85 -1
  9. package/dist/commands/init.js +1 -1
  10. package/dist/commands/install.d.ts +5 -0
  11. package/dist/commands/install.js +186 -9
  12. package/dist/commands/releases.d.ts +17 -0
  13. package/dist/commands/releases.js +54 -0
  14. package/dist/commands/replay.d.ts +18 -0
  15. package/dist/commands/replay.js +64 -0
  16. package/dist/commands/skad.d.ts +18 -0
  17. package/dist/commands/skad.js +53 -0
  18. package/dist/frameworks/detect.d.ts +1 -1
  19. package/dist/frameworks/detect.js +60 -0
  20. package/dist/index.js +162 -2
  21. package/package.json +1 -1
  22. package/scripts/gurulu-agentic-install.lib.cjs +32 -4
  23. package/scripts/gurulu-agentic-install.mjs +60 -5
  24. package/scripts/patches/auto-instrument/ast-helper.cjs +158 -10
  25. package/scripts/patches/auto-instrument/astro.cjs +12 -6
  26. package/scripts/patches/auto-instrument/express.cjs +23 -8
  27. package/scripts/patches/auto-instrument/fastify.cjs +7 -3
  28. package/scripts/patches/auto-instrument/hono.cjs +20 -9
  29. package/scripts/patches/auto-instrument/nestjs.cjs +7 -3
  30. package/scripts/patches/auto-instrument/nextjs-app-router.cjs +27 -9
  31. package/scripts/patches/auto-instrument/nextjs-pages.cjs +23 -10
  32. package/scripts/patches/auto-instrument/remix.cjs +7 -3
  33. package/scripts/patches/auto-instrument/sdk-helper-map.cjs +241 -0
  34. package/scripts/patches/auto-instrument/sveltekit.cjs +7 -3
  35. package/scripts/patches/auto-instrument/vue.cjs +7 -3
  36. package/scripts/patches/index.cjs +6 -0
package/dist/index.js CHANGED
@@ -41,6 +41,13 @@ const sourcemap_1 = require("./commands/sourcemap");
41
41
  const db_1 = require("./commands/db");
42
42
  // Sprint B Group VII B14 — live event stream
43
43
  const watch_1 = require("./commands/watch");
44
+ // Sprint E SE-A / SE-B — analytics + observability CLI surface
45
+ const attribution_1 = require("./commands/attribution");
46
+ const releases_1 = require("./commands/releases");
47
+ const skad_1 = require("./commands/skad");
48
+ const errors_1 = require("./commands/errors");
49
+ const replay_1 = require("./commands/replay");
50
+ const conversion_paths_1 = require("./commands/conversion-paths");
44
51
  (0, yargs_1.default)((0, helpers_1.hideBin)(process.argv))
45
52
  .scriptName('gurulu')
46
53
  .option('profile', { type: 'string', describe: 'Use a specific profile (default: personal)' })
@@ -458,18 +465,42 @@ const watch_1 = require("./commands/watch");
458
465
  json: args.json,
459
466
  profile: args.profile,
460
467
  }))
461
- .command('identity <action> [sub]', 'View identity state (decay stats, transfers list, cdc-sources list)', (y) => y
462
- .positional('action', { type: 'string', describe: 'decay | transfers | cdc-sources' })
468
+ .command('identity <action> [sub]', 'Identity state + writes (decay stats | transfers list | cdc-sources list | identify | alias | merge)', (y) => y
469
+ .positional('action', {
470
+ type: 'string',
471
+ describe: 'decay | transfers | cdc-sources | identify | alias | merge',
472
+ })
463
473
  .positional('sub', { type: 'string', describe: 'Subaction (stats, list)' })
464
474
  .option('direction', { type: 'string', describe: 'outbound | inbound | all' })
465
475
  .option('status', { type: 'string', describe: 'Filter by transfer status' })
466
476
  .option('limit', { type: 'number', describe: 'Max rows' })
477
+ // identify / alias / merge
478
+ .option('site', { type: 'string', describe: 'Site ID (write actions)' })
479
+ .option('user-id', { type: 'string', describe: 'User ID (identify)' })
480
+ .option('email', { type: 'string', describe: 'Email (identify)' })
481
+ .option('phone', { type: 'string', describe: 'Phone (identify)' })
482
+ .option('traits', { type: 'string', describe: 'Traits as JSON object (identify)' })
483
+ .option('previous-user-id', { type: 'string', describe: 'Anonymous/old user id (alias)' })
484
+ .option('new-user-id', { type: 'string', describe: 'New canonical user id (alias)' })
485
+ .option('canonical-id', { type: 'string', describe: 'Winner canonical profile id (merge)' })
486
+ .option('duplicate-id', { type: 'string', describe: 'Loser canonical profile id (merge)' })
487
+ .option('yes', { type: 'boolean', alias: 'y', describe: 'Skip confirmation' })
467
488
  .option('json', { type: 'boolean', describe: 'JSON output' }), (args) => (0, identity_1.identityCommand)({
468
489
  action: args.action,
469
490
  sub: args.sub,
470
491
  direction: args.direction,
471
492
  status: args.status,
472
493
  limit: args.limit,
494
+ site: args.site,
495
+ userId: args['user-id'],
496
+ email: args.email,
497
+ phone: args.phone,
498
+ traits: args.traits,
499
+ previousUserId: args['previous-user-id'],
500
+ newUserId: args['new-user-id'],
501
+ canonicalId: args['canonical-id'],
502
+ duplicateId: args['duplicate-id'],
503
+ yes: args.yes,
473
504
  json: args.json,
474
505
  profile: args.profile,
475
506
  }))
@@ -571,6 +602,135 @@ const watch_1 = require("./commands/watch");
571
602
  tail: args.tail,
572
603
  json: args.json,
573
604
  profile: args.profile,
605
+ }))
606
+ // ── Sprint E SE-A/B — analytics + observability CLI surface ──────────
607
+ .command('attribution <action>', 'Attribution analytics (report, compare, channels)', (y) => y
608
+ .positional('action', { type: 'string', describe: 'report | compare | channels' })
609
+ .option('site', { type: 'string', describe: 'Site ID' })
610
+ .option('from', { type: 'string', describe: 'ISO date (inclusive)' })
611
+ .option('to', { type: 'string', describe: 'ISO date (inclusive)' })
612
+ .option('range', { type: 'string', describe: '7d | 30d | 90d shorthand' })
613
+ .option('model', { type: 'string', describe: 'last-touch | first-touch | linear | time-decay | position-based | data-driven' })
614
+ .option('baseline-model', { type: 'string', describe: 'Baseline model id (compare)' })
615
+ .option('variant-model', { type: 'string', describe: 'Variant model id (compare)' })
616
+ .option('conversion-event', { type: 'string', describe: 'Conversion event (default $purchase)' })
617
+ .option('format', { type: 'string', describe: 'json | table' })
618
+ .option('json', { type: 'boolean', describe: 'JSON output (default)' }), (args) => (0, attribution_1.attributionCommand)({
619
+ action: args.action,
620
+ site: args.site,
621
+ from: args.from,
622
+ to: args.to,
623
+ range: args.range,
624
+ model: args.model,
625
+ baselineModel: args['baseline-model'],
626
+ variantModel: args['variant-model'],
627
+ conversionEvent: args['conversion-event'],
628
+ format: args.format,
629
+ json: args.json,
630
+ profile: args.profile,
631
+ }))
632
+ .command('releases <action>', 'Release health (list)', (y) => y
633
+ .positional('action', { type: 'string', describe: 'list' })
634
+ .option('site', { type: 'string', describe: 'Site ID' })
635
+ .option('range', { type: 'string', describe: '24h | 7d | 30d (default 7d)' })
636
+ .option('environment', { type: 'string', describe: 'Optional environment filter' })
637
+ .option('limit', { type: 'number', describe: 'Max rows (default 20)' })
638
+ .option('format', { type: 'string', describe: 'json | table' })
639
+ .option('json', { type: 'boolean', describe: 'JSON output' }), (args) => (0, releases_1.releasesCommand)({
640
+ action: args.action,
641
+ site: args.site,
642
+ range: args.range,
643
+ environment: args.environment,
644
+ limit: args.limit,
645
+ format: args.format,
646
+ json: args.json,
647
+ profile: args.profile,
648
+ }))
649
+ .command('skad <action>', 'Apple SKAdNetwork postbacks (postbacks)', (y) => y
650
+ .positional('action', { type: 'string', describe: 'postbacks' })
651
+ .option('site', { type: 'string', describe: 'Site ID' })
652
+ .option('from', { type: 'string', describe: 'ISO ts or 7d/24h' })
653
+ .option('to', { type: 'string', describe: 'ISO ts (default now)' })
654
+ .option('goal', { type: 'string', describe: 'Filter by resolved goal' })
655
+ .option('limit', { type: 'number', describe: 'Max rows (1..500)' })
656
+ .option('format', { type: 'string', describe: 'json | table' })
657
+ .option('json', { type: 'boolean', describe: 'JSON output' }), (args) => (0, skad_1.skadCommand)({
658
+ action: args.action,
659
+ site: args.site,
660
+ from: args.from,
661
+ to: args.to,
662
+ goal: args.goal,
663
+ limit: args.limit,
664
+ format: args.format,
665
+ json: args.json,
666
+ profile: args.profile,
667
+ }))
668
+ .command('errors <action>', 'Error tracking (list, detail, resolve, mute)', (y) => y
669
+ .positional('action', { type: 'string', describe: 'list | detail | resolve | mute | upload-sourcemap | upload-native-symbols' })
670
+ .option('site', { type: 'string', describe: 'Site ID' })
671
+ .option('fingerprint', { type: 'string', describe: 'Error group fingerprint' })
672
+ .option('level', { type: 'string', describe: 'error | warning | info' })
673
+ .option('resolved', { type: 'string', describe: 'true | false' })
674
+ .option('environment', { type: 'string', describe: 'Environment filter' })
675
+ .option('release-id', { type: 'string', describe: 'Filter to a release' })
676
+ .option('limit', { type: 'number', describe: 'Max rows' })
677
+ .option('duration', { type: 'string', describe: 'Mute duration (e.g. 24h, 7d)' })
678
+ .option('format', { type: 'string', describe: 'json | table' })
679
+ .option('yes', { type: 'boolean', alias: 'y', describe: 'Skip confirmation' })
680
+ .option('json', { type: 'boolean', describe: 'JSON output' }), (args) => (0, errors_1.errorsCommand)({
681
+ action: args.action,
682
+ site: args.site,
683
+ fingerprint: args.fingerprint,
684
+ level: args.level,
685
+ resolved: args.resolved,
686
+ environment: args.environment,
687
+ releaseId: args['release-id'],
688
+ limit: args.limit,
689
+ duration: args.duration,
690
+ format: args.format,
691
+ yes: args.yes,
692
+ json: args.json,
693
+ profile: args.profile,
694
+ }))
695
+ .command('replay <action>', 'Session replay (list, get)', (y) => y
696
+ .positional('action', { type: 'string', describe: 'list | get' })
697
+ .option('site', { type: 'string', describe: 'Site ID' })
698
+ .option('session-id', { type: 'string', describe: 'Session id (for get)' })
699
+ .option('range', { type: 'string', describe: '7d | 30d (default 7d)' })
700
+ .option('has-error', { type: 'boolean', describe: 'Filter to sessions with errors' })
701
+ .option('limit', { type: 'number', describe: 'Max rows' })
702
+ .option('format', { type: 'string', describe: 'json | table' })
703
+ .option('json', { type: 'boolean', describe: 'JSON output' }), (args) => (0, replay_1.replayCommand)({
704
+ action: args.action,
705
+ site: args.site,
706
+ sessionId: args['session-id'],
707
+ range: args.range,
708
+ hasError: args['has-error'],
709
+ limit: args.limit,
710
+ format: args.format,
711
+ json: args.json,
712
+ profile: args.profile,
713
+ }))
714
+ .command('conversion-paths <action>', 'Conversion path analytics (list)', (y) => y
715
+ .positional('action', { type: 'string', describe: 'list' })
716
+ .option('site', { type: 'string', describe: 'Site ID' })
717
+ .option('from', { type: 'string', describe: 'ISO date' })
718
+ .option('to', { type: 'string', describe: 'ISO date' })
719
+ .option('range', { type: 'string', describe: '7d | 30d | 90d' })
720
+ .option('conversion-event', { type: 'string', describe: 'Conversion event' })
721
+ .option('limit', { type: 'number', describe: 'Max rows' })
722
+ .option('format', { type: 'string', describe: 'json | table' })
723
+ .option('json', { type: 'boolean', describe: 'JSON output' }), (args) => (0, conversion_paths_1.conversionPathsCommand)({
724
+ action: args.action,
725
+ site: args.site,
726
+ from: args.from,
727
+ to: args.to,
728
+ range: args.range,
729
+ conversionEvent: args['conversion-event'],
730
+ limit: args.limit,
731
+ format: args.format,
732
+ json: args.json,
733
+ profile: args.profile,
574
734
  }))
575
735
  .demandCommand(1, 'Run gurulu --help for available commands')
576
736
  .strict()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gurulu/cli",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "Gurulu.io CLI — setup analytics in seconds",
5
5
  "bin": {
6
6
  "gurulu": "bin/gurulu.js"
@@ -380,16 +380,44 @@ function generateServerMap(scan /*, opts*/) {
380
380
  };
381
381
  }
382
382
 
383
+ /**
384
+ * Sprint E1.3b — singularize plural table/model names to canonical singular.
385
+ * The scan layer normalises mutations as `{ op: '<model>.<operation>' }` so we
386
+ * derive the operation from `m.op` when `m.operation` isn't present, and
387
+ * collapse common irregular plurals to their singular form so e.g. raw-SQL
388
+ * `INSERT INTO orders` and a Drizzle `pgTable('orders', ...)` both fold into
389
+ * the same `order` row in db-map.
390
+ */
391
+ const SINGULARIZE_IRREGULARS = {
392
+ users: 'user',
393
+ orders: 'order',
394
+ items: 'item',
395
+ };
396
+ function singularizeModelName(raw) {
397
+ const lower = String(raw || '').toLowerCase();
398
+ if (!lower) return lower;
399
+ if (Object.prototype.hasOwnProperty.call(SINGULARIZE_IRREGULARS, lower)) {
400
+ return SINGULARIZE_IRREGULARS[lower];
401
+ }
402
+ return lower;
403
+ }
404
+
383
405
  /** db-map.json — table → event emitter mapping from detected mutations. */
384
406
  function generateDbMap(scan, opts) {
385
407
  const byModel = new Map();
386
408
  for (const m of scan.mutations || []) {
387
- const name = String(m.model || '').trim();
388
- if (!name) continue;
389
- const op = cdcOp(m.operation);
409
+ const rawName = String(m.model || '').trim();
410
+ if (!rawName) continue;
411
+ const name = singularizeModelName(rawName);
412
+ // Sprint E1.3b — `scan.mutations` from gurulu-scan.lib.cjs normalises the
413
+ // operation as `m.op = "<model>.<operation>"` and drops `m.operation`.
414
+ // Derive the operation verb from whichever field is populated.
415
+ const operation =
416
+ m.operation || (typeof m.op === 'string' ? m.op.split('.').pop() : null);
417
+ const op = cdcOp(operation);
390
418
  if (!op) continue;
391
419
  if (!byModel.has(name)) byModel.set(name, new Set());
392
- const key = `${op}::${inferEventName(name, m.operation)}`;
420
+ const key = `${op}::${inferEventName(name, operation)}`;
393
421
  byModel.get(name).add(key);
394
422
  }
395
423
 
@@ -278,10 +278,13 @@ async function runPatchMode(repoRoot, args) {
278
278
  process.exit(1);
279
279
  }
280
280
  const framework = args.framework || 'auto';
281
- const { patcher, detection, error } = patches.resolvePatcher(repoRoot, framework);
281
+ const { patcher, detection, error, unsupportedFramework } = patches.resolvePatcher(repoRoot, framework);
282
282
  if (error) {
283
283
  process.stderr.write(`Error: ${error}\n`);
284
- process.exit(1);
284
+ // Sprint E1.6 — exit 6 specifically for mobile / not-yet-supported
285
+ // frameworks so the wrapping CLI can surface docs links instead of a
286
+ // generic install failure.
287
+ process.exit(unsupportedFramework ? 6 : 1);
285
288
  }
286
289
  if (!patcher) {
287
290
  process.stderr.write('No supported framework detected in ' + repoRoot + '\n');
@@ -379,13 +382,53 @@ async function runPatchMode(repoRoot, args) {
379
382
  // When --auto-instrument is set AND --intent-result is provided, load
380
383
  // the accepted events from the Phase 18.6 intent proposal, dispatch
381
384
  // 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).
385
+ // route-handler changes to the existing patch-log.
386
+ //
387
+ // Sprint D / D2 — auto-instrument failures (collision, apply-throw, or a
388
+ // missing intent-result file) now roll back the script-tag patch we just
389
+ // applied. Otherwise the user is left half-installed: a tracker tag in
390
+ // their HTML/layout but no route-handler instrumentation, and no signal
391
+ // that the partial state needs cleanup. We invoke `patches.rollback()` and
392
+ // emit `INSTALL_ROLLED_BACK` on stdout so install.ts (and any wrapping
393
+ // agent) can surface the rollback to the user.
384
394
  // -------------------------------------------------------------------
395
+ let autoInstrumentFailureReason = null;
396
+ function rollbackOnFailure(reason) {
397
+ if (autoInstrumentFailureReason) return; // only roll back once
398
+ autoInstrumentFailureReason = reason;
399
+ try {
400
+ const rb = patches.rollback(repoRoot);
401
+ if (rb && rb.rolledBack) {
402
+ log(`Auto-instrument failed (${reason}); rolled back ${rb.files.length} script-tag change(s).`);
403
+ process.stdout.write(
404
+ 'INSTALL_ROLLED_BACK ' +
405
+ JSON.stringify({ stage: 'auto-instrument', reason, files: rb.files }) +
406
+ '\n',
407
+ );
408
+ } else {
409
+ process.stderr.write(
410
+ `Auto-instrument failed (${reason}); rollback unavailable: ${rb && rb.reason}\n`,
411
+ );
412
+ }
413
+ } catch (err) {
414
+ process.stderr.write(
415
+ `Auto-instrument failed (${reason}); rollback threw: ${err.stack || err.message || err}\n`,
416
+ );
417
+ }
418
+ // Sprint E1.1 — exit code 5 signals "patches rolled back" so install.ts
419
+ // can skip the npm-install / .env-merge / ingest-ping steps and surface
420
+ // the rollback to the user. The wrapping CLI converts this into a
421
+ // partially-installed state with `summary.rolledBack=true`.
422
+ process.exit(5);
423
+ }
385
424
  if (args.autoInstrument) {
386
425
  const intentPath = args['intent-result'];
387
426
  if (!intentPath) {
388
- log('Auto-instrument: skipped (no --intent-result provided)');
427
+ // Sprint D / D2 — explicit `--auto-instrument` without `--intent-result`
428
+ // is a user error, not "skip silently". The script-tag patch we just
429
+ // applied promised the agent that route handlers would be wired, so
430
+ // unwind that partial state.
431
+ rollbackOnFailure('missing-intent-result');
389
432
  return;
390
433
  }
391
434
  let intent;
@@ -393,11 +436,14 @@ async function runPatchMode(repoRoot, args) {
393
436
  intent = JSON.parse(readFileSync(intentPath, 'utf8'));
394
437
  } catch (err) {
395
438
  process.stderr.write(`Auto-instrument: failed to read ${intentPath}: ${err.message}\n`);
439
+ rollbackOnFailure('intent-result-unreadable');
396
440
  return;
397
441
  }
398
442
  const acceptedEvents = (intent && intent.accepted && intent.accepted.events) || [];
399
443
  if (acceptedEvents.length === 0) {
400
444
  log('Auto-instrument: no accepted events to instrument');
445
+ // Empty intent file is NOT a failure — the agent legitimately had
446
+ // nothing to instrument. Leave the script-tag patch on disk.
401
447
  return;
402
448
  }
403
449
 
@@ -447,6 +493,10 @@ async function runPatchMode(repoRoot, args) {
447
493
  process.stderr.write(
448
494
  `Auto-instrument: singleton helper collision; aborting without changes.\n`,
449
495
  );
496
+ // Sprint D / D2 — collision mid-flow leaves the user with a script tag
497
+ // injected and no helper file. Roll back the script-tag patch so the
498
+ // agent can decide whether to retry after resolving the collision.
499
+ rollbackOnFailure('singleton-helper-collision');
450
500
  return;
451
501
  }
452
502
  if (!aiResult.changes || aiResult.changes.length === 0) {
@@ -477,6 +527,11 @@ async function runPatchMode(repoRoot, args) {
477
527
  );
478
528
  } catch (err) {
479
529
  process.stderr.write(`Auto-instrument: apply failed: ${err.stack || err.message || err}\n`);
530
+ // Sprint D / D2 — applyAutoInstrumentPlan threw mid-write. The patch
531
+ // log is partially written; let the rollback restore both script-tag
532
+ // and any auto-instrument files we already touched (rollback walks the
533
+ // full file list in patch-log.json).
534
+ rollbackOnFailure('apply-auto-instrument-threw');
480
535
  }
481
536
  }
482
537
  }
@@ -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