@massu/core 0.1.0 → 0.1.1

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