@gurulu/cli 0.1.1 → 0.1.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/package.json +7 -3
- package/scripts/.gitkeep +0 -0
- package/scripts/README-gurulu-agentic-install.md +114 -0
- package/scripts/README-gurulu-scan.md +98 -0
- package/scripts/audit-cli-scopes.mjs +204 -0
- package/scripts/backfill-tenant-id.mjs +172 -0
- package/scripts/backfill-tenant-links.ts +252 -0
- package/scripts/backup-clickhouse.sh +27 -0
- package/scripts/backup-postgres.sh +19 -0
- package/scripts/bootstrap-runtime-schema.mjs +105 -0
- package/scripts/bootstrap-stripe.mjs +158 -0
- package/scripts/gurulu-agentic-install.lib.cjs +734 -0
- package/scripts/gurulu-agentic-install.mjs +343 -0
- package/scripts/gurulu-scan.lib.cjs +989 -0
- package/scripts/gurulu-scan.mjs +91 -0
- package/scripts/gurulu-verify-install.lib.cjs +334 -0
- package/scripts/gurulu-verify-install.mjs +59 -0
- package/scripts/init-ssl.sh +26 -0
- package/scripts/migrate-flow-graph-enums.sh +86 -0
- package/scripts/monitor-disk.sh +24 -0
- package/scripts/patches/astro.patch.cjs +73 -0
- package/scripts/patches/auto-instrument/ast-helper.cjs +332 -0
- package/scripts/patches/auto-instrument/astro.cjs +267 -0
- package/scripts/patches/auto-instrument/express.cjs +368 -0
- package/scripts/patches/auto-instrument/fastify.cjs +258 -0
- package/scripts/patches/auto-instrument/index.cjs +78 -0
- package/scripts/patches/auto-instrument/nestjs.cjs +282 -0
- package/scripts/patches/auto-instrument/nextjs-app-router.cjs +318 -0
- package/scripts/patches/auto-instrument/nextjs-pages.cjs +348 -0
- package/scripts/patches/auto-instrument/remix.cjs +164 -0
- package/scripts/patches/auto-instrument/singleton-helper.cjs +193 -0
- package/scripts/patches/auto-instrument/sveltekit.cjs +157 -0
- package/scripts/patches/auto-instrument/vite-react.cjs +37 -0
- package/scripts/patches/auto-instrument/vue.cjs +192 -0
- package/scripts/patches/express.patch.cjs +99 -0
- package/scripts/patches/fastify.patch.cjs +107 -0
- package/scripts/patches/index.cjs +294 -0
- package/scripts/patches/nestjs.patch.cjs +111 -0
- package/scripts/patches/nextjs-app-router.patch.cjs +95 -0
- package/scripts/patches/nextjs-pages.patch.cjs +96 -0
- package/scripts/patches/remix.patch.cjs +74 -0
- package/scripts/patches/sveltekit.patch.cjs +71 -0
- package/scripts/patches/vite-react.patch.cjs +72 -0
- package/scripts/patches/vue.patch.cjs +81 -0
- package/scripts/renew-ssl.sh +14 -0
- package/scripts/resolve-migration.sh +23 -0
- package/scripts/seed-cli-dev-keys.mjs +130 -0
- package/scripts/seed-test-data.mjs +391 -0
- package/scripts/spike-browserless.ts +65 -0
- package/scripts/tenant-pivot-consistency-check.mjs +205 -0
- package/scripts/tenant-pivot-phase-3-cleanup.lib.cjs +258 -0
- package/scripts/tenant-pivot-phase-3-cleanup.mjs +98 -0
- package/scripts/test-identity-resolution.ts +804 -0
- package/scripts/validate-gurulu-schemas.mjs +79 -0
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// scripts/patches/auto-instrument/ast-helper.cjs — Phase 20 W1 A1.
|
|
2
|
+
//
|
|
3
|
+
// Shared Babel AST helpers used by every auto-instrument module. The goal of
|
|
4
|
+
// this file is twofold:
|
|
5
|
+
//
|
|
6
|
+
// 1. Provide a single place where we resolve `@babel/parser`,
|
|
7
|
+
// `@babel/traverse`, `@babel/generator`, `@babel/types` so the patcher
|
|
8
|
+
// modules stay tiny and framework-focused.
|
|
9
|
+
// 2. Encode the shared instrumentation operations (parse, find function
|
|
10
|
+
// node, insert a `gurulu.track(...)` call before the last return, ensure
|
|
11
|
+
// the `gurulu` import, check idempotency marker, serialize back to
|
|
12
|
+
// source) in one place — consistent behaviour across NestJS, Remix,
|
|
13
|
+
// SvelteKit, Astro, Fastify, Vue patchers.
|
|
14
|
+
//
|
|
15
|
+
// None of these helpers mutate source strings directly; they parse an AST,
|
|
16
|
+
// apply transforms, then run `@babel/generator` to serialize the result.
|
|
17
|
+
// Parse errors are NOT swallowed — callers catch and fall back to regex.
|
|
18
|
+
|
|
19
|
+
const parser = require('@babel/parser');
|
|
20
|
+
// `@babel/traverse` exposes its default export under `.default` in CJS.
|
|
21
|
+
const traverseMod = require('@babel/traverse');
|
|
22
|
+
const traverse = traverseMod.default || traverseMod;
|
|
23
|
+
const generatorMod = require('@babel/generator');
|
|
24
|
+
const generate = generatorMod.default || generatorMod;
|
|
25
|
+
const t = require('@babel/types');
|
|
26
|
+
|
|
27
|
+
const IMPORT_LINE = "import { gurulu } from '@/lib/gurulu';";
|
|
28
|
+
const MARKER = '@gurulu-instrumented';
|
|
29
|
+
const MARKER_COMMENT = `// @gurulu-instrumented`;
|
|
30
|
+
|
|
31
|
+
const DEFAULT_PARSE_PLUGINS = [
|
|
32
|
+
'typescript',
|
|
33
|
+
'jsx',
|
|
34
|
+
'decorators-legacy',
|
|
35
|
+
'classProperties',
|
|
36
|
+
'classPrivateProperties',
|
|
37
|
+
'classPrivateMethods',
|
|
38
|
+
'topLevelAwait',
|
|
39
|
+
'importAssertions',
|
|
40
|
+
'importAttributes',
|
|
41
|
+
'explicitResourceManagement',
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Parse `content` into a Babel AST. Throws on parse error so callers can
|
|
46
|
+
* fall back to their legacy regex path.
|
|
47
|
+
*/
|
|
48
|
+
function parseSource(content) {
|
|
49
|
+
return parser.parse(content, {
|
|
50
|
+
sourceType: 'module',
|
|
51
|
+
allowReturnOutsideFunction: true,
|
|
52
|
+
allowAwaitOutsideFunction: true,
|
|
53
|
+
allowImportExportEverywhere: true,
|
|
54
|
+
errorRecovery: false,
|
|
55
|
+
plugins: DEFAULT_PARSE_PLUGINS,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Serialize an AST back to source. We preserve the original source for
|
|
61
|
+
* fidelity when possible; otherwise Babel re-prints using its default.
|
|
62
|
+
*/
|
|
63
|
+
function generateSource(ast, originalSource) {
|
|
64
|
+
const out = generate(
|
|
65
|
+
ast,
|
|
66
|
+
{
|
|
67
|
+
retainLines: false,
|
|
68
|
+
compact: false,
|
|
69
|
+
comments: true,
|
|
70
|
+
jsescOption: { minimal: true, quotes: 'single' },
|
|
71
|
+
},
|
|
72
|
+
originalSource,
|
|
73
|
+
);
|
|
74
|
+
return out.code;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Return true if `node` (function declaration / expression / arrow) carries
|
|
79
|
+
* a leading `@gurulu-instrumented` block/line comment. We look at
|
|
80
|
+
* `leadingComments` on the node AND on its parent export declaration (set by
|
|
81
|
+
* the caller when relevant).
|
|
82
|
+
*/
|
|
83
|
+
function hasInstrumentedMarker(node) {
|
|
84
|
+
const comments = (node && (node.leadingComments || [])) || [];
|
|
85
|
+
for (const c of comments) {
|
|
86
|
+
if (c && typeof c.value === 'string' && c.value.includes(MARKER)) return true;
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Attach the `@gurulu-instrumented` marker to a function node so future
|
|
93
|
+
* runs are idempotent.
|
|
94
|
+
*/
|
|
95
|
+
function tagInstrumented(node) {
|
|
96
|
+
t.addComment(node, 'leading', ` @gurulu-instrumented`, true);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Build the `gurulu.track('eventName', { ...properties })` statement. Empty
|
|
101
|
+
* object when no auto-properties; otherwise an ObjectExpression with the
|
|
102
|
+
* property AST nodes inlined.
|
|
103
|
+
*/
|
|
104
|
+
function buildTrackStatement(eventName, autoProperties) {
|
|
105
|
+
const args = [t.stringLiteral(eventName)];
|
|
106
|
+
if (Array.isArray(autoProperties) && autoProperties.length > 0) {
|
|
107
|
+
const props = [];
|
|
108
|
+
for (const p of autoProperties) {
|
|
109
|
+
if (!p || !p.name) continue;
|
|
110
|
+
// Allow `source` to be a JS expression like `req.body.orderId`.
|
|
111
|
+
let valueNode;
|
|
112
|
+
if (p.source && typeof p.source === 'string') {
|
|
113
|
+
try {
|
|
114
|
+
const parsedExpr = parser.parseExpression(p.source, {
|
|
115
|
+
plugins: DEFAULT_PARSE_PLUGINS,
|
|
116
|
+
});
|
|
117
|
+
valueNode = parsedExpr;
|
|
118
|
+
} catch (_) {
|
|
119
|
+
valueNode = t.stringLiteral(String(p.source));
|
|
120
|
+
}
|
|
121
|
+
} else {
|
|
122
|
+
valueNode = t.identifier('undefined');
|
|
123
|
+
}
|
|
124
|
+
props.push(
|
|
125
|
+
t.objectProperty(
|
|
126
|
+
t.identifier(toSafeIdentifier(p.name)),
|
|
127
|
+
valueNode,
|
|
128
|
+
false,
|
|
129
|
+
toSafeIdentifier(p.name) === p.name,
|
|
130
|
+
),
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
args.push(t.objectExpression(props));
|
|
134
|
+
} else {
|
|
135
|
+
args.push(t.objectExpression([]));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const call = t.expressionStatement(
|
|
139
|
+
t.callExpression(
|
|
140
|
+
t.memberExpression(t.identifier('gurulu'), t.identifier('track')),
|
|
141
|
+
args,
|
|
142
|
+
),
|
|
143
|
+
);
|
|
144
|
+
// Leading marker so humans can spot the insertion and so idempotency
|
|
145
|
+
// fallbacks (string-based) still see it.
|
|
146
|
+
t.addComment(
|
|
147
|
+
call,
|
|
148
|
+
'leading',
|
|
149
|
+
` @gurulu-instrumented ${eventName}`,
|
|
150
|
+
true,
|
|
151
|
+
);
|
|
152
|
+
return call;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function toSafeIdentifier(name) {
|
|
156
|
+
return String(name).replace(/[^A-Za-z0-9_$]/g, '_');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Given a function body (BlockStatement), inject `gurulu.track(...)`
|
|
161
|
+
* statements right before the LAST top-level `return` statement. If the
|
|
162
|
+
* function has no top-level return, append at the end of the body.
|
|
163
|
+
*
|
|
164
|
+
* The function tags the containing function node with the
|
|
165
|
+
* `@gurulu-instrumented` comment (via the `fn` param passed by the caller).
|
|
166
|
+
*
|
|
167
|
+
* Returns `true` if any statements were injected.
|
|
168
|
+
*/
|
|
169
|
+
function injectTrackBeforeLastReturn(fn, body, trackStatements) {
|
|
170
|
+
if (!body || !Array.isArray(body.body)) return false;
|
|
171
|
+
if (trackStatements.length === 0) return false;
|
|
172
|
+
|
|
173
|
+
// Find last top-level return.
|
|
174
|
+
let lastIdx = -1;
|
|
175
|
+
for (let i = body.body.length - 1; i >= 0; i--) {
|
|
176
|
+
if (t.isReturnStatement(body.body[i])) {
|
|
177
|
+
lastIdx = i;
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (lastIdx === -1) {
|
|
183
|
+
// No top-level return — insert BEFORE the last statement of the body
|
|
184
|
+
// so trailing `res.json(...)` / `reply.send(...)` calls still come after
|
|
185
|
+
// our track call (matching operator intuition + Phase 18.7 tests).
|
|
186
|
+
const insertAt = Math.max(0, body.body.length - 1);
|
|
187
|
+
body.body.splice(insertAt, 0, ...trackStatements);
|
|
188
|
+
} else {
|
|
189
|
+
body.body.splice(lastIdx, 0, ...trackStatements);
|
|
190
|
+
}
|
|
191
|
+
tagInstrumented(fn);
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Ensure `import { gurulu } from '@/lib/gurulu'` is present. No-op when the
|
|
197
|
+
* import already exists.
|
|
198
|
+
*/
|
|
199
|
+
function ensureGuruluImport(ast) {
|
|
200
|
+
let present = false;
|
|
201
|
+
let lastImportIdx = -1;
|
|
202
|
+
const body = ast.program.body;
|
|
203
|
+
for (let i = 0; i < body.length; i++) {
|
|
204
|
+
const node = body[i];
|
|
205
|
+
if (t.isImportDeclaration(node)) {
|
|
206
|
+
lastImportIdx = i;
|
|
207
|
+
if (node.source && node.source.value === '@/lib/gurulu') {
|
|
208
|
+
const hasNamed = (node.specifiers || []).some(
|
|
209
|
+
(s) => t.isImportSpecifier(s) && t.isIdentifier(s.imported) && s.imported.name === 'gurulu',
|
|
210
|
+
);
|
|
211
|
+
if (hasNamed) present = true;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (present) return;
|
|
216
|
+
const imp = t.importDeclaration(
|
|
217
|
+
[t.importSpecifier(t.identifier('gurulu'), t.identifier('gurulu'))],
|
|
218
|
+
t.stringLiteral('@/lib/gurulu'),
|
|
219
|
+
);
|
|
220
|
+
body.splice(lastImportIdx + 1, 0, imp);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Walk the AST and locate the underlying function node for a named export
|
|
225
|
+
* (handles `export async function NAME(){}`, `export const NAME = () => {}`,
|
|
226
|
+
* `export { NAME }` re-exports where NAME binds to a locally-defined function,
|
|
227
|
+
* and `export default` when `defaultName === true`).
|
|
228
|
+
*
|
|
229
|
+
* Returns an array of `{ fn, body }` entries (multiple when the name matches
|
|
230
|
+
* several export shapes in the same file, which is rare but tolerated).
|
|
231
|
+
*/
|
|
232
|
+
function findExportedFunction(ast, exportName, opts = {}) {
|
|
233
|
+
const results = [];
|
|
234
|
+
const wantDefault = !!opts.defaultExport;
|
|
235
|
+
|
|
236
|
+
// Resolve local bindings for later lookup of re-exports + const-init arrows.
|
|
237
|
+
const localBindings = new Map();
|
|
238
|
+
|
|
239
|
+
traverse(ast, {
|
|
240
|
+
FunctionDeclaration(pathNode) {
|
|
241
|
+
if (pathNode.node.id && pathNode.node.id.name) {
|
|
242
|
+
localBindings.set(pathNode.node.id.name, pathNode.node);
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
VariableDeclaration(pathNode) {
|
|
246
|
+
for (const decl of pathNode.node.declarations) {
|
|
247
|
+
if (!t.isIdentifier(decl.id)) continue;
|
|
248
|
+
if (!decl.init) continue;
|
|
249
|
+
if (
|
|
250
|
+
t.isArrowFunctionExpression(decl.init) ||
|
|
251
|
+
t.isFunctionExpression(decl.init)
|
|
252
|
+
) {
|
|
253
|
+
localBindings.set(decl.id.name, decl.init);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
traverse(ast, {
|
|
260
|
+
ExportNamedDeclaration(pathNode) {
|
|
261
|
+
const decl = pathNode.node.declaration;
|
|
262
|
+
if (decl) {
|
|
263
|
+
if (t.isFunctionDeclaration(decl) && decl.id && decl.id.name === exportName) {
|
|
264
|
+
results.push({ fn: decl, body: decl.body });
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (t.isVariableDeclaration(decl)) {
|
|
268
|
+
for (const d of decl.declarations) {
|
|
269
|
+
if (!t.isIdentifier(d.id) || d.id.name !== exportName) continue;
|
|
270
|
+
if (t.isArrowFunctionExpression(d.init) || t.isFunctionExpression(d.init)) {
|
|
271
|
+
// Arrow functions need a block body for injection.
|
|
272
|
+
if (t.isBlockStatement(d.init.body)) {
|
|
273
|
+
results.push({ fn: d.init, body: d.init.body });
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
// `export { foo }` re-export referencing local binding.
|
|
280
|
+
for (const spec of pathNode.node.specifiers || []) {
|
|
281
|
+
if (!t.isExportSpecifier(spec)) continue;
|
|
282
|
+
const exported = t.isIdentifier(spec.exported) ? spec.exported.name : null;
|
|
283
|
+
if (exported !== exportName) continue;
|
|
284
|
+
const localName = t.isIdentifier(spec.local) ? spec.local.name : null;
|
|
285
|
+
if (!localName) continue;
|
|
286
|
+
const bound = localBindings.get(localName);
|
|
287
|
+
if (!bound) continue;
|
|
288
|
+
if (t.isBlockStatement(bound.body)) {
|
|
289
|
+
results.push({ fn: bound, body: bound.body });
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
},
|
|
293
|
+
ExportDefaultDeclaration(pathNode) {
|
|
294
|
+
if (!wantDefault) return;
|
|
295
|
+
const decl = pathNode.node.declaration;
|
|
296
|
+
if (t.isFunctionDeclaration(decl) || t.isFunctionExpression(decl)) {
|
|
297
|
+
if (t.isBlockStatement(decl.body)) {
|
|
298
|
+
results.push({ fn: decl, body: decl.body });
|
|
299
|
+
}
|
|
300
|
+
} else if (t.isArrowFunctionExpression(decl)) {
|
|
301
|
+
if (t.isBlockStatement(decl.body)) {
|
|
302
|
+
results.push({ fn: decl, body: decl.body });
|
|
303
|
+
}
|
|
304
|
+
} else if (t.isIdentifier(decl)) {
|
|
305
|
+
const bound = localBindings.get(decl.name);
|
|
306
|
+
if (bound && t.isBlockStatement(bound.body)) {
|
|
307
|
+
results.push({ fn: bound, body: bound.body });
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
},
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
return results;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
module.exports = {
|
|
317
|
+
parser,
|
|
318
|
+
traverse,
|
|
319
|
+
generate,
|
|
320
|
+
t,
|
|
321
|
+
parseSource,
|
|
322
|
+
generateSource,
|
|
323
|
+
hasInstrumentedMarker,
|
|
324
|
+
tagInstrumented,
|
|
325
|
+
buildTrackStatement,
|
|
326
|
+
injectTrackBeforeLastReturn,
|
|
327
|
+
ensureGuruluImport,
|
|
328
|
+
findExportedFunction,
|
|
329
|
+
IMPORT_LINE,
|
|
330
|
+
MARKER,
|
|
331
|
+
MARKER_COMMENT,
|
|
332
|
+
};
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// scripts/patches/auto-instrument/astro.cjs — Phase 20 W1 A3.
|
|
2
|
+
//
|
|
3
|
+
// Astro API endpoints live at `src/pages/api/**.{ts,js}` and export HTTP
|
|
4
|
+
// verb functions (`GET`, `POST`, ...). `.astro` components with a server
|
|
5
|
+
// frontmatter block (between `---` fences) are also supported: the patcher
|
|
6
|
+
// extracts the frontmatter JS/TS, detects server-side fetch/data-loading
|
|
7
|
+
// patterns, and injects `gurulu.track()` calls.
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
|
|
12
|
+
const ast = require('./ast-helper.cjs');
|
|
13
|
+
const singleton = require('./singleton-helper.cjs');
|
|
14
|
+
|
|
15
|
+
const NAME = 'auto-instrument-astro';
|
|
16
|
+
const SUPPORTED = ['astro'];
|
|
17
|
+
|
|
18
|
+
function parseEventRoute(routeStr) {
|
|
19
|
+
if (!routeStr || typeof routeStr !== 'string') return null;
|
|
20
|
+
const m = routeStr.trim().match(/^([A-Z]+)\s+(\/.*)$/);
|
|
21
|
+
if (!m) return null;
|
|
22
|
+
return { method: m[1].toUpperCase(), urlPath: m[2].replace(/\/+$/, '') || '/' };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function routeToCandidates(urlPath) {
|
|
26
|
+
const cleaned = urlPath.replace(/^\//, '').replace(/\/+$/, '');
|
|
27
|
+
const segments = cleaned.split('/').filter(Boolean);
|
|
28
|
+
const exts = ['ts', 'js', 'mjs'];
|
|
29
|
+
const out = [];
|
|
30
|
+
for (const ext of exts) {
|
|
31
|
+
out.push(path.posix.join('src', 'pages', ...segments) + `.${ext}`);
|
|
32
|
+
out.push(path.posix.join('src', 'pages', ...segments, `index.${ext}`));
|
|
33
|
+
}
|
|
34
|
+
// .astro component files (page routes, not API endpoints)
|
|
35
|
+
out.push(path.posix.join('src', 'pages', ...segments) + '.astro');
|
|
36
|
+
out.push(path.posix.join('src', 'pages', ...segments, 'index.astro'));
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// .astro frontmatter support
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Server-side data-loading patterns we look for inside .astro frontmatter.
|
|
48
|
+
* If any match, we consider the frontmatter instrumentable.
|
|
49
|
+
*/
|
|
50
|
+
const DATA_LOADING_PATTERNS = [
|
|
51
|
+
/\bfetch\s*\(/,
|
|
52
|
+
/\bawait\b/,
|
|
53
|
+
/\bgetCollection\s*\(/,
|
|
54
|
+
/\bgetEntryBySlug\s*\(/,
|
|
55
|
+
/\bAstro\s*\.\s*glob\s*\(/,
|
|
56
|
+
/\bimport\s*\(/, // dynamic import
|
|
57
|
+
/\.findMany\s*\(/,
|
|
58
|
+
/\.find\s*\(/,
|
|
59
|
+
/\.query\s*\(/,
|
|
60
|
+
/\.select\s*\(/,
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
function hasDataLoadingPattern(frontmatterSource) {
|
|
64
|
+
return DATA_LOADING_PATTERNS.some((re) => re.test(frontmatterSource));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Instrument an .astro file's frontmatter block. Parses the JS/TS inside the
|
|
69
|
+
* `---` fences via the shared AST helper, injects gurulu.track() calls, and
|
|
70
|
+
* reconstructs the full .astro file.
|
|
71
|
+
*/
|
|
72
|
+
function astInstrumentFrontmatter(source, events) {
|
|
73
|
+
const fmMatch = source.match(FRONTMATTER_RE);
|
|
74
|
+
if (!fmMatch) return { ok: false, reason: 'no-frontmatter' };
|
|
75
|
+
|
|
76
|
+
const frontmatter = fmMatch[1];
|
|
77
|
+
if (!hasDataLoadingPattern(frontmatter)) {
|
|
78
|
+
return { ok: false, reason: 'no-data-loading-pattern' };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Check idempotency — if the frontmatter already has the marker, skip.
|
|
82
|
+
if (frontmatter.includes(ast.MARKER)) {
|
|
83
|
+
return {
|
|
84
|
+
ok: true,
|
|
85
|
+
after: source,
|
|
86
|
+
instrumented: [],
|
|
87
|
+
skipped: events.map((e) => ({ event: e.name, reason: 'already-instrumented' })),
|
|
88
|
+
changed: false,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Parse the frontmatter as a standalone JS/TS module.
|
|
93
|
+
const tree = ast.parseSource(frontmatter);
|
|
94
|
+
|
|
95
|
+
// Build track statements for all events.
|
|
96
|
+
const stmts = events.map((e) => ast.buildTrackStatement(e.name, e.autoProperties));
|
|
97
|
+
|
|
98
|
+
// Inject before last return or at end of program body.
|
|
99
|
+
const body = tree.program;
|
|
100
|
+
let lastReturnIdx = -1;
|
|
101
|
+
for (let i = body.body.length - 1; i >= 0; i--) {
|
|
102
|
+
if (ast.t.isReturnStatement(body.body[i])) {
|
|
103
|
+
lastReturnIdx = i;
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Add marker comment to first track statement
|
|
109
|
+
if (stmts.length > 0) {
|
|
110
|
+
// Tag the program itself with a comment (will appear in generated source)
|
|
111
|
+
const markerComment = ast.t.addComment(
|
|
112
|
+
body.body[0] || stmts[0],
|
|
113
|
+
'leading',
|
|
114
|
+
` @gurulu-instrumented`,
|
|
115
|
+
true,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (lastReturnIdx >= 0) {
|
|
120
|
+
body.body.splice(lastReturnIdx, 0, ...stmts);
|
|
121
|
+
} else {
|
|
122
|
+
// Append at end of frontmatter
|
|
123
|
+
body.body.push(...stmts);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
ast.ensureGuruluImport(tree);
|
|
127
|
+
const newFrontmatter = ast.generateSource(tree, frontmatter);
|
|
128
|
+
|
|
129
|
+
// Reconstruct the full .astro file
|
|
130
|
+
const beforeFm = source.substring(0, fmMatch.index);
|
|
131
|
+
const afterFm = source.substring(fmMatch.index + fmMatch[0].length);
|
|
132
|
+
const after = `${beforeFm}---\n${newFrontmatter}\n---${afterFm}`;
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
ok: true,
|
|
136
|
+
after,
|
|
137
|
+
instrumented: events.map((e) => e.name),
|
|
138
|
+
skipped: [],
|
|
139
|
+
changed: true,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function astInstrumentFile(source, method, events) {
|
|
144
|
+
const tree = ast.parseSource(source);
|
|
145
|
+
const fns = ast.findExportedFunction(tree, method);
|
|
146
|
+
if (fns.length === 0) return { ok: false, reason: `${method}-not-found` };
|
|
147
|
+
const target = fns[0];
|
|
148
|
+
if (ast.hasInstrumentedMarker(target.fn)) {
|
|
149
|
+
return {
|
|
150
|
+
ok: true,
|
|
151
|
+
after: source,
|
|
152
|
+
instrumented: [],
|
|
153
|
+
skipped: events.map((e) => ({ event: e.name, reason: 'already-instrumented' })),
|
|
154
|
+
changed: false,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
const stmts = events.map((e) => ast.buildTrackStatement(e.name, e.autoProperties));
|
|
158
|
+
ast.injectTrackBeforeLastReturn(target.fn, target.body, stmts);
|
|
159
|
+
ast.ensureGuruluImport(tree);
|
|
160
|
+
const after = ast.generateSource(tree, source);
|
|
161
|
+
return {
|
|
162
|
+
ok: true,
|
|
163
|
+
after,
|
|
164
|
+
instrumented: events.map((e) => e.name),
|
|
165
|
+
skipped: [],
|
|
166
|
+
changed: true,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function instrumentEvents(ctx, events) {
|
|
171
|
+
const helper = singleton.ensureSingletonHelper(ctx, 'astro');
|
|
172
|
+
const changes = [...helper.changes];
|
|
173
|
+
const notes = [...helper.notes];
|
|
174
|
+
let eventsInstrumented = 0;
|
|
175
|
+
let eventsSkipped = 0;
|
|
176
|
+
|
|
177
|
+
if (helper.collision) {
|
|
178
|
+
return {
|
|
179
|
+
changes: [],
|
|
180
|
+
notes,
|
|
181
|
+
filesModified: 0,
|
|
182
|
+
eventsInstrumented: 0,
|
|
183
|
+
eventsSkipped: events ? events.length : 0,
|
|
184
|
+
collision: true,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const groups = new Map();
|
|
189
|
+
for (const e of events || []) {
|
|
190
|
+
const parsed = parseEventRoute(e && e.source && e.source.route);
|
|
191
|
+
if (!parsed) {
|
|
192
|
+
notes.push(`skip:${e && e.name}:no-source-route`);
|
|
193
|
+
eventsSkipped++;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const candidates = routeToCandidates(parsed.urlPath);
|
|
197
|
+
const found = candidates.find((rel) => fs.existsSync(path.join(ctx.repoRoot, rel)));
|
|
198
|
+
if (!found) {
|
|
199
|
+
notes.push(`skip:${e.name}:route-file-not-found`);
|
|
200
|
+
eventsSkipped++;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
const key = `${found}::${parsed.method}`;
|
|
204
|
+
if (!groups.has(key)) groups.set(key, { relPath: found, method: parsed.method, events: [] });
|
|
205
|
+
groups.get(key).events.push(e);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
for (const group of groups.values()) {
|
|
209
|
+
const abs = path.join(ctx.repoRoot, group.relPath);
|
|
210
|
+
const before = fs.readFileSync(abs, 'utf8');
|
|
211
|
+
let res;
|
|
212
|
+
try {
|
|
213
|
+
const isAstroComponent = group.relPath.endsWith('.astro');
|
|
214
|
+
res = isAstroComponent
|
|
215
|
+
? astInstrumentFrontmatter(before, group.events)
|
|
216
|
+
: astInstrumentFile(before, group.method, group.events);
|
|
217
|
+
} catch (err) {
|
|
218
|
+
const msg = (err && err.message) || String(err);
|
|
219
|
+
// eslint-disable-next-line no-console
|
|
220
|
+
console.warn(`[auto-instrument] patch.fallback ${group.relPath}: ${msg}`);
|
|
221
|
+
notes.push(`patch.fallback:${group.relPath}:${msg}`);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (!res.ok) {
|
|
225
|
+
for (const e of group.events) {
|
|
226
|
+
notes.push(`skip:${e.name}:${res.reason}`);
|
|
227
|
+
eventsSkipped++;
|
|
228
|
+
}
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
for (const s of res.skipped || []) {
|
|
232
|
+
notes.push(`skip:${s.event}:${s.reason}`);
|
|
233
|
+
eventsSkipped++;
|
|
234
|
+
}
|
|
235
|
+
if (res.changed) {
|
|
236
|
+
changes.push({
|
|
237
|
+
relPath: group.relPath,
|
|
238
|
+
before,
|
|
239
|
+
after: res.after,
|
|
240
|
+
reason: `auto-instrument-astro`,
|
|
241
|
+
type: 'auto-instrument',
|
|
242
|
+
});
|
|
243
|
+
eventsInstrumented += (res.instrumented || []).length;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const filesModified = changes.filter((c) => c.type === 'auto-instrument').length;
|
|
248
|
+
return { changes, notes, filesModified, eventsInstrumented, eventsSkipped, collision: false };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function ensureSingletonHelper(ctx) {
|
|
252
|
+
return singleton.ensureSingletonHelper(ctx, 'astro');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
module.exports = {
|
|
256
|
+
name: NAME,
|
|
257
|
+
supportedFrameworks: SUPPORTED,
|
|
258
|
+
ensureSingletonHelper,
|
|
259
|
+
instrumentEvents,
|
|
260
|
+
_internals: {
|
|
261
|
+
parseEventRoute,
|
|
262
|
+
routeToCandidates,
|
|
263
|
+
astInstrumentFile,
|
|
264
|
+
astInstrumentFrontmatter,
|
|
265
|
+
hasDataLoadingPattern,
|
|
266
|
+
},
|
|
267
|
+
};
|