@massu/core 0.1.0 → 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 (67) hide show
  1. package/LICENSE +71 -0
  2. package/README.md +2 -2
  3. package/dist/hooks/cost-tracker.js +149 -11527
  4. package/dist/hooks/post-edit-context.js +127 -11493
  5. package/dist/hooks/post-tool-use.js +169 -11550
  6. package/dist/hooks/pre-compact.js +149 -11530
  7. package/dist/hooks/pre-delete-check.js +144 -11523
  8. package/dist/hooks/quality-event.js +149 -11527
  9. package/dist/hooks/session-end.js +188 -11570
  10. package/dist/hooks/session-start.js +159 -11534
  11. package/dist/hooks/user-prompt.js +149 -11530
  12. package/package.json +14 -19
  13. package/src/adr-generator.ts +292 -0
  14. package/src/analytics.ts +373 -0
  15. package/src/audit-trail.ts +450 -0
  16. package/src/backfill-sessions.ts +180 -0
  17. package/src/cli.ts +105 -0
  18. package/src/cloud-sync.ts +190 -0
  19. package/src/commands/doctor.ts +300 -0
  20. package/src/commands/init.ts +395 -0
  21. package/src/commands/install-hooks.ts +26 -0
  22. package/src/config.ts +357 -0
  23. package/src/cost-tracker.ts +355 -0
  24. package/src/db.ts +233 -0
  25. package/src/dependency-scorer.ts +337 -0
  26. package/src/docs-map.json +100 -0
  27. package/src/docs-tools.ts +517 -0
  28. package/src/domains.ts +181 -0
  29. package/src/hooks/cost-tracker.ts +66 -0
  30. package/src/hooks/intent-suggester.ts +131 -0
  31. package/src/hooks/post-edit-context.ts +91 -0
  32. package/src/hooks/post-tool-use.ts +175 -0
  33. package/src/hooks/pre-compact.ts +146 -0
  34. package/src/hooks/pre-delete-check.ts +153 -0
  35. package/src/hooks/quality-event.ts +127 -0
  36. package/src/hooks/security-gate.ts +121 -0
  37. package/src/hooks/session-end.ts +467 -0
  38. package/src/hooks/session-start.ts +210 -0
  39. package/src/hooks/user-prompt.ts +91 -0
  40. package/src/import-resolver.ts +224 -0
  41. package/src/memory-db.ts +1376 -0
  42. package/src/memory-tools.ts +391 -0
  43. package/src/middleware-tree.ts +70 -0
  44. package/src/observability-tools.ts +343 -0
  45. package/src/observation-extractor.ts +411 -0
  46. package/src/page-deps.ts +283 -0
  47. package/src/prompt-analyzer.ts +332 -0
  48. package/src/regression-detector.ts +319 -0
  49. package/src/rules.ts +57 -0
  50. package/src/schema-mapper.ts +232 -0
  51. package/src/security-scorer.ts +405 -0
  52. package/src/security-utils.ts +133 -0
  53. package/src/sentinel-db.ts +578 -0
  54. package/src/sentinel-scanner.ts +405 -0
  55. package/src/sentinel-tools.ts +512 -0
  56. package/src/sentinel-types.ts +140 -0
  57. package/src/server.ts +189 -0
  58. package/src/session-archiver.ts +112 -0
  59. package/src/session-state-generator.ts +174 -0
  60. package/src/team-knowledge.ts +407 -0
  61. package/src/tools.ts +847 -0
  62. package/src/transcript-parser.ts +458 -0
  63. package/src/trpc-index.ts +214 -0
  64. package/src/validate-features-runner.ts +106 -0
  65. package/src/validation-engine.ts +358 -0
  66. package/dist/cli.js +0 -7890
  67. package/dist/server.js +0 -7008
@@ -0,0 +1,458 @@
1
+ // Copyright (c) 2026 Massu. All rights reserved.
2
+ // Licensed under BSL 1.1 - see LICENSE file for details.
3
+
4
+ import { createReadStream } from 'fs';
5
+ import { createInterface } from 'readline';
6
+
7
+ // ============================================================
8
+ // P2-001: JSONL Transcript Parser
9
+ // ============================================================
10
+
11
+ /**
12
+ * Represents a parsed transcript entry.
13
+ */
14
+ export interface TranscriptEntry {
15
+ type: 'user' | 'assistant' | 'system' | 'progress' | 'summary' | 'file-history-snapshot' | 'unknown';
16
+ sessionId?: string;
17
+ gitBranch?: string;
18
+ timestamp?: string;
19
+ uuid?: string;
20
+ isMeta?: boolean;
21
+ message?: TranscriptMessage;
22
+ data?: Record<string, unknown>;
23
+ }
24
+
25
+ export interface TranscriptMessage {
26
+ role: 'user' | 'assistant' | 'system';
27
+ content: TranscriptContentBlock[];
28
+ }
29
+
30
+ export type TranscriptContentBlock =
31
+ | { type: 'text'; text: string }
32
+ | { type: 'tool_use'; id: string; name: string; input: Record<string, unknown> }
33
+ | { type: 'tool_result'; tool_use_id: string; content: string | TranscriptContentBlock[]; is_error?: boolean }
34
+ | { type: string; [key: string]: unknown };
35
+
36
+ /**
37
+ * Parsed tool call with input and result linked.
38
+ */
39
+ export interface ParsedToolCall {
40
+ toolName: string;
41
+ toolUseId: string;
42
+ input: Record<string, unknown>;
43
+ result?: string;
44
+ isError?: boolean;
45
+ timestamp?: string;
46
+ }
47
+
48
+ /**
49
+ * File operation extracted from tool calls.
50
+ */
51
+ export interface FileOperation {
52
+ type: 'read' | 'write' | 'edit' | 'glob' | 'grep' | 'delete';
53
+ filePath: string;
54
+ toolName: string;
55
+ }
56
+
57
+ /**
58
+ * Extracted decision from assistant text.
59
+ */
60
+ export interface ExtractedDecision {
61
+ text: string;
62
+ context: string;
63
+ }
64
+
65
+ /**
66
+ * Extracted failed attempt from assistant text.
67
+ */
68
+ export interface ExtractedFailedAttempt {
69
+ text: string;
70
+ context: string;
71
+ }
72
+
73
+ /**
74
+ * Parse a JSONL transcript file line-by-line (streaming).
75
+ * Handles 400MB+ files without loading entire file into memory.
76
+ */
77
+ export async function parseTranscript(filePath: string): Promise<TranscriptEntry[]> {
78
+ const entries: TranscriptEntry[] = [];
79
+
80
+ const rl = createInterface({
81
+ input: createReadStream(filePath, { encoding: 'utf-8' }),
82
+ crlfDelay: Infinity,
83
+ });
84
+
85
+ for await (const line of rl) {
86
+ const trimmed = line.trim();
87
+ if (!trimmed) continue;
88
+
89
+ try {
90
+ const raw = JSON.parse(trimmed) as Record<string, unknown>;
91
+ const entry = parseEntry(raw);
92
+ if (entry) {
93
+ entries.push(entry);
94
+ }
95
+ } catch (_e) {
96
+ // Skip unparseable lines - defensive parsing
97
+ continue;
98
+ }
99
+ }
100
+
101
+ return entries;
102
+ }
103
+
104
+ /**
105
+ * Parse a single JSONL entry into a TranscriptEntry.
106
+ */
107
+ function parseEntry(raw: Record<string, unknown>): TranscriptEntry | null {
108
+ const entryType = raw.type as string | undefined;
109
+ if (!entryType) return null;
110
+
111
+ const base: TranscriptEntry = {
112
+ type: (['user', 'assistant', 'system', 'progress', 'summary', 'file-history-snapshot'].includes(entryType)
113
+ ? entryType
114
+ : 'unknown') as TranscriptEntry['type'],
115
+ sessionId: raw.sessionId as string | undefined,
116
+ gitBranch: raw.gitBranch as string | undefined,
117
+ timestamp: raw.timestamp as string | undefined,
118
+ uuid: raw.uuid as string | undefined,
119
+ };
120
+
121
+ if (raw.isMeta) {
122
+ base.isMeta = true;
123
+ }
124
+
125
+ if (entryType === 'user' || entryType === 'assistant') {
126
+ const msgRaw = raw.message as Record<string, unknown> | undefined;
127
+ if (msgRaw) {
128
+ base.message = {
129
+ role: (msgRaw.role as string ?? entryType) as 'user' | 'assistant',
130
+ content: normalizeContent(msgRaw.content),
131
+ };
132
+ }
133
+ }
134
+
135
+ if (entryType === 'progress') {
136
+ base.data = raw.data as Record<string, unknown> | undefined;
137
+ }
138
+
139
+ return base;
140
+ }
141
+
142
+ /**
143
+ * Normalize content to array of content blocks.
144
+ */
145
+ function normalizeContent(content: unknown): TranscriptContentBlock[] {
146
+ if (!content) return [];
147
+ if (typeof content === 'string') {
148
+ return [{ type: 'text', text: content }];
149
+ }
150
+ if (Array.isArray(content)) {
151
+ return content.filter((block): block is TranscriptContentBlock =>
152
+ typeof block === 'object' && block !== null && 'type' in block
153
+ );
154
+ }
155
+ return [];
156
+ }
157
+
158
+ // ============================================================
159
+ // Extraction utilities
160
+ // ============================================================
161
+
162
+ /**
163
+ * Extract all user messages from transcript entries.
164
+ */
165
+ export function extractUserMessages(entries: TranscriptEntry[]): Array<{ text: string; timestamp?: string }> {
166
+ const messages: Array<{ text: string; timestamp?: string }> = [];
167
+ for (const entry of entries) {
168
+ if (entry.type !== 'user' || !entry.message) continue;
169
+ // Skip meta/system messages
170
+ if (entry.isMeta) continue;
171
+
172
+ const text = getTextFromContent(entry.message.content);
173
+ if (text.trim()) {
174
+ messages.push({ text: text.trim(), timestamp: entry.timestamp });
175
+ }
176
+ }
177
+ return messages;
178
+ }
179
+
180
+ /**
181
+ * Extract all assistant text messages.
182
+ */
183
+ export function extractAssistantMessages(entries: TranscriptEntry[]): Array<{ text: string; timestamp?: string }> {
184
+ const messages: Array<{ text: string; timestamp?: string }> = [];
185
+ for (const entry of entries) {
186
+ if (entry.type !== 'assistant' || !entry.message) continue;
187
+ const text = getTextFromContent(entry.message.content);
188
+ if (text.trim()) {
189
+ messages.push({ text: text.trim(), timestamp: entry.timestamp });
190
+ }
191
+ }
192
+ return messages;
193
+ }
194
+
195
+ /**
196
+ * Extract all tool calls from transcript entries.
197
+ */
198
+ export function extractToolCalls(entries: TranscriptEntry[]): ParsedToolCall[] {
199
+ const toolCalls: ParsedToolCall[] = [];
200
+ const toolUseMap = new Map<string, ParsedToolCall>();
201
+
202
+ for (const entry of entries) {
203
+ if (!entry.message?.content) continue;
204
+
205
+ for (const block of entry.message.content) {
206
+ if (block.type === 'tool_use') {
207
+ const tc: ParsedToolCall = {
208
+ toolName: (block as { name: string }).name,
209
+ toolUseId: (block as { id: string }).id,
210
+ input: (block as { input: Record<string, unknown> }).input ?? {},
211
+ timestamp: entry.timestamp,
212
+ };
213
+ toolCalls.push(tc);
214
+ toolUseMap.set(tc.toolUseId, tc);
215
+ } else if (block.type === 'tool_result') {
216
+ const toolUseId = (block as { tool_use_id: string }).tool_use_id;
217
+ const existing = toolUseMap.get(toolUseId);
218
+ if (existing) {
219
+ existing.result = getToolResultText(block);
220
+ existing.isError = (block as { is_error?: boolean }).is_error ?? false;
221
+ }
222
+ }
223
+ }
224
+ }
225
+
226
+ return toolCalls;
227
+ }
228
+
229
+ /**
230
+ * Extract file operations from tool calls.
231
+ */
232
+ export function extractFileOperations(toolCalls: ParsedToolCall[]): FileOperation[] {
233
+ const ops: FileOperation[] = [];
234
+
235
+ for (const tc of toolCalls) {
236
+ switch (tc.toolName) {
237
+ case 'Read': {
238
+ const filePath = tc.input.file_path as string;
239
+ if (filePath) ops.push({ type: 'read', filePath, toolName: 'Read' });
240
+ break;
241
+ }
242
+ case 'Write': {
243
+ const filePath = tc.input.file_path as string;
244
+ if (filePath) ops.push({ type: 'write', filePath, toolName: 'Write' });
245
+ break;
246
+ }
247
+ case 'Edit': {
248
+ const filePath = tc.input.file_path as string;
249
+ if (filePath) ops.push({ type: 'edit', filePath, toolName: 'Edit' });
250
+ break;
251
+ }
252
+ case 'Glob': {
253
+ ops.push({ type: 'glob', filePath: tc.input.pattern as string ?? '', toolName: 'Glob' });
254
+ break;
255
+ }
256
+ case 'Grep': {
257
+ ops.push({ type: 'grep', filePath: tc.input.path as string ?? '', toolName: 'Grep' });
258
+ break;
259
+ }
260
+ }
261
+ }
262
+
263
+ return ops;
264
+ }
265
+
266
+ /**
267
+ * Extract verification commands from tool calls.
268
+ */
269
+ export function extractVerificationCommands(toolCalls: ParsedToolCall[]): Array<{
270
+ vrType: string;
271
+ command: string;
272
+ result: string;
273
+ passed: boolean;
274
+ }> {
275
+ const verifications: Array<{
276
+ vrType: string;
277
+ command: string;
278
+ result: string;
279
+ passed: boolean;
280
+ }> = [];
281
+
282
+ for (const tc of toolCalls) {
283
+ if (tc.toolName !== 'Bash') continue;
284
+ const cmd = tc.input.command as string ?? '';
285
+ const result = tc.result ?? '';
286
+
287
+ // Pattern scanner
288
+ if (cmd.includes('pattern-scanner')) {
289
+ verifications.push({
290
+ vrType: 'VR-PATTERN',
291
+ command: cmd,
292
+ result: result.slice(0, 500),
293
+ passed: !result.includes('FAIL') && !result.includes('BLOCKED'),
294
+ });
295
+ }
296
+ // Build
297
+ if (cmd.includes('npm run build')) {
298
+ verifications.push({
299
+ vrType: 'VR-BUILD',
300
+ command: cmd,
301
+ result: result.slice(0, 500),
302
+ passed: !tc.isError && !result.includes('error'),
303
+ });
304
+ }
305
+ // Type check
306
+ if (cmd.includes('tsc --noEmit')) {
307
+ verifications.push({
308
+ vrType: 'VR-TYPE',
309
+ command: cmd,
310
+ result: result.slice(0, 500),
311
+ passed: !tc.isError && !result.includes('error'),
312
+ });
313
+ }
314
+ // Tests
315
+ if (cmd.includes('npm test') || cmd.includes('vitest run') || cmd.includes('vitest ')) {
316
+ verifications.push({
317
+ vrType: 'VR-TEST',
318
+ command: cmd,
319
+ result: result.slice(0, 500),
320
+ passed: !tc.isError && !result.includes('FAIL'),
321
+ });
322
+ }
323
+ }
324
+
325
+ return verifications;
326
+ }
327
+
328
+ /**
329
+ * Extract decisions from assistant text (heuristic).
330
+ */
331
+ export function extractDecisions(entries: TranscriptEntry[]): ExtractedDecision[] {
332
+ const decisions: ExtractedDecision[] = [];
333
+ const decisionPatterns = /\b(decided|chose|chosen|decision|instead of|opted for|going with|approach:|strategy:)\b/i;
334
+
335
+ for (const entry of entries) {
336
+ if (entry.type !== 'assistant' || !entry.message) continue;
337
+ const text = getTextFromContent(entry.message.content);
338
+ if (!text) continue;
339
+
340
+ // Split into sentences/paragraphs
341
+ const paragraphs = text.split(/\n\n|\.\s+/);
342
+ for (const para of paragraphs) {
343
+ if (decisionPatterns.test(para) && para.length > 20 && para.length < 500) {
344
+ decisions.push({
345
+ text: para.trim().slice(0, 300),
346
+ context: text.slice(0, 200),
347
+ });
348
+ }
349
+ }
350
+ }
351
+
352
+ return decisions;
353
+ }
354
+
355
+ /**
356
+ * Extract failed attempts from assistant text (heuristic).
357
+ */
358
+ export function extractFailedAttempts(entries: TranscriptEntry[]): ExtractedFailedAttempt[] {
359
+ const failures: ExtractedFailedAttempt[] = [];
360
+ const failurePatterns = /\b(error|failed|doesn't work|didn't work|reverted|rolled back|bug|broken|issue:|problem:)\b/i;
361
+
362
+ for (const entry of entries) {
363
+ if (entry.type !== 'assistant' || !entry.message) continue;
364
+ const text = getTextFromContent(entry.message.content);
365
+ if (!text) continue;
366
+
367
+ const paragraphs = text.split(/\n\n|\.\s+/);
368
+ for (const para of paragraphs) {
369
+ if (failurePatterns.test(para) && para.length > 20 && para.length < 500) {
370
+ failures.push({
371
+ text: para.trim().slice(0, 300),
372
+ context: text.slice(0, 200),
373
+ });
374
+ }
375
+ }
376
+ }
377
+
378
+ return failures;
379
+ }
380
+
381
+ /**
382
+ * Parse a JSONL transcript file starting from a specific line (for incremental parsing).
383
+ * Skips the first `startLine` lines and returns entries from the rest.
384
+ */
385
+ export async function parseTranscriptFrom(filePath: string, startLine: number): Promise<{ entries: TranscriptEntry[]; totalLines: number }> {
386
+ const entries: TranscriptEntry[] = [];
387
+ let lineNumber = 0;
388
+
389
+ const rl = createInterface({
390
+ input: createReadStream(filePath, { encoding: 'utf-8' }),
391
+ crlfDelay: Infinity,
392
+ });
393
+
394
+ for await (const line of rl) {
395
+ lineNumber++;
396
+ if (lineNumber <= startLine) continue;
397
+
398
+ const trimmed = line.trim();
399
+ if (!trimmed) continue;
400
+
401
+ try {
402
+ const raw = JSON.parse(trimmed) as Record<string, unknown>;
403
+ const entry = parseEntry(raw);
404
+ if (entry) {
405
+ entries.push(entry);
406
+ }
407
+ } catch (_e) {
408
+ // Skip unparseable lines
409
+ continue;
410
+ }
411
+ }
412
+
413
+ return { entries, totalLines: lineNumber };
414
+ }
415
+
416
+ /**
417
+ * Estimate token count for a text string.
418
+ * Approximation: chars / 4.
419
+ */
420
+ export function estimateTokens(text: string): number {
421
+ return Math.ceil(text.length / 4);
422
+ }
423
+
424
+ /**
425
+ * Get the last assistant message from entries (useful for session summaries).
426
+ */
427
+ export function getLastAssistantMessage(entries: TranscriptEntry[]): string | null {
428
+ for (let i = entries.length - 1; i >= 0; i--) {
429
+ if (entries[i].type === 'assistant' && entries[i].message) {
430
+ const text = getTextFromContent(entries[i].message!.content);
431
+ if (text.trim()) return text.trim();
432
+ }
433
+ }
434
+ return null;
435
+ }
436
+
437
+ // ============================================================
438
+ // Helpers
439
+ // ============================================================
440
+
441
+ function getTextFromContent(content: TranscriptContentBlock[]): string {
442
+ return content
443
+ .filter((block): block is { type: 'text'; text: string } => block.type === 'text')
444
+ .map(block => block.text)
445
+ .join('\n');
446
+ }
447
+
448
+ function getToolResultText(block: TranscriptContentBlock): string {
449
+ const content = (block as { content: string | TranscriptContentBlock[] }).content;
450
+ if (typeof content === 'string') return content;
451
+ if (Array.isArray(content)) {
452
+ return content
453
+ .filter((b): b is { type: 'text'; text: string } => typeof b === 'object' && b !== null && b.type === 'text')
454
+ .map(b => b.text)
455
+ .join('\n');
456
+ }
457
+ return '';
458
+ }
@@ -0,0 +1,214 @@
1
+ // Copyright (c) 2026 Massu. All rights reserved.
2
+ // Licensed under BSL 1.1 - see LICENSE file for details.
3
+
4
+ import { readFileSync, existsSync, readdirSync } from 'fs';
5
+ import { resolve, join } from 'path';
6
+ import type Database from 'better-sqlite3';
7
+ import { getConfig, getResolvedPaths, getProjectRoot } from './config.ts';
8
+
9
+ interface RouterMapping {
10
+ key: string; // e.g., "orders" (used as api.orders.*)
11
+ variable: string; // e.g., "ordersRouter"
12
+ file: string; // e.g., "src/server/api/routers/orders.ts"
13
+ }
14
+
15
+ interface ProcedureInfo {
16
+ name: string;
17
+ type: 'query' | 'mutation';
18
+ isProtected: boolean;
19
+ }
20
+
21
+ /**
22
+ * Parse src/server/api/root.ts to extract router key-to-file mapping.
23
+ * The key is what UI code uses: api.[key].[procedure]
24
+ */
25
+ export function parseRootRouter(): RouterMapping[] {
26
+ const paths = getResolvedPaths();
27
+ const rootPath = paths.rootRouterPath;
28
+ if (!existsSync(rootPath)) {
29
+ throw new Error(`Root router not found at ${rootPath}`);
30
+ }
31
+
32
+ const source = readFileSync(rootPath, 'utf-8');
33
+ const mappings: RouterMapping[] = [];
34
+
35
+ // Step 1: Parse imports to map variable names to file paths
36
+ // import { ordersRouter } from './routers/orders'
37
+ const importMap = new Map<string, string>();
38
+ const importRegex = /import\s+\{[^}]*?(\w+Router)[^}]*\}\s+from\s+['"]\.\/routers\/([^'"]+)['"]/g;
39
+ let match;
40
+ while ((match = importRegex.exec(source)) !== null) {
41
+ const variable = match[1];
42
+ let filePath = match[2];
43
+ // Resolve to actual file path
44
+ const fullPath = resolve(paths.routersDir, filePath);
45
+ // Try with extensions
46
+ for (const ext of ['.ts', '.tsx', '']) {
47
+ const candidate = fullPath + ext;
48
+ const routersRelPath = getConfig().paths.routers ?? 'src/server/api/routers';
49
+ if (existsSync(candidate)) {
50
+ filePath = routersRelPath + '/' + filePath + ext;
51
+ break;
52
+ }
53
+ // Check if it's a directory with index
54
+ const indexCandidate = join(fullPath, 'index.ts');
55
+ if (existsSync(indexCandidate)) {
56
+ filePath = routersRelPath + '/' + filePath + '/index.ts';
57
+ break;
58
+ }
59
+ }
60
+ importMap.set(variable, filePath);
61
+ }
62
+
63
+ // Step 2: Parse router registration - "orders: ordersRouter"
64
+ const regRegex = /(\w+)\s*:\s*(\w+Router)/g;
65
+ while ((match = regRegex.exec(source)) !== null) {
66
+ const key = match[1];
67
+ const variable = match[2];
68
+ const file = importMap.get(variable);
69
+ if (file) {
70
+ mappings.push({ key, variable, file });
71
+ }
72
+ }
73
+
74
+ return mappings;
75
+ }
76
+
77
+ /**
78
+ * Extract procedure definitions from a router file.
79
+ */
80
+ export function extractProcedures(routerFilePath: string): ProcedureInfo[] {
81
+ const absPath = resolve(getProjectRoot(), routerFilePath);
82
+ if (!existsSync(absPath)) return [];
83
+
84
+ const source = readFileSync(absPath, 'utf-8');
85
+ const procedures: ProcedureInfo[] = [];
86
+ const seen = new Set<string>();
87
+
88
+ // Pattern: procedureName: protectedProcedure or publicProcedure
89
+ const procRegex = /(\w+)\s*:\s*(protected|public)Procedure/g;
90
+ let match;
91
+ while ((match = procRegex.exec(source)) !== null) {
92
+ const name = match[1];
93
+ const isProtected = match[2] === 'protected';
94
+ if (seen.has(name)) continue;
95
+ seen.add(name);
96
+
97
+ // Determine if query or mutation by looking ahead
98
+ const afterMatch = source.slice(match.index);
99
+ const typeMatch = afterMatch.match(/\.(query|mutation)\s*\(/);
100
+ const type = typeMatch ? (typeMatch[1] as 'query' | 'mutation') : 'query';
101
+
102
+ procedures.push({ name, type, isProtected });
103
+ }
104
+
105
+ return procedures;
106
+ }
107
+
108
+ /**
109
+ * Find UI call sites for a given router key and procedure name.
110
+ */
111
+ export function findUICallSites(routerKey: string, procedureName: string): { file: string; line: number; pattern: string }[] {
112
+ const callSites: { file: string; line: number; pattern: string }[] = [];
113
+ const config = getConfig();
114
+ const root = getProjectRoot();
115
+ const src = config.paths.source;
116
+ const searchDirs = [
117
+ resolve(root, config.paths.pages ?? (src + '/app')),
118
+ resolve(root, config.paths.components ?? (src + '/components')),
119
+ resolve(root, config.paths.hooks ?? (src + '/hooks')),
120
+ ];
121
+
122
+ const searchPattern = `api.${routerKey}.${procedureName}`;
123
+
124
+ for (const dir of searchDirs) {
125
+ if (!existsSync(dir)) continue;
126
+ searchDirectory(dir, searchPattern, callSites);
127
+ }
128
+
129
+ return callSites;
130
+ }
131
+
132
+ function searchDirectory(dir: string, pattern: string, results: { file: string; line: number; pattern: string }[]): void {
133
+ const entries = readdirSync(dir, { withFileTypes: true });
134
+ for (const entry of entries) {
135
+ const fullPath = join(dir, entry.name);
136
+ if (entry.isDirectory()) {
137
+ if (entry.name === 'node_modules' || entry.name === '.next') continue;
138
+ searchDirectory(fullPath, pattern, results);
139
+ } else if (entry.name.endsWith('.ts') || entry.name.endsWith('.tsx')) {
140
+ try {
141
+ const source = readFileSync(fullPath, 'utf-8');
142
+ const lines = source.split('\n');
143
+ for (let i = 0; i < lines.length; i++) {
144
+ if (lines[i].includes(pattern)) {
145
+ const relPath = fullPath.slice(getProjectRoot().length + 1);
146
+ // Extract the full call pattern (e.g., api.orders.create.useMutation())
147
+ const lineContent = lines[i].trim();
148
+ const callMatch = lineContent.match(new RegExp(`(api\\.${escapeRegex(pattern.slice(4))}\\.[\\w.()]+)`));
149
+ results.push({
150
+ file: relPath,
151
+ line: i + 1,
152
+ pattern: callMatch ? callMatch[1] : pattern,
153
+ });
154
+ }
155
+ }
156
+ } catch {
157
+ // Skip unreadable files
158
+ }
159
+ }
160
+ }
161
+ }
162
+
163
+ function escapeRegex(str: string): string {
164
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
165
+ }
166
+
167
+ /**
168
+ * Build the full tRPC procedure index.
169
+ * Stores results in massu_trpc_procedures and massu_trpc_call_sites tables.
170
+ */
171
+ export function buildTrpcIndex(dataDb: Database.Database): { totalProcedures: number; withCallers: number; withoutCallers: number } {
172
+ // Clear existing data
173
+ dataDb.exec('DELETE FROM massu_trpc_call_sites');
174
+ dataDb.exec('DELETE FROM massu_trpc_procedures');
175
+
176
+ const routerMappings = parseRootRouter();
177
+
178
+ const insertProc = dataDb.prepare(
179
+ 'INSERT INTO massu_trpc_procedures (router_file, router_name, procedure_name, procedure_type, has_ui_caller) VALUES (?, ?, ?, ?, ?)'
180
+ );
181
+ const insertCallSite = dataDb.prepare(
182
+ 'INSERT INTO massu_trpc_call_sites (procedure_id, file, line, call_pattern) VALUES (?, ?, ?, ?)'
183
+ );
184
+
185
+ let totalProcedures = 0;
186
+ let withCallers = 0;
187
+ let withoutCallers = 0;
188
+
189
+ const insertAll = dataDb.transaction(() => {
190
+ for (const router of routerMappings) {
191
+ const procedures = extractProcedures(router.file);
192
+
193
+ for (const proc of procedures) {
194
+ const callSites = findUICallSites(router.key, proc.name);
195
+ const hasUICaller = callSites.length > 0 ? 1 : 0;
196
+
197
+ const result = insertProc.run(router.file, router.key, proc.name, proc.type, hasUICaller);
198
+ const procId = result.lastInsertRowid;
199
+
200
+ for (const site of callSites) {
201
+ insertCallSite.run(procId, site.file, site.line, site.pattern);
202
+ }
203
+
204
+ totalProcedures++;
205
+ if (hasUICaller) withCallers++;
206
+ else withoutCallers++;
207
+ }
208
+ }
209
+ });
210
+
211
+ insertAll();
212
+
213
+ return { totalProcedures, withCallers, withoutCallers };
214
+ }