@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 +1 -1
- package/scripts/gurulu-agentic-install.mjs +50 -3
- 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/package.json
CHANGED
|
@@ -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.
|
|
383
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) +
|
|
217
|
+
return source.slice(0, end) + importLine + '\n' + source.slice(end);
|
|
209
218
|
}
|
|
210
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) +
|
|
116
|
+
return { source: source.slice(0, end) + importLine + '\n' + source.slice(end), added: true };
|
|
102
117
|
}
|
|
103
|
-
return { source:
|
|
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
|
-
|
|
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
|
-
|
|
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) +
|
|
144
|
+
return source.slice(0, end) + importLine + '\n' + source.slice(end);
|
|
134
145
|
}
|
|
135
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|