@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.
- package/dist/commands/attribution.d.ts +22 -0
- package/dist/commands/attribution.js +111 -0
- package/dist/commands/conversion-paths.d.ts +19 -0
- package/dist/commands/conversion-paths.js +55 -0
- package/dist/commands/errors.d.ts +27 -0
- package/dist/commands/errors.js +121 -0
- package/dist/commands/identity.d.ts +13 -0
- package/dist/commands/identity.js +85 -1
- package/dist/commands/init.js +1 -1
- package/dist/commands/install.d.ts +5 -0
- package/dist/commands/install.js +186 -9
- package/dist/commands/releases.d.ts +17 -0
- package/dist/commands/releases.js +54 -0
- package/dist/commands/replay.d.ts +18 -0
- package/dist/commands/replay.js +64 -0
- package/dist/commands/skad.d.ts +18 -0
- package/dist/commands/skad.js +53 -0
- package/dist/frameworks/detect.d.ts +1 -1
- package/dist/frameworks/detect.js +60 -0
- package/dist/index.js +162 -2
- package/package.json +1 -1
- package/scripts/gurulu-agentic-install.lib.cjs +32 -4
- package/scripts/gurulu-agentic-install.mjs +60 -5
- package/scripts/patches/auto-instrument/ast-helper.cjs +158 -10
- package/scripts/patches/auto-instrument/astro.cjs +12 -6
- package/scripts/patches/auto-instrument/express.cjs +23 -8
- package/scripts/patches/auto-instrument/fastify.cjs +7 -3
- package/scripts/patches/auto-instrument/hono.cjs +20 -9
- package/scripts/patches/auto-instrument/nestjs.cjs +7 -3
- package/scripts/patches/auto-instrument/nextjs-app-router.cjs +27 -9
- package/scripts/patches/auto-instrument/nextjs-pages.cjs +23 -10
- package/scripts/patches/auto-instrument/remix.cjs +7 -3
- package/scripts/patches/auto-instrument/sdk-helper-map.cjs +241 -0
- package/scripts/patches/auto-instrument/sveltekit.cjs +7 -3
- package/scripts/patches/auto-instrument/vue.cjs +7 -3
- 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]', '
|
|
462
|
-
.positional('action', {
|
|
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
|
@@ -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
|
|
388
|
-
if (!
|
|
389
|
-
const
|
|
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,
|
|
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
|
-
|
|
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.
|
|
383
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
197
|
-
* import already
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|