@massu/core 0.1.2 → 0.4.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.
- package/commands/_shared-preamble.md +76 -0
- package/commands/massu-audit-deps.md +211 -0
- package/commands/massu-changelog.md +174 -0
- package/commands/massu-cleanup.md +315 -0
- package/commands/massu-commit.md +481 -0
- package/commands/massu-create-plan.md +752 -0
- package/commands/massu-dead-code.md +131 -0
- package/commands/massu-debug.md +484 -0
- package/commands/massu-deploy.md +91 -0
- package/commands/massu-deps.md +374 -0
- package/commands/massu-doc-gen.md +279 -0
- package/commands/massu-docs.md +364 -0
- package/commands/massu-estimate.md +313 -0
- package/commands/massu-golden-path.md +973 -0
- package/commands/massu-guide.md +167 -0
- package/commands/massu-hotfix.md +480 -0
- package/commands/massu-loop-playwright.md +837 -0
- package/commands/massu-loop.md +775 -0
- package/commands/massu-new-feature.md +511 -0
- package/commands/massu-parity.md +214 -0
- package/commands/massu-plan.md +456 -0
- package/commands/massu-push-light.md +207 -0
- package/commands/massu-push.md +434 -0
- package/commands/massu-refactor.md +410 -0
- package/commands/massu-release.md +363 -0
- package/commands/massu-review.md +238 -0
- package/commands/massu-simplify.md +281 -0
- package/commands/massu-status.md +278 -0
- package/commands/massu-tdd.md +201 -0
- package/commands/massu-test.md +516 -0
- package/commands/massu-verify-playwright.md +281 -0
- package/commands/massu-verify.md +667 -0
- package/dist/cli.js +12521 -0
- package/dist/hooks/cost-tracker.js +80 -5
- package/dist/hooks/post-edit-context.js +72 -6
- package/dist/hooks/post-tool-use.js +234 -57
- package/dist/hooks/pre-compact.js +144 -5
- package/dist/hooks/pre-delete-check.js +141 -11
- package/dist/hooks/quality-event.js +80 -5
- package/dist/hooks/security-gate.js +29 -0
- package/dist/hooks/session-end.js +83 -8
- package/dist/hooks/session-start.js +153 -7
- package/dist/hooks/user-prompt.js +166 -5
- package/package.json +6 -5
- package/src/backfill-sessions.ts +5 -4
- package/src/cli.ts +6 -1
- package/src/commands/doctor.ts +193 -6
- package/src/commands/init.ts +235 -6
- package/src/commands/install-commands.ts +137 -0
- package/src/config.ts +68 -2
- package/src/db.ts +115 -2
- package/src/docs-tools.ts +8 -6
- package/src/hooks/post-edit-context.ts +1 -1
- package/src/hooks/post-tool-use.ts +130 -0
- package/src/hooks/pre-compact.ts +23 -1
- package/src/hooks/pre-delete-check.ts +92 -4
- package/src/hooks/security-gate.ts +32 -0
- package/src/hooks/session-start.ts +97 -4
- package/src/hooks/user-prompt.ts +46 -1
- package/src/import-resolver.ts +2 -1
- package/src/knowledge-db.ts +169 -0
- package/src/knowledge-indexer.ts +704 -0
- package/src/knowledge-tools.ts +1413 -0
- package/src/license.ts +482 -0
- package/src/memory-db.ts +14 -1
- package/src/observation-extractor.ts +11 -4
- package/src/page-deps.ts +3 -2
- package/src/python/coupling-detector.ts +124 -0
- package/src/python/domain-enforcer.ts +83 -0
- package/src/python/impact-analyzer.ts +95 -0
- package/src/python/import-parser.ts +244 -0
- package/src/python/import-resolver.ts +135 -0
- package/src/python/migration-indexer.ts +115 -0
- package/src/python/migration-parser.ts +332 -0
- package/src/python/model-indexer.ts +70 -0
- package/src/python/model-parser.ts +279 -0
- package/src/python/route-indexer.ts +58 -0
- package/src/python/route-parser.ts +317 -0
- package/src/python-tools.ts +629 -0
- package/src/sentinel-db.ts +2 -1
- package/src/server.ts +29 -6
- package/src/session-archiver.ts +4 -5
- package/src/tools.ts +283 -31
- package/README.md +0 -40
|
@@ -0,0 +1,629 @@
|
|
|
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 type { ToolDefinition, ToolResult } from './tools.ts';
|
|
6
|
+
import { getConfig } from './config.ts';
|
|
7
|
+
import { classifyPythonFileDomain } from './python/domain-enforcer.ts';
|
|
8
|
+
|
|
9
|
+
/** Get the configured tool prefix */
|
|
10
|
+
function p(name: string): string {
|
|
11
|
+
return `${getConfig().toolPrefix}_${name}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function stripPrefix(name: string): string {
|
|
15
|
+
const pfx = getConfig().toolPrefix + '_';
|
|
16
|
+
return name.startsWith(pfx) ? name.slice(pfx.length) : name;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function text(content: string): ToolResult {
|
|
20
|
+
return { content: [{ type: 'text', text: content }] };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Safely parse a JSON array from SQLite, returning [] on error */
|
|
24
|
+
function safeParseArray(json: string): unknown[] {
|
|
25
|
+
try { return JSON.parse(json); } catch { return []; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Python tool definitions — only included when python.root is configured.
|
|
30
|
+
*/
|
|
31
|
+
export function getPythonToolDefinitions(): ToolDefinition[] {
|
|
32
|
+
const config = getConfig();
|
|
33
|
+
if (!config.python?.root) return [];
|
|
34
|
+
|
|
35
|
+
return [
|
|
36
|
+
{
|
|
37
|
+
name: p('py_imports'),
|
|
38
|
+
description: 'Query the Python import graph. Show imports for a file, reverse lookup (who imports this), or transitive dependencies.',
|
|
39
|
+
inputSchema: {
|
|
40
|
+
type: 'object',
|
|
41
|
+
properties: {
|
|
42
|
+
file: { type: 'string', description: 'Python file path (relative to project root) to show imports for' },
|
|
43
|
+
imported_by: { type: 'string', description: 'Python file path to find reverse imports (who imports this file)' },
|
|
44
|
+
transitive: { type: 'boolean', description: 'If true, traverse import graph transitively' },
|
|
45
|
+
depth: { type: 'number', description: 'Max depth for transitive traversal (default: 5)' },
|
|
46
|
+
},
|
|
47
|
+
required: [],
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: p('py_routes'),
|
|
52
|
+
description: 'List and filter FastAPI/Flask routes. Find routes by method, path, file. Show unauthenticated routes or routes with no frontend callers.',
|
|
53
|
+
inputSchema: {
|
|
54
|
+
type: 'object',
|
|
55
|
+
properties: {
|
|
56
|
+
method: { type: 'string', description: 'Filter by HTTP method (GET, POST, etc.)' },
|
|
57
|
+
path: { type: 'string', description: 'Filter by route path pattern' },
|
|
58
|
+
file: { type: 'string', description: 'Filter by source file' },
|
|
59
|
+
unauthenticated: { type: 'boolean', description: 'Show only unauthenticated routes' },
|
|
60
|
+
uncoupled: { type: 'boolean', description: 'Show only routes with no frontend callers' },
|
|
61
|
+
limit: { type: 'number', description: 'Max results to return (default: 500)' },
|
|
62
|
+
},
|
|
63
|
+
required: [],
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
name: p('py_coupling'),
|
|
68
|
+
description: 'Show frontend-to-backend coupling map. Find which frontend files call which backend routes.',
|
|
69
|
+
inputSchema: {
|
|
70
|
+
type: 'object',
|
|
71
|
+
properties: {
|
|
72
|
+
frontend_file: { type: 'string', description: 'Filter by frontend file' },
|
|
73
|
+
backend_path: { type: 'string', description: 'Filter by backend route path' },
|
|
74
|
+
},
|
|
75
|
+
required: [],
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: p('py_models'),
|
|
80
|
+
description: 'SQLAlchemy model catalog. View models, FK graph, column details, or verify column references in a file.',
|
|
81
|
+
inputSchema: {
|
|
82
|
+
type: 'object',
|
|
83
|
+
properties: {
|
|
84
|
+
model: { type: 'string', description: 'Model class name to inspect' },
|
|
85
|
+
table: { type: 'string', description: 'Table name to inspect' },
|
|
86
|
+
fk_graph: { type: 'boolean', description: 'Show foreign key relationship graph' },
|
|
87
|
+
verify_file: { type: 'string', description: 'File path to verify column references against models' },
|
|
88
|
+
limit: { type: 'number', description: 'Max results to return (default: 500)' },
|
|
89
|
+
},
|
|
90
|
+
required: [],
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
name: p('py_migrations'),
|
|
95
|
+
description: 'Alembic migration information. View migration chain, specific revision details, or detect drift between models and migrations.',
|
|
96
|
+
inputSchema: {
|
|
97
|
+
type: 'object',
|
|
98
|
+
properties: {
|
|
99
|
+
revision: { type: 'string', description: 'Specific revision ID to inspect' },
|
|
100
|
+
chain: { type: 'boolean', description: 'Show full migration chain' },
|
|
101
|
+
drift: { type: 'boolean', description: 'Detect drift between models and migration state' },
|
|
102
|
+
limit: { type: 'number', description: 'Max results to return (default: 500)' },
|
|
103
|
+
},
|
|
104
|
+
required: [],
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
name: p('py_domains'),
|
|
109
|
+
description: 'Python domain boundary information. Classify files, show cross-domain imports, or list files in a domain.',
|
|
110
|
+
inputSchema: {
|
|
111
|
+
type: 'object',
|
|
112
|
+
properties: {
|
|
113
|
+
file: { type: 'string', description: 'File to classify into a domain' },
|
|
114
|
+
crossings: { type: 'boolean', description: 'Show all cross-domain import violations' },
|
|
115
|
+
domain: { type: 'string', description: 'Domain name to list files for' },
|
|
116
|
+
},
|
|
117
|
+
required: [],
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
name: p('py_impact'),
|
|
122
|
+
description: 'Full impact analysis for a Python file: affected endpoints, models, frontend pages, and domain crossings.',
|
|
123
|
+
inputSchema: {
|
|
124
|
+
type: 'object',
|
|
125
|
+
properties: {
|
|
126
|
+
file: { type: 'string', description: 'Python file path to analyze impact for' },
|
|
127
|
+
},
|
|
128
|
+
required: ['file'],
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
name: p('py_context'),
|
|
133
|
+
description: 'Complete context for a Python file: applicable rules, imports, domain, routes, models, and impact.',
|
|
134
|
+
inputSchema: {
|
|
135
|
+
type: 'object',
|
|
136
|
+
properties: {
|
|
137
|
+
file: { type: 'string', description: 'Python file path to get context for' },
|
|
138
|
+
},
|
|
139
|
+
required: ['file'],
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Check if a tool name is a Python tool */
|
|
146
|
+
export function isPythonTool(name: string): boolean {
|
|
147
|
+
const base = stripPrefix(name);
|
|
148
|
+
return base.startsWith('py_');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Handle Python tool calls */
|
|
152
|
+
export function handlePythonToolCall(
|
|
153
|
+
name: string,
|
|
154
|
+
args: Record<string, unknown>,
|
|
155
|
+
dataDb: Database.Database
|
|
156
|
+
): ToolResult {
|
|
157
|
+
const baseName = stripPrefix(name);
|
|
158
|
+
|
|
159
|
+
switch (baseName) {
|
|
160
|
+
case 'py_imports':
|
|
161
|
+
return handlePyImports(args, dataDb);
|
|
162
|
+
case 'py_routes':
|
|
163
|
+
return handlePyRoutes(args, dataDb);
|
|
164
|
+
case 'py_coupling':
|
|
165
|
+
return handlePyCoupling(args, dataDb);
|
|
166
|
+
case 'py_models':
|
|
167
|
+
return handlePyModels(args, dataDb);
|
|
168
|
+
case 'py_migrations':
|
|
169
|
+
return handlePyMigrations(args, dataDb);
|
|
170
|
+
case 'py_domains':
|
|
171
|
+
return handlePyDomains(args, dataDb);
|
|
172
|
+
case 'py_impact':
|
|
173
|
+
return handlePyImpact(args, dataDb);
|
|
174
|
+
case 'py_context':
|
|
175
|
+
return handlePyContext(args, dataDb);
|
|
176
|
+
default:
|
|
177
|
+
return text(`Unknown Python tool: ${name}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// === Tool Handlers ===
|
|
182
|
+
|
|
183
|
+
function handlePyImports(args: Record<string, unknown>, dataDb: Database.Database): ToolResult {
|
|
184
|
+
const lines: string[] = [];
|
|
185
|
+
const file = args.file as string | undefined;
|
|
186
|
+
const importedBy = args.imported_by as string | undefined;
|
|
187
|
+
const transitive = args.transitive as boolean | undefined;
|
|
188
|
+
const maxDepth = Math.min(Math.max((args.depth as number) || 5, 1), 20);
|
|
189
|
+
|
|
190
|
+
if (file) {
|
|
191
|
+
if (transitive) {
|
|
192
|
+
lines.push(`## Transitive imports from ${file} (max depth: ${maxDepth})`);
|
|
193
|
+
const visited = new Set<string>();
|
|
194
|
+
function traverse(f: string, depth: number): void {
|
|
195
|
+
if (depth > maxDepth || visited.has(f)) return;
|
|
196
|
+
visited.add(f);
|
|
197
|
+
const imports = dataDb.prepare(
|
|
198
|
+
'SELECT target_file, imported_names FROM massu_py_imports WHERE source_file = ?'
|
|
199
|
+
).all(f) as { target_file: string; imported_names: string }[];
|
|
200
|
+
for (const imp of imports) {
|
|
201
|
+
const indent = ' '.repeat(depth);
|
|
202
|
+
const names = safeParseArray(imp.imported_names) as string[];
|
|
203
|
+
lines.push(`${indent}- ${imp.target_file}${names.length > 0 ? ': ' + names.join(', ') : ''}`);
|
|
204
|
+
traverse(imp.target_file, depth + 1);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
traverse(file, 0);
|
|
208
|
+
} else {
|
|
209
|
+
const imports = dataDb.prepare(
|
|
210
|
+
'SELECT target_file, import_type, imported_names, line FROM massu_py_imports WHERE source_file = ? ORDER BY line'
|
|
211
|
+
).all(file) as { target_file: string; import_type: string; imported_names: string; line: number }[];
|
|
212
|
+
lines.push(`## Imports from ${file} (${imports.length} edges)`);
|
|
213
|
+
for (const imp of imports) {
|
|
214
|
+
const names = safeParseArray(imp.imported_names) as string[];
|
|
215
|
+
lines.push(`- L${imp.line} [${imp.import_type}] ${imp.target_file}${names.length > 0 ? ': ' + names.join(', ') : ''}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
} else if (importedBy) {
|
|
219
|
+
const importers = dataDb.prepare(
|
|
220
|
+
'SELECT source_file, imported_names, line FROM massu_py_imports WHERE target_file = ? ORDER BY source_file'
|
|
221
|
+
).all(importedBy) as { source_file: string; imported_names: string; line: number }[];
|
|
222
|
+
lines.push(`## Files importing ${importedBy} (${importers.length} edges)`);
|
|
223
|
+
for (const imp of importers) {
|
|
224
|
+
const names = safeParseArray(imp.imported_names) as string[];
|
|
225
|
+
lines.push(`- ${imp.source_file}:${imp.line}${names.length > 0 ? ' (' + names.join(', ') + ')' : ''}`);
|
|
226
|
+
}
|
|
227
|
+
} else {
|
|
228
|
+
const total = (dataDb.prepare('SELECT COUNT(*) as cnt FROM massu_py_imports').get() as { cnt: number }).cnt;
|
|
229
|
+
const files = (dataDb.prepare('SELECT COUNT(DISTINCT source_file) as cnt FROM massu_py_imports').get() as { cnt: number }).cnt;
|
|
230
|
+
lines.push('## Python Import Index Summary');
|
|
231
|
+
lines.push(`- Total import edges: ${total}`);
|
|
232
|
+
lines.push(`- Files with imports: ${files}`);
|
|
233
|
+
lines.push('');
|
|
234
|
+
lines.push('Use { file: "path" } to see imports for a specific file.');
|
|
235
|
+
lines.push('Use { imported_by: "path" } for reverse lookup.');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return text(lines.join('\n') || 'No Python import data available. Run sync first.');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function handlePyRoutes(args: Record<string, unknown>, dataDb: Database.Database): ToolResult {
|
|
242
|
+
const lines: string[] = [];
|
|
243
|
+
const limit = Math.min(Math.max((args.limit as number) || 500, 1), 5000);
|
|
244
|
+
let query = 'SELECT * FROM massu_py_routes WHERE 1=1';
|
|
245
|
+
const params: unknown[] = [];
|
|
246
|
+
|
|
247
|
+
if (args.method) { query += ' AND method = ?'; params.push((args.method as string).toUpperCase()); }
|
|
248
|
+
if (args.path) { query += ' AND path LIKE ?'; params.push(`%${args.path as string}%`); }
|
|
249
|
+
if (args.file) { query += ' AND file = ?'; params.push(args.file); }
|
|
250
|
+
if (args.unauthenticated) { query += ' AND is_authenticated = 0'; }
|
|
251
|
+
|
|
252
|
+
query += ` ORDER BY file, line LIMIT ${limit}`;
|
|
253
|
+
|
|
254
|
+
const routes = dataDb.prepare(query).all(...params) as {
|
|
255
|
+
id: number; file: string; method: string; path: string; function_name: string;
|
|
256
|
+
dependencies: string; request_model: string | null; response_model: string | null;
|
|
257
|
+
is_authenticated: number; line: number;
|
|
258
|
+
}[];
|
|
259
|
+
|
|
260
|
+
if (args.uncoupled) {
|
|
261
|
+
const callerCountStmt = dataDb.prepare('SELECT COUNT(*) as cnt FROM massu_py_route_callers WHERE route_id = ?');
|
|
262
|
+
const uncoupledRoutes = routes.filter(r => {
|
|
263
|
+
const callers = (callerCountStmt.get(r.id) as { cnt: number }).cnt;
|
|
264
|
+
return callers === 0;
|
|
265
|
+
});
|
|
266
|
+
lines.push(`## Uncoupled Routes (${uncoupledRoutes.length} with no frontend callers)`);
|
|
267
|
+
for (const r of uncoupledRoutes) {
|
|
268
|
+
lines.push(`- ${r.method} ${r.path} → ${r.function_name} (${r.file}:${r.line})`);
|
|
269
|
+
}
|
|
270
|
+
} else {
|
|
271
|
+
lines.push(`## Python Routes (${routes.length} found)`);
|
|
272
|
+
for (const r of routes) {
|
|
273
|
+
const auth = r.is_authenticated ? '' : ' [UNAUTH]';
|
|
274
|
+
const deps = safeParseArray(r.dependencies) as string[];
|
|
275
|
+
lines.push(`- ${r.method} ${r.path} → ${r.function_name} (${r.file}:${r.line})${auth}`);
|
|
276
|
+
if (r.response_model) lines.push(` Response: ${r.response_model}`);
|
|
277
|
+
if (deps.length > 0) lines.push(` Depends: ${deps.join(', ')}`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return text(lines.join('\n') || 'No Python routes found.');
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function handlePyCoupling(args: Record<string, unknown>, dataDb: Database.Database): ToolResult {
|
|
285
|
+
const lines: string[] = [];
|
|
286
|
+
let query = `
|
|
287
|
+
SELECT rc.frontend_file, rc.call_pattern, rc.line as caller_line,
|
|
288
|
+
r.method, r.path, r.function_name, r.file as backend_file
|
|
289
|
+
FROM massu_py_route_callers rc
|
|
290
|
+
JOIN massu_py_routes r ON rc.route_id = r.id
|
|
291
|
+
WHERE 1=1
|
|
292
|
+
`;
|
|
293
|
+
const params: unknown[] = [];
|
|
294
|
+
|
|
295
|
+
if (args.frontend_file) { query += ' AND rc.frontend_file = ?'; params.push(args.frontend_file); }
|
|
296
|
+
if (args.backend_path) { query += ' AND r.path LIKE ?'; params.push(`%${args.backend_path as string}%`); }
|
|
297
|
+
|
|
298
|
+
query += ' ORDER BY rc.frontend_file, rc.line';
|
|
299
|
+
|
|
300
|
+
const couplings = dataDb.prepare(query).all(...params) as {
|
|
301
|
+
frontend_file: string; call_pattern: string; caller_line: number;
|
|
302
|
+
method: string; path: string; function_name: string; backend_file: string;
|
|
303
|
+
}[];
|
|
304
|
+
|
|
305
|
+
lines.push(`## Frontend ↔ Backend Coupling (${couplings.length} connections)`);
|
|
306
|
+
let currentFrontend = '';
|
|
307
|
+
for (const c of couplings) {
|
|
308
|
+
if (c.frontend_file !== currentFrontend) {
|
|
309
|
+
currentFrontend = c.frontend_file;
|
|
310
|
+
lines.push(`\n### ${currentFrontend}`);
|
|
311
|
+
}
|
|
312
|
+
lines.push(`- L${c.caller_line}: ${c.call_pattern} → ${c.method} ${c.path} (${c.backend_file})`);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return text(lines.join('\n') || 'No coupling data found.');
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function handlePyModels(args: Record<string, unknown>, dataDb: Database.Database): ToolResult {
|
|
319
|
+
const lines: string[] = [];
|
|
320
|
+
|
|
321
|
+
if (args.fk_graph) {
|
|
322
|
+
const edges = dataDb.prepare('SELECT * FROM massu_py_fk_edges ORDER BY source_table').all() as {
|
|
323
|
+
source_table: string; source_column: string; target_table: string; target_column: string;
|
|
324
|
+
}[];
|
|
325
|
+
lines.push(`## Foreign Key Graph (${edges.length} edges)`);
|
|
326
|
+
for (const e of edges) {
|
|
327
|
+
lines.push(`- ${e.source_table}.${e.source_column} → ${e.target_table}.${e.target_column}`);
|
|
328
|
+
}
|
|
329
|
+
} else if (args.model || args.table) {
|
|
330
|
+
const search = (args.model || args.table) as string;
|
|
331
|
+
const model = dataDb.prepare(
|
|
332
|
+
'SELECT * FROM massu_py_models WHERE class_name = ? OR table_name = ?'
|
|
333
|
+
).get(search, search) as {
|
|
334
|
+
class_name: string; table_name: string; file: string; line: number;
|
|
335
|
+
columns: string; relationships: string; foreign_keys: string;
|
|
336
|
+
} | undefined;
|
|
337
|
+
|
|
338
|
+
if (!model) return text(`Model "${search}" not found.`);
|
|
339
|
+
|
|
340
|
+
lines.push(`## ${model.class_name} (table: ${model.table_name || 'N/A'})`);
|
|
341
|
+
lines.push(`File: ${model.file}:${model.line}`);
|
|
342
|
+
|
|
343
|
+
const cols = safeParseArray(model.columns) as { name: string; type: string; nullable?: boolean; primary_key?: boolean }[];
|
|
344
|
+
if (cols.length > 0) {
|
|
345
|
+
lines.push('\n### Columns');
|
|
346
|
+
for (const col of cols) {
|
|
347
|
+
lines.push(`- ${col.name}: ${col.type}${col.nullable ? '?' : ''}${col.primary_key ? ' [PK]' : ''}`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const rels = safeParseArray(model.relationships) as { name: string; target: string; back_populates?: string }[];
|
|
352
|
+
if (rels.length > 0) {
|
|
353
|
+
lines.push('\n### Relationships');
|
|
354
|
+
for (const rel of rels) {
|
|
355
|
+
lines.push(`- ${rel.name} → ${rel.target}${rel.back_populates ? ` (back_populates: ${rel.back_populates})` : ''}`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const fks = safeParseArray(model.foreign_keys) as { column: string; target: string }[];
|
|
360
|
+
if (fks.length > 0) {
|
|
361
|
+
lines.push('\n### Foreign Keys');
|
|
362
|
+
for (const fk of fks) {
|
|
363
|
+
lines.push(`- ${fk.column} → ${fk.target}`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
} else {
|
|
367
|
+
const limit = Math.min(Math.max((args.limit as number) || 500, 1), 5000);
|
|
368
|
+
const models = dataDb.prepare(`SELECT class_name, table_name, file, line FROM massu_py_models ORDER BY class_name LIMIT ${limit}`).all() as {
|
|
369
|
+
class_name: string; table_name: string; file: string; line: number;
|
|
370
|
+
}[];
|
|
371
|
+
lines.push(`## Python Models (${models.length})`);
|
|
372
|
+
for (const m of models) {
|
|
373
|
+
lines.push(`- ${m.class_name} (${m.table_name || 'no table'}) — ${m.file}:${m.line}`);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return text(lines.join('\n') || 'No Python models found.');
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function handlePyMigrations(args: Record<string, unknown>, dataDb: Database.Database): ToolResult {
|
|
381
|
+
const lines: string[] = [];
|
|
382
|
+
|
|
383
|
+
if (args.drift) {
|
|
384
|
+
// Compare models against migration operations
|
|
385
|
+
const models = dataDb.prepare('SELECT class_name, table_name, columns FROM massu_py_models').all() as {
|
|
386
|
+
class_name: string; table_name: string; columns: string;
|
|
387
|
+
}[];
|
|
388
|
+
const migrations = dataDb.prepare('SELECT revision, operations FROM massu_py_migrations').all() as {
|
|
389
|
+
revision: string; operations: string;
|
|
390
|
+
}[];
|
|
391
|
+
|
|
392
|
+
const migratedTables = new Set<string>();
|
|
393
|
+
for (const m of migrations) {
|
|
394
|
+
const ops = safeParseArray(m.operations) as { type: string; table?: string; column?: string }[];
|
|
395
|
+
for (const op of ops) {
|
|
396
|
+
if (op.table) migratedTables.add(op.table);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
lines.push('## Migration Drift Report');
|
|
401
|
+
const unmigrated = models.filter(m => m.table_name && !migratedTables.has(m.table_name));
|
|
402
|
+
if (unmigrated.length > 0) {
|
|
403
|
+
lines.push(`\n### Models with no migration (${unmigrated.length})`);
|
|
404
|
+
for (const m of unmigrated) {
|
|
405
|
+
lines.push(`- ${m.class_name} (table: ${m.table_name})`);
|
|
406
|
+
}
|
|
407
|
+
} else {
|
|
408
|
+
lines.push('All models have corresponding migrations.');
|
|
409
|
+
}
|
|
410
|
+
} else if (args.chain) {
|
|
411
|
+
const limit = Math.min(Math.max((args.limit as number) || 500, 1), 5000);
|
|
412
|
+
const migrations = dataDb.prepare(`SELECT * FROM massu_py_migrations ORDER BY id LIMIT ${limit}`).all() as {
|
|
413
|
+
revision: string; down_revision: string | null; file: string; description: string | null; is_head: number;
|
|
414
|
+
}[];
|
|
415
|
+
lines.push(`## Migration Chain (${migrations.length} revisions)`);
|
|
416
|
+
for (const m of migrations) {
|
|
417
|
+
const head = m.is_head ? ' [HEAD]' : '';
|
|
418
|
+
lines.push(`- ${m.revision}${head} ← ${m.down_revision || 'BASE'}: ${m.description || '(no description)'} (${m.file})`);
|
|
419
|
+
}
|
|
420
|
+
} else if (args.revision) {
|
|
421
|
+
const m = dataDb.prepare('SELECT * FROM massu_py_migrations WHERE revision = ?').get(args.revision) as {
|
|
422
|
+
revision: string; down_revision: string | null; file: string; description: string | null;
|
|
423
|
+
operations: string; is_head: number;
|
|
424
|
+
} | undefined;
|
|
425
|
+
if (!m) return text(`Revision "${args.revision}" not found.`);
|
|
426
|
+
lines.push(`## Revision: ${m.revision}`);
|
|
427
|
+
lines.push(`Down: ${m.down_revision || 'BASE'}`);
|
|
428
|
+
lines.push(`File: ${m.file}`);
|
|
429
|
+
lines.push(`Description: ${m.description || 'N/A'}`);
|
|
430
|
+
const ops = safeParseArray(m.operations) as { type: string; table?: string; column?: string }[];
|
|
431
|
+
if (ops.length > 0) {
|
|
432
|
+
lines.push('\n### Operations');
|
|
433
|
+
for (const op of ops) {
|
|
434
|
+
lines.push(`- ${op.type}: ${op.table || op.column || ''}`);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
} else {
|
|
438
|
+
const total = (dataDb.prepare('SELECT COUNT(*) as cnt FROM massu_py_migrations').get() as { cnt: number }).cnt;
|
|
439
|
+
const head = dataDb.prepare('SELECT revision FROM massu_py_migrations WHERE is_head = 1').get() as { revision: string } | undefined;
|
|
440
|
+
lines.push('## Migration Summary');
|
|
441
|
+
lines.push(`- Total revisions: ${total}`);
|
|
442
|
+
lines.push(`- Current head: ${head?.revision || 'N/A'}`);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return text(lines.join('\n') || 'No Python migration data found.');
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function handlePyDomains(args: Record<string, unknown>, dataDb: Database.Database): ToolResult {
|
|
449
|
+
const lines: string[] = [];
|
|
450
|
+
const config = getConfig();
|
|
451
|
+
const domains = config.python?.domains || [];
|
|
452
|
+
|
|
453
|
+
if (args.file) {
|
|
454
|
+
const file = args.file as string;
|
|
455
|
+
const domain = classifyPythonFileDomain(file);
|
|
456
|
+
lines.push(`## ${file}`);
|
|
457
|
+
lines.push(`Domain: ${domain}`);
|
|
458
|
+
const domainConfig = domains.find(d => d.name === domain);
|
|
459
|
+
if (domainConfig) {
|
|
460
|
+
lines.push(`Allowed imports from: ${domainConfig.allowed_imports_from.join(', ') || 'any'}`);
|
|
461
|
+
}
|
|
462
|
+
} else if (args.crossings) {
|
|
463
|
+
const imports = dataDb.prepare('SELECT source_file, target_file FROM massu_py_imports').all() as {
|
|
464
|
+
source_file: string; target_file: string;
|
|
465
|
+
}[];
|
|
466
|
+
const violations: string[] = [];
|
|
467
|
+
for (const imp of imports) {
|
|
468
|
+
const srcDomain = classifyPythonFileDomain(imp.source_file);
|
|
469
|
+
const tgtDomain = classifyPythonFileDomain(imp.target_file);
|
|
470
|
+
if (srcDomain !== tgtDomain && srcDomain !== 'Unknown' && tgtDomain !== 'Unknown') {
|
|
471
|
+
const srcConfig = domains.find(d => d.name === srcDomain);
|
|
472
|
+
if (srcConfig && !srcConfig.allowed_imports_from.includes(tgtDomain)) {
|
|
473
|
+
violations.push(`${imp.source_file} (${srcDomain}) → ${imp.target_file} (${tgtDomain})`);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
lines.push(`## Cross-Domain Violations (${violations.length})`);
|
|
478
|
+
for (const v of violations.slice(0, 50)) {
|
|
479
|
+
lines.push(`- ${v}`);
|
|
480
|
+
}
|
|
481
|
+
if (violations.length > 50) lines.push(`... and ${violations.length - 50} more`);
|
|
482
|
+
} else if (args.domain) {
|
|
483
|
+
const domainName = args.domain as string;
|
|
484
|
+
const allFiles = dataDb.prepare('SELECT DISTINCT source_file FROM massu_py_imports UNION SELECT DISTINCT target_file FROM massu_py_imports').all() as { source_file: string }[];
|
|
485
|
+
const filesInDomain = allFiles.filter(f => classifyPythonFileDomain(f.source_file) === domainName);
|
|
486
|
+
lines.push(`## Domain: ${domainName} (${filesInDomain.length} files)`);
|
|
487
|
+
for (const f of filesInDomain) {
|
|
488
|
+
lines.push(`- ${f.source_file}`);
|
|
489
|
+
}
|
|
490
|
+
} else {
|
|
491
|
+
lines.push('## Python Domain Summary');
|
|
492
|
+
for (const d of domains) {
|
|
493
|
+
lines.push(`- **${d.name}**: packages ${d.packages.join(', ')}, imports from: ${d.allowed_imports_from.join(', ') || 'any'}`);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
return text(lines.join('\n') || 'No Python domains configured.');
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function handlePyImpact(args: Record<string, unknown>, dataDb: Database.Database): ToolResult {
|
|
501
|
+
const file = args.file as string;
|
|
502
|
+
const lines: string[] = [`## Impact Analysis: ${file}`, ''];
|
|
503
|
+
|
|
504
|
+
// 1. Who imports this file
|
|
505
|
+
const importedBy = dataDb.prepare(
|
|
506
|
+
'SELECT source_file FROM massu_py_imports WHERE target_file = ?'
|
|
507
|
+
).all(file) as { source_file: string }[];
|
|
508
|
+
lines.push(`### Imported By (${importedBy.length} files)`);
|
|
509
|
+
for (const imp of importedBy.slice(0, 20)) {
|
|
510
|
+
lines.push(`- ${imp.source_file}`);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// 2. Routes in this file
|
|
514
|
+
const routes = dataDb.prepare(
|
|
515
|
+
'SELECT method, path, function_name FROM massu_py_routes WHERE file = ?'
|
|
516
|
+
).all(file) as { method: string; path: string; function_name: string }[];
|
|
517
|
+
if (routes.length > 0) {
|
|
518
|
+
lines.push(`\n### Routes Defined (${routes.length})`);
|
|
519
|
+
for (const r of routes) {
|
|
520
|
+
lines.push(`- ${r.method} ${r.path} → ${r.function_name}`);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// 3. Models in this file
|
|
525
|
+
const models = dataDb.prepare(
|
|
526
|
+
'SELECT class_name, table_name FROM massu_py_models WHERE file = ?'
|
|
527
|
+
).all(file) as { class_name: string; table_name: string }[];
|
|
528
|
+
if (models.length > 0) {
|
|
529
|
+
lines.push(`\n### Models Defined (${models.length})`);
|
|
530
|
+
for (const m of models) {
|
|
531
|
+
lines.push(`- ${m.class_name} (${m.table_name || 'no table'})`);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// 4. Frontend callers (via routes)
|
|
536
|
+
const routeIds = dataDb.prepare('SELECT id FROM massu_py_routes WHERE file = ?').all(file) as { id: number }[];
|
|
537
|
+
if (routeIds.length > 0) {
|
|
538
|
+
const placeholders = routeIds.map(() => '?').join(',');
|
|
539
|
+
const callers = dataDb.prepare(
|
|
540
|
+
`SELECT DISTINCT frontend_file FROM massu_py_route_callers WHERE route_id IN (${placeholders})`
|
|
541
|
+
).all(...routeIds.map(r => r.id)) as { frontend_file: string }[];
|
|
542
|
+
if (callers.length > 0) {
|
|
543
|
+
lines.push(`\n### Frontend Callers (${callers.length} files)`);
|
|
544
|
+
for (const c of callers) {
|
|
545
|
+
lines.push(`- ${c.frontend_file}`);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// 5. Domain
|
|
551
|
+
const config = getConfig();
|
|
552
|
+
const domain = classifyPythonFileDomain(file);
|
|
553
|
+
lines.push(`\n### Domain: ${domain}`);
|
|
554
|
+
|
|
555
|
+
return text(lines.join('\n'));
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function handlePyContext(args: Record<string, unknown>, dataDb: Database.Database): ToolResult {
|
|
559
|
+
const file = args.file as string;
|
|
560
|
+
const lines: string[] = [`## Python Context: ${file}`, ''];
|
|
561
|
+
const config = getConfig();
|
|
562
|
+
|
|
563
|
+
// Domain
|
|
564
|
+
const domain = classifyPythonFileDomain(file);
|
|
565
|
+
lines.push(`**Domain**: ${domain}`);
|
|
566
|
+
|
|
567
|
+
// Imports
|
|
568
|
+
const imports = dataDb.prepare(
|
|
569
|
+
'SELECT target_file, imported_names FROM massu_py_imports WHERE source_file = ? LIMIT 20'
|
|
570
|
+
).all(file) as { target_file: string; imported_names: string }[];
|
|
571
|
+
if (imports.length > 0) {
|
|
572
|
+
lines.push('\n### Imports');
|
|
573
|
+
for (const imp of imports) {
|
|
574
|
+
const names = safeParseArray(imp.imported_names) as string[];
|
|
575
|
+
lines.push(`- ${imp.target_file}${names.length > 0 ? ': ' + names.join(', ') : ''}`);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Imported by
|
|
580
|
+
const importedBy = dataDb.prepare(
|
|
581
|
+
'SELECT source_file FROM massu_py_imports WHERE target_file = ? LIMIT 10'
|
|
582
|
+
).all(file) as { source_file: string }[];
|
|
583
|
+
if (importedBy.length > 0) {
|
|
584
|
+
lines.push('\n### Imported By');
|
|
585
|
+
for (const imp of importedBy) {
|
|
586
|
+
lines.push(`- ${imp.source_file}`);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Routes
|
|
591
|
+
const routes = dataDb.prepare('SELECT method, path, function_name, line FROM massu_py_routes WHERE file = ?')
|
|
592
|
+
.all(file) as { method: string; path: string; function_name: string; line: number }[];
|
|
593
|
+
if (routes.length > 0) {
|
|
594
|
+
lines.push('\n### Routes');
|
|
595
|
+
for (const r of routes) {
|
|
596
|
+
lines.push(`- ${r.method} ${r.path} → ${r.function_name} (L${r.line})`);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Models
|
|
601
|
+
const models = dataDb.prepare('SELECT class_name, table_name, line FROM massu_py_models WHERE file = ?')
|
|
602
|
+
.all(file) as { class_name: string; table_name: string; line: number }[];
|
|
603
|
+
if (models.length > 0) {
|
|
604
|
+
lines.push('\n### Models');
|
|
605
|
+
for (const m of models) {
|
|
606
|
+
lines.push(`- ${m.class_name} (${m.table_name || 'N/A'}) L${m.line}`);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Applicable rules from config
|
|
611
|
+
const rules = config.rules || [];
|
|
612
|
+
const matchingRules = rules.filter(r => {
|
|
613
|
+
const pattern = r.pattern;
|
|
614
|
+
if (pattern === '**') return true;
|
|
615
|
+
if (pattern.includes('**/*.py')) return file.endsWith('.py');
|
|
616
|
+
return file.includes(pattern.replace(/\*\*/g, ''));
|
|
617
|
+
});
|
|
618
|
+
if (matchingRules.length > 0) {
|
|
619
|
+
lines.push('\n### Applicable Rules');
|
|
620
|
+
for (const rule of matchingRules) {
|
|
621
|
+
for (const r of rule.rules) {
|
|
622
|
+
lines.push(`- ${r}`);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
return text(lines.join('\n'));
|
|
628
|
+
}
|
|
629
|
+
|
package/src/sentinel-db.ts
CHANGED
|
@@ -23,6 +23,7 @@ import type {
|
|
|
23
23
|
ComponentRole,
|
|
24
24
|
} from './sentinel-types.ts';
|
|
25
25
|
import { getProjectRoot } from './config.ts';
|
|
26
|
+
import { sanitizeFts5Query } from './memory-db.ts';
|
|
26
27
|
|
|
27
28
|
// ============================================================
|
|
28
29
|
// Sentinel: Feature Registry Data Access Layer
|
|
@@ -135,7 +136,7 @@ export function searchFeatures(
|
|
|
135
136
|
JOIN massu_sentinel_fts fts ON s.id = fts.rowid
|
|
136
137
|
WHERE massu_sentinel_fts MATCH ?
|
|
137
138
|
`;
|
|
138
|
-
params.push(query);
|
|
139
|
+
params.push(sanitizeFts5Query(query));
|
|
139
140
|
} else {
|
|
140
141
|
sql = `
|
|
141
142
|
SELECT s.*,
|