@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,517 @@
1
+ // Copyright (c) 2026 Massu. All rights reserved.
2
+ // Licensed under BSL 1.1 - see LICENSE file for details.
3
+
4
+ import { readFileSync, existsSync } from 'fs';
5
+ import { resolve, basename } from 'path';
6
+ import { getConfig, getResolvedPaths } from './config.ts';
7
+ import type { ToolDefinition, ToolResult } from './tools.ts';
8
+
9
+ /** Prefix a base tool name with the configured tool prefix. */
10
+ function p(baseName: string): string {
11
+ return `${getConfig().toolPrefix}_${baseName}`;
12
+ }
13
+
14
+ // ============================================================
15
+ // Help Site Auto-Sync: MCP Docs Tools
16
+ // docs_audit + docs_coverage
17
+ // ============================================================
18
+
19
+ interface DocsMapping {
20
+ id: string;
21
+ helpPage: string;
22
+ appRoutes: string[];
23
+ routers: string[];
24
+ components: string[];
25
+ keywords: string[];
26
+ }
27
+
28
+ interface DocsMap {
29
+ version: number;
30
+ mappings: DocsMapping[];
31
+ userGuideInheritance: {
32
+ examples: Record<string, string>;
33
+ };
34
+ }
35
+
36
+ interface AuditResult {
37
+ helpPage: string;
38
+ mappingId: string;
39
+ status: 'STALE' | 'NEW' | 'OK';
40
+ reason: string;
41
+ sections: string[];
42
+ changedFiles: string[];
43
+ suggestedAction: string;
44
+ }
45
+
46
+ interface AuditReport {
47
+ affectedPages: AuditResult[];
48
+ summary: string;
49
+ }
50
+
51
+ interface CoverageEntry {
52
+ id: string;
53
+ helpPage: string;
54
+ exists: boolean;
55
+ hasContent: boolean;
56
+ lineCount: number;
57
+ lastVerified: string | null;
58
+ status: string | null;
59
+ }
60
+
61
+ interface CoverageReport {
62
+ totalMappings: number;
63
+ pagesExisting: number;
64
+ pagesWithContent: number;
65
+ coveragePercent: number;
66
+ entries: CoverageEntry[];
67
+ gaps: string[];
68
+ }
69
+
70
+ // ============================================================
71
+ // Tool Definitions
72
+ // ============================================================
73
+
74
+ export function getDocsToolDefinitions(): ToolDefinition[] {
75
+ return [
76
+ {
77
+ name: p('docs_audit'),
78
+ description: 'Audit which help site pages need updating based on changed files. Maps code changes to affected documentation pages using docs-map.json.',
79
+ inputSchema: {
80
+ type: 'object',
81
+ properties: {
82
+ changed_files: {
83
+ type: 'array',
84
+ items: { type: 'string' },
85
+ description: 'List of changed files from git diff (relative to project root)',
86
+ },
87
+ commit_message: {
88
+ type: 'string',
89
+ description: 'Optional commit message for context',
90
+ },
91
+ },
92
+ required: ['changed_files'],
93
+ },
94
+ },
95
+ {
96
+ name: p('docs_coverage'),
97
+ description: 'Report docs coverage: which help pages exist, have content, and are up-to-date. Identifies documentation gaps.',
98
+ inputSchema: {
99
+ type: 'object',
100
+ properties: {
101
+ domain: {
102
+ type: 'string',
103
+ description: 'Optional: filter by mapping ID (e.g., "dashboard", "users")',
104
+ },
105
+ },
106
+ required: [],
107
+ },
108
+ },
109
+ ];
110
+ }
111
+
112
+ // ============================================================
113
+ // Tool Handler Router
114
+ // ============================================================
115
+
116
+ export function handleDocsToolCall(
117
+ name: string,
118
+ args: Record<string, unknown>
119
+ ): ToolResult {
120
+ const prefix = getConfig().toolPrefix + '_';
121
+ const baseName = name.startsWith(prefix) ? name.slice(prefix.length) : name;
122
+
123
+ switch (baseName) {
124
+ case 'docs_audit':
125
+ return handleDocsAudit(args);
126
+ case 'docs_coverage':
127
+ return handleDocsCoverage(args);
128
+ default:
129
+ return text(`Unknown docs tool: ${name}`);
130
+ }
131
+ }
132
+
133
+ // ============================================================
134
+ // Core Logic
135
+ // ============================================================
136
+
137
+ function loadDocsMap(): DocsMap {
138
+ const mapPath = getResolvedPaths().docsMapPath;
139
+ if (!existsSync(mapPath)) {
140
+ throw new Error(`docs-map.json not found at ${mapPath}`);
141
+ }
142
+ return JSON.parse(readFileSync(mapPath, 'utf-8'));
143
+ }
144
+
145
+ /**
146
+ * Check if a file path matches a glob-like pattern.
147
+ * Supports ** (any depth) and * (single segment).
148
+ */
149
+ function matchesPattern(filePath: string, pattern: string): boolean {
150
+ // Convert glob pattern to regex
151
+ const regexStr = pattern
152
+ .replace(/\./g, '\\.')
153
+ .replace(/\*\*/g, '{{GLOBSTAR}}')
154
+ .replace(/\*/g, '[^/]*')
155
+ .replace(/\{\{GLOBSTAR\}\}/g, '.*');
156
+ return new RegExp(`^${regexStr}$`).test(filePath);
157
+ }
158
+
159
+ /**
160
+ * Find which mappings are affected by a set of changed files.
161
+ */
162
+ function findAffectedMappings(docsMap: DocsMap, changedFiles: string[]): Map<string, string[]> {
163
+ // Map of mapping ID -> list of changed files that triggered it
164
+ const affected = new Map<string, string[]>();
165
+
166
+ for (const file of changedFiles) {
167
+ const fileName = basename(file);
168
+
169
+ for (const mapping of docsMap.mappings) {
170
+ let matched = false;
171
+
172
+ // Check app routes (glob patterns)
173
+ for (const routePattern of mapping.appRoutes) {
174
+ if (matchesPattern(file, routePattern)) {
175
+ matched = true;
176
+ break;
177
+ }
178
+ }
179
+
180
+ // Check routers (filename match)
181
+ if (!matched) {
182
+ for (const router of mapping.routers) {
183
+ if (fileName === router || file.endsWith(`/routers/${router}`)) {
184
+ matched = true;
185
+ break;
186
+ }
187
+ }
188
+ }
189
+
190
+ // Check components (glob patterns)
191
+ if (!matched) {
192
+ for (const compPattern of mapping.components) {
193
+ if (matchesPattern(file, compPattern)) {
194
+ matched = true;
195
+ break;
196
+ }
197
+ }
198
+ }
199
+
200
+ if (matched) {
201
+ const existing = affected.get(mapping.id) || [];
202
+ existing.push(file);
203
+ affected.set(mapping.id, existing);
204
+ }
205
+ }
206
+
207
+ // Check user guide inheritance
208
+ // If a file matches a parent feature, the user guide also needs review
209
+ // (handled implicitly - the parent mapping is what gets flagged)
210
+ }
211
+
212
+ return affected;
213
+ }
214
+
215
+ /**
216
+ * Extract headings (H2/H3) from MDX content.
217
+ */
218
+ function extractSections(content: string): string[] {
219
+ const headingRegex = /^#{2,3}\s+(.+)$/gm;
220
+ const sections: string[] = [];
221
+ let match;
222
+ while ((match = headingRegex.exec(content)) !== null) {
223
+ sections.push(match[0].trim());
224
+ }
225
+ return sections;
226
+ }
227
+
228
+ /**
229
+ * Extract frontmatter from MDX content.
230
+ */
231
+ function extractFrontmatter(content: string): Record<string, string> | null {
232
+ if (!content.startsWith('---')) return null;
233
+ const endIndex = content.indexOf('---', 3);
234
+ if (endIndex === -1) return null;
235
+
236
+ const frontmatterStr = content.substring(3, endIndex).trim();
237
+ const result: Record<string, string> = {};
238
+
239
+ for (const line of frontmatterStr.split('\n')) {
240
+ const colonIndex = line.indexOf(':');
241
+ if (colonIndex > 0) {
242
+ const key = line.substring(0, colonIndex).trim();
243
+ const value = line.substring(colonIndex + 1).trim().replace(/^["']|["']$/g, '');
244
+ result[key] = value;
245
+ }
246
+ }
247
+
248
+ return result;
249
+ }
250
+
251
+ /**
252
+ * Extract procedure names from a router file.
253
+ */
254
+ function extractProcedureNames(routerPath: string): string[] {
255
+ const absPath = resolve(getResolvedPaths().srcDir, '..', routerPath);
256
+ if (!existsSync(absPath)) {
257
+ // Try from project root
258
+ const altPath = resolve(getResolvedPaths().srcDir, '../server/api/routers', basename(routerPath));
259
+ if (!existsSync(altPath)) return [];
260
+ return extractProcedureNamesFromContent(readFileSync(altPath, 'utf-8'));
261
+ }
262
+ return extractProcedureNamesFromContent(readFileSync(absPath, 'utf-8'));
263
+ }
264
+
265
+ function extractProcedureNamesFromContent(content: string): string[] {
266
+ const procRegex = /\.(?:query|mutation)\s*\(/g;
267
+ const nameRegex = /(\w+)\s*:\s*(?:protected|public)Procedure/g;
268
+ const procedures: string[] = [];
269
+
270
+ let match;
271
+ while ((match = nameRegex.exec(content)) !== null) {
272
+ procedures.push(match[1]);
273
+ }
274
+
275
+ return procedures;
276
+ }
277
+
278
+ /**
279
+ * Check if MDX content mentions a procedure/feature name.
280
+ */
281
+ function contentMentions(content: string, term: string): boolean {
282
+ // Check for the term in various formats
283
+ const lowerContent = content.toLowerCase();
284
+ const lowerTerm = term.toLowerCase();
285
+
286
+ // Direct mention
287
+ if (lowerContent.includes(lowerTerm)) return true;
288
+
289
+ // camelCase to words: bulkUpdateStatus -> bulk update status
290
+ const words = term.replace(/([A-Z])/g, ' $1').toLowerCase().trim();
291
+ if (lowerContent.includes(words)) return true;
292
+
293
+ // kebab-case
294
+ const kebab = term.replace(/([A-Z])/g, '-$1').toLowerCase().trim().replace(/^-/, '');
295
+ if (lowerContent.includes(kebab)) return true;
296
+
297
+ return false;
298
+ }
299
+
300
+ // ============================================================
301
+ // Tool Handlers
302
+ // ============================================================
303
+
304
+ function handleDocsAudit(args: Record<string, unknown>): ToolResult {
305
+ const changedFiles = args.changed_files as string[];
306
+ const commitMessage = (args.commit_message as string) || '';
307
+
308
+ if (!changedFiles || changedFiles.length === 0) {
309
+ return text(JSON.stringify({ affectedPages: [], summary: 'No changed files provided.' }));
310
+ }
311
+
312
+ const docsMap = loadDocsMap();
313
+ const affectedMappings = findAffectedMappings(docsMap, changedFiles);
314
+
315
+ if (affectedMappings.size === 0) {
316
+ return text(JSON.stringify({
317
+ affectedPages: [],
318
+ summary: `0 help pages affected by ${changedFiles.length} changed files. No docs update needed.`,
319
+ }));
320
+ }
321
+
322
+ const results: AuditResult[] = [];
323
+
324
+ for (const [mappingId, triggeringFiles] of affectedMappings) {
325
+ const mapping = docsMap.mappings.find(m => m.id === mappingId);
326
+ if (!mapping) continue;
327
+
328
+ const helpPagePath = resolve(getResolvedPaths().helpSitePath, mapping.helpPage);
329
+
330
+ if (!existsSync(helpPagePath)) {
331
+ results.push({
332
+ helpPage: mapping.helpPage,
333
+ mappingId,
334
+ status: 'NEW',
335
+ reason: `Help page does not exist: ${mapping.helpPage}`,
336
+ sections: [],
337
+ changedFiles: triggeringFiles,
338
+ suggestedAction: `Create ${mapping.helpPage} with documentation for this feature`,
339
+ });
340
+ continue;
341
+ }
342
+
343
+ const content = readFileSync(helpPagePath, 'utf-8');
344
+ const sections = extractSections(content);
345
+ const frontmatter = extractFrontmatter(content);
346
+
347
+ // Check for staleness indicators
348
+ const staleReasons: string[] = [];
349
+
350
+ // Check router changes - are new procedures documented?
351
+ for (const file of triggeringFiles) {
352
+ const fileName = basename(file);
353
+ if (mapping.routers.includes(fileName)) {
354
+ const procedures = extractProcedureNames(file);
355
+ const undocumented = procedures.filter(p => !contentMentions(content, p));
356
+ if (undocumented.length > 0) {
357
+ staleReasons.push(
358
+ `Router ${fileName}: procedures not documented: ${undocumented.slice(0, 5).join(', ')}${undocumented.length > 5 ? ` (+${undocumented.length - 5} more)` : ''}`
359
+ );
360
+ }
361
+ }
362
+ }
363
+
364
+ // Check if lastVerified is old (> 30 days)
365
+ if (frontmatter?.lastVerified) {
366
+ const lastDate = new Date(frontmatter.lastVerified);
367
+ const daysSince = Math.floor((Date.now() - lastDate.getTime()) / (1000 * 60 * 60 * 24));
368
+ if (daysSince > 30) {
369
+ staleReasons.push(`lastVerified is ${daysSince} days old`);
370
+ }
371
+ } else {
372
+ staleReasons.push('No lastVerified frontmatter');
373
+ }
374
+
375
+ // Check commit message for new feature indicators
376
+ if (commitMessage && /\b(add|new|feature|implement)\b/i.test(commitMessage)) {
377
+ staleReasons.push(`Commit message suggests new functionality: "${commitMessage}"`);
378
+ }
379
+
380
+ const status = staleReasons.length > 0 ? 'STALE' : 'OK';
381
+
382
+ results.push({
383
+ helpPage: mapping.helpPage,
384
+ mappingId,
385
+ status,
386
+ reason: staleReasons.length > 0 ? staleReasons.join('; ') : 'Content appears current',
387
+ sections,
388
+ changedFiles: triggeringFiles,
389
+ suggestedAction: status === 'STALE'
390
+ ? `Review and update ${mapping.helpPage} to reflect changes in: ${triggeringFiles.map(f => basename(f)).join(', ')}`
391
+ : 'No action needed',
392
+ });
393
+
394
+ // Also flag inherited user guides
395
+ for (const [guideName, parentId] of Object.entries(docsMap.userGuideInheritance.examples)) {
396
+ if (parentId === mappingId) {
397
+ const guidePath = resolve(getResolvedPaths().helpSitePath, `pages/user-guides/${guideName}/index.mdx`);
398
+ if (existsSync(guidePath)) {
399
+ const guideContent = readFileSync(guidePath, 'utf-8');
400
+ const guideFrontmatter = extractFrontmatter(guideContent);
401
+
402
+ if (!guideFrontmatter?.lastVerified || status === 'STALE') {
403
+ results.push({
404
+ helpPage: `pages/user-guides/${guideName}/index.mdx`,
405
+ mappingId: `${mappingId}:${guideName}`,
406
+ status: 'STALE',
407
+ reason: `Inherited from parent mapping "${mappingId}" which has changes`,
408
+ sections: extractSections(guideContent),
409
+ changedFiles: triggeringFiles,
410
+ suggestedAction: `Review user guide "${guideName}" for consistency with updated ${mapping.helpPage}`,
411
+ });
412
+ }
413
+ }
414
+ }
415
+ }
416
+ }
417
+
418
+ const staleCount = results.filter(r => r.status === 'STALE').length;
419
+ const newCount = results.filter(r => r.status === 'NEW').length;
420
+ const okCount = results.filter(r => r.status === 'OK').length;
421
+
422
+ const report: AuditReport = {
423
+ affectedPages: results,
424
+ summary: `${results.length} pages checked: ${staleCount} STALE, ${newCount} NEW, ${okCount} OK. ${staleCount + newCount > 0 ? `${staleCount + newCount} pages need updates.` : 'All docs are current.'}`,
425
+ };
426
+
427
+ return text(JSON.stringify(report, null, 2));
428
+ }
429
+
430
+ function handleDocsCoverage(args: Record<string, unknown>): ToolResult {
431
+ const filterDomain = args.domain as string | undefined;
432
+ const docsMap = loadDocsMap();
433
+
434
+ const entries: CoverageEntry[] = [];
435
+ const gaps: string[] = [];
436
+
437
+ const mappings = filterDomain
438
+ ? docsMap.mappings.filter(m => m.id === filterDomain)
439
+ : docsMap.mappings;
440
+
441
+ for (const mapping of mappings) {
442
+ const helpPagePath = resolve(getResolvedPaths().helpSitePath, mapping.helpPage);
443
+ const exists = existsSync(helpPagePath);
444
+ let hasContent = false;
445
+ let lineCount = 0;
446
+ let lastVerified: string | null = null;
447
+ let status: string | null = null;
448
+
449
+ if (exists) {
450
+ const content = readFileSync(helpPagePath, 'utf-8');
451
+ lineCount = content.split('\n').length;
452
+ hasContent = lineCount > 10; // More than just frontmatter
453
+
454
+ const frontmatter = extractFrontmatter(content);
455
+ if (frontmatter) {
456
+ lastVerified = frontmatter.lastVerified || null;
457
+ status = frontmatter.status || null;
458
+ }
459
+ } else {
460
+ gaps.push(`${mapping.id}: Help page missing (${mapping.helpPage})`);
461
+ }
462
+
463
+ entries.push({
464
+ id: mapping.id,
465
+ helpPage: mapping.helpPage,
466
+ exists,
467
+ hasContent,
468
+ lineCount,
469
+ lastVerified,
470
+ status,
471
+ });
472
+ }
473
+
474
+ const report: CoverageReport = {
475
+ totalMappings: mappings.length,
476
+ pagesExisting: entries.filter(e => e.exists).length,
477
+ pagesWithContent: entries.filter(e => e.hasContent).length,
478
+ coveragePercent: Math.round((entries.filter(e => e.hasContent).length / mappings.length) * 100),
479
+ entries,
480
+ gaps,
481
+ };
482
+
483
+ const lines: string[] = [];
484
+ lines.push(`## Docs Coverage Report${filterDomain ? ` (${filterDomain})` : ''}`);
485
+ lines.push('');
486
+ lines.push(`- Total mappings: ${report.totalMappings}`);
487
+ lines.push(`- Pages existing: ${report.pagesExisting}`);
488
+ lines.push(`- Pages with content: ${report.pagesWithContent}`);
489
+ lines.push(`- Coverage: ${report.coveragePercent}%`);
490
+ lines.push('');
491
+
492
+ if (report.gaps.length > 0) {
493
+ lines.push('### Gaps');
494
+ for (const gap of report.gaps) {
495
+ lines.push(`- ${gap}`);
496
+ }
497
+ lines.push('');
498
+ }
499
+
500
+ lines.push('### Page Status');
501
+ for (const entry of report.entries) {
502
+ const verified = entry.lastVerified ? ` (verified: ${entry.lastVerified})` : ' (not verified)';
503
+ const pageStatus = entry.status ? ` [${entry.status}]` : '';
504
+ const icon = entry.hasContent ? 'OK' : entry.exists ? 'THIN' : 'MISSING';
505
+ lines.push(`- [${icon}] ${entry.id}: ${entry.helpPage}${verified}${pageStatus} (${entry.lineCount} lines)`);
506
+ }
507
+
508
+ return text(lines.join('\n'));
509
+ }
510
+
511
+ // ============================================================
512
+ // Utilities
513
+ // ============================================================
514
+
515
+ function text(content: string): ToolResult {
516
+ return { content: [{ type: 'text', text: content }] };
517
+ }
package/src/domains.ts ADDED
@@ -0,0 +1,181 @@
1
+ // Copyright (c) 2026 Massu. All rights reserved.
2
+ // Licensed under BSL 1.1 - see LICENSE file for details.
3
+
4
+ import type Database from 'better-sqlite3';
5
+ import { globMatch } from './rules.ts';
6
+ import { getConfig, getResolvedPaths } from './config.ts';
7
+ import type { DomainConfig } from './config.ts';
8
+
9
+ // Re-export for backward compatibility
10
+ export type { DomainConfig };
11
+
12
+ /**
13
+ * Get domain configurations from the config file.
14
+ * Returns an empty array if no domains are configured.
15
+ */
16
+ function getDomains(): DomainConfig[] {
17
+ return getConfig().domains;
18
+ }
19
+
20
+ /**
21
+ * Classify a router name into its domain.
22
+ * Returns the domain name or 'Unknown' if no match.
23
+ */
24
+ export function classifyRouter(routerName: string): string {
25
+ const domains = getDomains();
26
+ for (const domain of domains) {
27
+ for (const pattern of domain.routers) {
28
+ if (globMatchSimple(routerName, pattern)) {
29
+ return domain.name;
30
+ }
31
+ }
32
+ }
33
+ return 'Unknown';
34
+ }
35
+
36
+ /**
37
+ * Classify a file path into its domain.
38
+ */
39
+ export function classifyFile(filePath: string): string {
40
+ const domains = getDomains();
41
+ const config = getConfig();
42
+ const normalized = filePath.replace(/\\/g, '/');
43
+
44
+ // Check page patterns
45
+ for (const domain of domains) {
46
+ for (const pattern of domain.pages) {
47
+ if (globMatch(normalized, pattern)) {
48
+ return domain.name;
49
+ }
50
+ }
51
+ }
52
+
53
+ // Check if it's a router file - derive router dir from config
54
+ const routersPath = config.paths.routers ?? 'src/server/api/routers';
55
+ const routerPrefix = routersPath.replace(/\\/g, '/');
56
+ if (normalized.includes(routerPrefix + '/')) {
57
+ const routerName = normalized
58
+ .replace(routerPrefix + '/', '')
59
+ .replace(/\.ts$/, '')
60
+ .replace(/\/index$/, '');
61
+ return classifyRouter(routerName);
62
+ }
63
+
64
+ // Check component paths
65
+ if (normalized.includes('/components/')) {
66
+ const parts = normalized.split('/');
67
+ const compIdx = parts.indexOf('components');
68
+ if (compIdx >= 0 && compIdx + 1 < parts.length) {
69
+ const compGroup = parts[compIdx + 1];
70
+ for (const domain of domains) {
71
+ for (const pattern of domain.routers) {
72
+ if (globMatchSimple(compGroup, pattern)) {
73
+ return domain.name;
74
+ }
75
+ }
76
+ }
77
+ }
78
+ }
79
+
80
+ return 'Unknown';
81
+ }
82
+
83
+ /**
84
+ * Simple glob matching for router/table names (single-level, no path separators).
85
+ */
86
+ function globMatchSimple(name: string, pattern: string): boolean {
87
+ const regexStr = pattern
88
+ .replace(/\./g, '\\.')
89
+ .replace(/\*/g, '.*')
90
+ .replace(/\?/g, '.');
91
+ return new RegExp(`^${regexStr}$`).test(name);
92
+ }
93
+
94
+ /**
95
+ * Find all cross-domain imports using the imports table.
96
+ */
97
+ export function findCrossDomainImports(dataDb: Database.Database): {
98
+ source: string;
99
+ target: string;
100
+ sourceDomain: string;
101
+ targetDomain: string;
102
+ allowed: boolean;
103
+ }[] {
104
+ const domains = getDomains();
105
+ const config = getConfig();
106
+ const srcPrefix = config.paths.source;
107
+
108
+ const srcPattern = srcPrefix + '/%';
109
+ const imports = dataDb.prepare(
110
+ 'SELECT source_file, target_file FROM massu_imports WHERE source_file LIKE ? AND target_file LIKE ?'
111
+ ).all(srcPattern, srcPattern) as { source_file: string; target_file: string }[];
112
+
113
+ const crossings: {
114
+ source: string; target: string;
115
+ sourceDomain: string; targetDomain: string;
116
+ allowed: boolean;
117
+ }[] = [];
118
+
119
+ for (const imp of imports) {
120
+ const sourceDomain = classifyFile(imp.source_file);
121
+ const targetDomain = classifyFile(imp.target_file);
122
+
123
+ if (sourceDomain === 'Unknown' || targetDomain === 'Unknown') continue;
124
+ if (sourceDomain === targetDomain) continue;
125
+
126
+ // Check if source domain allows wildcard imports
127
+ const sourceConfig = domains.find(d => d.name === sourceDomain);
128
+ if (sourceConfig?.allowedImportsFrom.length === 0) continue; // System domain
129
+ const allowed = sourceConfig?.allowedImportsFrom.includes('*') ||
130
+ sourceConfig?.allowedImportsFrom.includes(targetDomain) || false;
131
+
132
+ crossings.push({
133
+ source: imp.source_file,
134
+ target: imp.target_file,
135
+ sourceDomain,
136
+ targetDomain,
137
+ allowed,
138
+ });
139
+ }
140
+
141
+ return crossings;
142
+ }
143
+
144
+ /**
145
+ * Get all files in a specific domain.
146
+ */
147
+ export function getFilesInDomain(dataDb: Database.Database, codegraphDb: Database.Database, domainName: string): {
148
+ routers: string[];
149
+ pages: string[];
150
+ components: string[];
151
+ } {
152
+ const domains = getDomains();
153
+ const config = getConfig();
154
+ const domain = domains.find(d => d.name === domainName);
155
+ if (!domain) return { routers: [], pages: [], components: [] };
156
+
157
+ const srcPrefix = config.paths.source;
158
+ const routersPath = config.paths.routers ?? 'src/server/api/routers';
159
+
160
+ const srcPattern = srcPrefix + '/%';
161
+ const allFiles = codegraphDb.prepare('SELECT path FROM files WHERE path LIKE ?').all(srcPattern) as { path: string }[];
162
+
163
+ const routers: string[] = [];
164
+ const pages: string[] = [];
165
+ const components: string[] = [];
166
+
167
+ for (const file of allFiles) {
168
+ const fileDomain = classifyFile(file.path);
169
+ if (fileDomain !== domainName) continue;
170
+
171
+ if (file.path.includes(routersPath + '/')) {
172
+ routers.push(file.path);
173
+ } else if (file.path.match(/page\.tsx?$/)) {
174
+ pages.push(file.path);
175
+ } else if (file.path.includes('/components/')) {
176
+ components.push(file.path);
177
+ }
178
+ }
179
+
180
+ return { routers, pages, components };
181
+ }