@gurulu/cli 0.1.1 → 0.1.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.
Files changed (54) hide show
  1. package/package.json +3 -2
  2. package/scripts/.gitkeep +0 -0
  3. package/scripts/README-gurulu-agentic-install.md +114 -0
  4. package/scripts/README-gurulu-scan.md +98 -0
  5. package/scripts/audit-cli-scopes.mjs +204 -0
  6. package/scripts/backfill-tenant-id.mjs +172 -0
  7. package/scripts/backfill-tenant-links.ts +252 -0
  8. package/scripts/backup-clickhouse.sh +27 -0
  9. package/scripts/backup-postgres.sh +19 -0
  10. package/scripts/bootstrap-runtime-schema.mjs +105 -0
  11. package/scripts/bootstrap-stripe.mjs +158 -0
  12. package/scripts/gurulu-agentic-install.lib.cjs +734 -0
  13. package/scripts/gurulu-agentic-install.mjs +343 -0
  14. package/scripts/gurulu-scan.lib.cjs +989 -0
  15. package/scripts/gurulu-scan.mjs +91 -0
  16. package/scripts/gurulu-verify-install.lib.cjs +334 -0
  17. package/scripts/gurulu-verify-install.mjs +59 -0
  18. package/scripts/init-ssl.sh +26 -0
  19. package/scripts/migrate-flow-graph-enums.sh +86 -0
  20. package/scripts/monitor-disk.sh +24 -0
  21. package/scripts/patches/astro.patch.cjs +73 -0
  22. package/scripts/patches/auto-instrument/ast-helper.cjs +332 -0
  23. package/scripts/patches/auto-instrument/astro.cjs +267 -0
  24. package/scripts/patches/auto-instrument/express.cjs +368 -0
  25. package/scripts/patches/auto-instrument/fastify.cjs +258 -0
  26. package/scripts/patches/auto-instrument/index.cjs +78 -0
  27. package/scripts/patches/auto-instrument/nestjs.cjs +282 -0
  28. package/scripts/patches/auto-instrument/nextjs-app-router.cjs +318 -0
  29. package/scripts/patches/auto-instrument/nextjs-pages.cjs +348 -0
  30. package/scripts/patches/auto-instrument/remix.cjs +164 -0
  31. package/scripts/patches/auto-instrument/singleton-helper.cjs +193 -0
  32. package/scripts/patches/auto-instrument/sveltekit.cjs +157 -0
  33. package/scripts/patches/auto-instrument/vite-react.cjs +37 -0
  34. package/scripts/patches/auto-instrument/vue.cjs +192 -0
  35. package/scripts/patches/express.patch.cjs +99 -0
  36. package/scripts/patches/fastify.patch.cjs +107 -0
  37. package/scripts/patches/index.cjs +294 -0
  38. package/scripts/patches/nestjs.patch.cjs +111 -0
  39. package/scripts/patches/nextjs-app-router.patch.cjs +95 -0
  40. package/scripts/patches/nextjs-pages.patch.cjs +96 -0
  41. package/scripts/patches/remix.patch.cjs +74 -0
  42. package/scripts/patches/sveltekit.patch.cjs +71 -0
  43. package/scripts/patches/vite-react.patch.cjs +72 -0
  44. package/scripts/patches/vue.patch.cjs +81 -0
  45. package/scripts/renew-ssl.sh +14 -0
  46. package/scripts/resolve-migration.sh +23 -0
  47. package/scripts/seed-cli-dev-keys.mjs +130 -0
  48. package/scripts/seed-test-data.mjs +391 -0
  49. package/scripts/spike-browserless.ts +65 -0
  50. package/scripts/tenant-pivot-consistency-check.mjs +205 -0
  51. package/scripts/tenant-pivot-phase-3-cleanup.lib.cjs +258 -0
  52. package/scripts/tenant-pivot-phase-3-cleanup.mjs +98 -0
  53. package/scripts/test-identity-resolution.ts +804 -0
  54. 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
+ };