@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.
- package/LICENSE +71 -0
- package/README.md +2 -2
- package/dist/hooks/cost-tracker.js +149 -11527
- package/dist/hooks/post-edit-context.js +127 -11493
- package/dist/hooks/post-tool-use.js +169 -11550
- package/dist/hooks/pre-compact.js +149 -11530
- package/dist/hooks/pre-delete-check.js +144 -11523
- package/dist/hooks/quality-event.js +149 -11527
- package/dist/hooks/session-end.js +188 -11570
- package/dist/hooks/session-start.js +159 -11534
- package/dist/hooks/user-prompt.js +149 -11530
- package/package.json +14 -19
- package/src/adr-generator.ts +292 -0
- package/src/analytics.ts +373 -0
- package/src/audit-trail.ts +450 -0
- package/src/backfill-sessions.ts +180 -0
- package/src/cli.ts +105 -0
- package/src/cloud-sync.ts +190 -0
- package/src/commands/doctor.ts +300 -0
- package/src/commands/init.ts +395 -0
- package/src/commands/install-hooks.ts +26 -0
- package/src/config.ts +357 -0
- package/src/cost-tracker.ts +355 -0
- package/src/db.ts +233 -0
- package/src/dependency-scorer.ts +337 -0
- package/src/docs-map.json +100 -0
- package/src/docs-tools.ts +517 -0
- package/src/domains.ts +181 -0
- package/src/hooks/cost-tracker.ts +66 -0
- package/src/hooks/intent-suggester.ts +131 -0
- package/src/hooks/post-edit-context.ts +91 -0
- package/src/hooks/post-tool-use.ts +175 -0
- package/src/hooks/pre-compact.ts +146 -0
- package/src/hooks/pre-delete-check.ts +153 -0
- package/src/hooks/quality-event.ts +127 -0
- package/src/hooks/security-gate.ts +121 -0
- package/src/hooks/session-end.ts +467 -0
- package/src/hooks/session-start.ts +210 -0
- package/src/hooks/user-prompt.ts +91 -0
- package/src/import-resolver.ts +224 -0
- package/src/memory-db.ts +1376 -0
- package/src/memory-tools.ts +391 -0
- package/src/middleware-tree.ts +70 -0
- package/src/observability-tools.ts +343 -0
- package/src/observation-extractor.ts +411 -0
- package/src/page-deps.ts +283 -0
- package/src/prompt-analyzer.ts +332 -0
- package/src/regression-detector.ts +319 -0
- package/src/rules.ts +57 -0
- package/src/schema-mapper.ts +232 -0
- package/src/security-scorer.ts +405 -0
- package/src/security-utils.ts +133 -0
- package/src/sentinel-db.ts +578 -0
- package/src/sentinel-scanner.ts +405 -0
- package/src/sentinel-tools.ts +512 -0
- package/src/sentinel-types.ts +140 -0
- package/src/server.ts +189 -0
- package/src/session-archiver.ts +112 -0
- package/src/session-state-generator.ts +174 -0
- package/src/team-knowledge.ts +407 -0
- package/src/tools.ts +847 -0
- package/src/transcript-parser.ts +458 -0
- package/src/trpc-index.ts +214 -0
- package/src/validate-features-runner.ts +106 -0
- package/src/validation-engine.ts +358 -0
- package/dist/cli.js +0 -7890
- 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
|
+
}
|