@massu/core 0.1.1 → 0.4.0
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 +7772 -3140
- package/dist/hooks/cost-tracker.js +103 -40
- package/dist/hooks/post-edit-context.js +74 -8
- package/dist/hooks/post-tool-use.js +268 -106
- package/dist/hooks/pre-compact.js +167 -43
- package/dist/hooks/pre-delete-check.js +159 -42
- package/dist/hooks/quality-event.js +103 -40
- package/dist/hooks/security-gate.js +29 -0
- package/dist/hooks/session-end.js +143 -84
- package/dist/hooks/session-start.js +186 -49
- package/dist/hooks/user-prompt.js +189 -43
- package/package.json +10 -15
- package/src/adr-generator.ts +9 -2
- package/src/analytics.ts +9 -3
- package/src/audit-trail.ts +10 -3
- package/src/backfill-sessions.ts +5 -4
- package/src/cli.ts +6 -0
- package/src/cloud-sync.ts +14 -18
- package/src/commands/doctor.ts +193 -6
- package/src/commands/init.ts +230 -5
- package/src/commands/install-commands.ts +137 -0
- package/src/config.ts +68 -2
- package/src/cost-tracker.ts +11 -6
- package/src/db.ts +115 -2
- package/src/dependency-scorer.ts +9 -2
- package/src/docs-tools.ts +21 -16
- package/src/hooks/post-edit-context.ts +4 -4
- 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-end.ts +3 -3
- package/src/hooks/session-start.ts +99 -6
- 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 +1364 -23
- package/src/memory-tools.ts +14 -15
- package/src/observability-tools.ts +13 -2
- package/src/observation-extractor.ts +11 -4
- package/src/page-deps.ts +3 -2
- package/src/prompt-analyzer.ts +9 -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/regression-detector.ts +9 -3
- package/src/security-scorer.ts +9 -2
- package/src/sentinel-db.ts +45 -89
- package/src/sentinel-tools.ts +8 -11
- package/src/server.ts +29 -7
- package/src/session-archiver.ts +4 -5
- package/src/team-knowledge.ts +9 -2
- package/src/tools.ts +1032 -44
- package/src/validate-features-runner.ts +0 -1
- package/src/validation-engine.ts +9 -2
- package/README.md +0 -40
- package/dist/server.js +0 -7008
- package/src/__tests__/adr-generator.test.ts +0 -260
- package/src/__tests__/analytics.test.ts +0 -282
- package/src/__tests__/audit-trail.test.ts +0 -382
- package/src/__tests__/backfill-sessions.test.ts +0 -690
- package/src/__tests__/cli.test.ts +0 -290
- package/src/__tests__/cloud-sync.test.ts +0 -261
- package/src/__tests__/config-sections.test.ts +0 -359
- package/src/__tests__/config.test.ts +0 -732
- package/src/__tests__/cost-tracker.test.ts +0 -348
- package/src/__tests__/db.test.ts +0 -177
- package/src/__tests__/dependency-scorer.test.ts +0 -325
- package/src/__tests__/docs-integration.test.ts +0 -178
- package/src/__tests__/docs-tools.test.ts +0 -199
- package/src/__tests__/domains.test.ts +0 -236
- package/src/__tests__/hooks.test.ts +0 -221
- package/src/__tests__/import-resolver.test.ts +0 -95
- package/src/__tests__/integration/path-traversal.test.ts +0 -134
- package/src/__tests__/integration/pricing-consistency.test.ts +0 -88
- package/src/__tests__/integration/tool-registration.test.ts +0 -146
- package/src/__tests__/memory-db.test.ts +0 -404
- package/src/__tests__/memory-enhancements.test.ts +0 -316
- package/src/__tests__/memory-tools.test.ts +0 -199
- package/src/__tests__/middleware-tree.test.ts +0 -177
- package/src/__tests__/observability-tools.test.ts +0 -595
- package/src/__tests__/observability.test.ts +0 -437
- package/src/__tests__/observation-extractor.test.ts +0 -167
- package/src/__tests__/page-deps.test.ts +0 -60
- package/src/__tests__/prompt-analyzer.test.ts +0 -298
- package/src/__tests__/regression-detector.test.ts +0 -295
- package/src/__tests__/rules.test.ts +0 -87
- package/src/__tests__/schema-mapper.test.ts +0 -29
- package/src/__tests__/security-scorer.test.ts +0 -238
- package/src/__tests__/security-utils.test.ts +0 -175
- package/src/__tests__/sentinel-db.test.ts +0 -491
- package/src/__tests__/sentinel-scanner.test.ts +0 -750
- package/src/__tests__/sentinel-tools.test.ts +0 -324
- package/src/__tests__/sentinel-types.test.ts +0 -750
- package/src/__tests__/server.test.ts +0 -452
- package/src/__tests__/session-archiver.test.ts +0 -524
- package/src/__tests__/session-state-generator.test.ts +0 -900
- package/src/__tests__/team-knowledge.test.ts +0 -327
- package/src/__tests__/tools.test.ts +0 -340
- package/src/__tests__/transcript-parser.test.ts +0 -195
- package/src/__tests__/trpc-index.test.ts +0 -25
- package/src/__tests__/validate-features-runner.test.ts +0 -517
- package/src/__tests__/validation-engine.test.ts +0 -300
- package/src/core-tools.ts +0 -685
- package/src/memory-queries.ts +0 -804
- package/src/memory-schema.ts +0 -546
- package/src/tool-helpers.ts +0 -41
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// Copyright (c) 2026 Massu. All rights reserved.
|
|
2
|
+
// Licensed under BSL 1.1 - see LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
export interface MigrationOperation {
|
|
5
|
+
type:
|
|
6
|
+
| 'create_table'
|
|
7
|
+
| 'drop_table'
|
|
8
|
+
| 'add_column'
|
|
9
|
+
| 'drop_column'
|
|
10
|
+
| 'alter_column'
|
|
11
|
+
| 'create_index'
|
|
12
|
+
| 'other';
|
|
13
|
+
table?: string;
|
|
14
|
+
column?: string;
|
|
15
|
+
details?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ParsedMigration {
|
|
19
|
+
revision: string;
|
|
20
|
+
downRevision: string | null;
|
|
21
|
+
description: string | null;
|
|
22
|
+
operations: MigrationOperation[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Extract a string assignment value from source.
|
|
27
|
+
* Handles: revision = 'abc123' and revision = "abc123"
|
|
28
|
+
*/
|
|
29
|
+
function extractStringVar(source: string, varName: string): string | null {
|
|
30
|
+
const re = new RegExp(`^${varName}\\s*(?::\\s*\\w+)?\\s*=\\s*['"]([^'"]*)['"]\s*$`, 'm');
|
|
31
|
+
const m = source.match(re);
|
|
32
|
+
return m ? m[1] : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Extract a variable that can be a string or None.
|
|
37
|
+
* Handles: down_revision = None, down_revision = 'abc', down_revision: ... = None
|
|
38
|
+
*/
|
|
39
|
+
function extractNullableStringVar(source: string, varName: string): string | null {
|
|
40
|
+
// Check for None first
|
|
41
|
+
const noneRe = new RegExp(`^${varName}\\s*(?::\\s*[^=]+)?\\s*=\\s*None\\s*$`, 'm');
|
|
42
|
+
if (noneRe.test(source)) return null;
|
|
43
|
+
|
|
44
|
+
return extractStringVar(source, varName);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Extract the description from the module docstring or revision message.
|
|
49
|
+
* Alembic typically puts it in a comment like: """description here"""
|
|
50
|
+
* or in the Revision ID comment block.
|
|
51
|
+
*/
|
|
52
|
+
function extractDescription(source: string): string | null {
|
|
53
|
+
// Try triple-quoted module docstring
|
|
54
|
+
const docMatch = source.match(/^"""(.*?)"""/ms);
|
|
55
|
+
if (docMatch) {
|
|
56
|
+
const firstLine = docMatch[1].trim().split('\n')[0].trim();
|
|
57
|
+
if (firstLine && !firstLine.startsWith('Revision ID')) {
|
|
58
|
+
return firstLine;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Try single-line comment after "Revises:" block
|
|
63
|
+
const descMatch = source.match(/^#\s*Revision ID:\s*\w+\n#\s*Revises:\s*\w*\n#\s*Create Date:\s*.+\n\n"""(.+?)"""/m);
|
|
64
|
+
if (descMatch) return descMatch[1].trim();
|
|
65
|
+
|
|
66
|
+
// Try the first triple-quoted string content that looks like a description
|
|
67
|
+
const tripleMatch = source.match(/"""([^"]+?)(?:\n\nRevision ID|\n\n|""")/);
|
|
68
|
+
if (tripleMatch) {
|
|
69
|
+
const desc = tripleMatch[1].trim();
|
|
70
|
+
if (desc && desc.length < 200) return desc;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Extract the body of the upgrade() function.
|
|
78
|
+
*/
|
|
79
|
+
function extractFunctionBody(source: string, funcName: string): string {
|
|
80
|
+
const funcRe = new RegExp(`^def\\s+${funcName}\\s*\\([^)]*\\)\\s*(?:->\\s*\\w+)?\\s*:`, 'm');
|
|
81
|
+
const match = funcRe.exec(source);
|
|
82
|
+
if (!match) return '';
|
|
83
|
+
|
|
84
|
+
const startIndex = match.index + match[0].length;
|
|
85
|
+
const lines = source.substring(startIndex).split('\n');
|
|
86
|
+
const bodyLines: string[] = [];
|
|
87
|
+
|
|
88
|
+
for (let i = 0; i < lines.length; i++) {
|
|
89
|
+
const line = lines[i];
|
|
90
|
+
// Empty lines are part of the body
|
|
91
|
+
if (line.trim() === '') {
|
|
92
|
+
bodyLines.push(line);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
// If line starts with indent (space or tab), it's part of the body
|
|
96
|
+
if (line.startsWith(' ') || line.startsWith('\t')) {
|
|
97
|
+
bodyLines.push(line);
|
|
98
|
+
} else {
|
|
99
|
+
// Hit a non-indented line — end of function
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return bodyLines.join('\n');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Extract the first quoted string argument from an op call.
|
|
109
|
+
* e.g., op.create_table('users', ...) -> 'users'
|
|
110
|
+
*/
|
|
111
|
+
function extractFirstStringArg(argsStr: string): string | null {
|
|
112
|
+
const m = argsStr.match(/['"]([^'"]+)['"]/);
|
|
113
|
+
return m ? m[1] : null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Extract table and column from op calls that take (table, column, ...).
|
|
118
|
+
* e.g., op.add_column('users', sa.Column('email', ...)) -> { table: 'users', column: 'email' }
|
|
119
|
+
*/
|
|
120
|
+
function extractTableAndColumn(argsStr: string): { table: string | null; column: string | null } {
|
|
121
|
+
const parts = splitTopLevelCommas(argsStr);
|
|
122
|
+
const table = parts[0] ? extractFirstStringArg(parts[0]) : null;
|
|
123
|
+
|
|
124
|
+
// For add_column, the second arg is sa.Column('name', ...)
|
|
125
|
+
let column: string | null = null;
|
|
126
|
+
if (parts[1]) {
|
|
127
|
+
const colMatch = parts[1].match(/Column\s*\(\s*['"]([^'"]+)['"]/);
|
|
128
|
+
if (colMatch) {
|
|
129
|
+
column = colMatch[1];
|
|
130
|
+
} else {
|
|
131
|
+
// For drop_column, it's just a string: op.drop_column('table', 'column')
|
|
132
|
+
column = extractFirstStringArg(parts[1]);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { table, column };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Split a string by top-level commas (not inside parens/brackets/strings).
|
|
141
|
+
*/
|
|
142
|
+
function splitTopLevelCommas(text: string): string[] {
|
|
143
|
+
const parts: string[] = [];
|
|
144
|
+
let current = '';
|
|
145
|
+
let depth = 0;
|
|
146
|
+
let inString: string | null = null;
|
|
147
|
+
|
|
148
|
+
for (let i = 0; i < text.length; i++) {
|
|
149
|
+
const ch = text[i];
|
|
150
|
+
|
|
151
|
+
if (inString) {
|
|
152
|
+
current += ch;
|
|
153
|
+
if (ch === inString && text[i - 1] !== '\\') {
|
|
154
|
+
inString = null;
|
|
155
|
+
}
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (ch === "'" || ch === '"') {
|
|
160
|
+
inString = ch;
|
|
161
|
+
current += ch;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (ch === '(' || ch === '[') {
|
|
166
|
+
depth++;
|
|
167
|
+
current += ch;
|
|
168
|
+
} else if (ch === ')' || ch === ']') {
|
|
169
|
+
depth = Math.max(0, depth - 1);
|
|
170
|
+
current += ch;
|
|
171
|
+
} else if (ch === ',' && depth === 0) {
|
|
172
|
+
parts.push(current.trim());
|
|
173
|
+
current = '';
|
|
174
|
+
} else {
|
|
175
|
+
current += ch;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (current.trim()) parts.push(current.trim());
|
|
180
|
+
return parts;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Parse operations from the upgrade function body.
|
|
185
|
+
*/
|
|
186
|
+
function parseOperations(body: string): MigrationOperation[] {
|
|
187
|
+
const operations: MigrationOperation[] = [];
|
|
188
|
+
|
|
189
|
+
// Join multi-line op calls
|
|
190
|
+
const joinedBody = joinOpCalls(body);
|
|
191
|
+
const lines = joinedBody.split('\n');
|
|
192
|
+
|
|
193
|
+
for (const line of lines) {
|
|
194
|
+
const trimmed = line.trim();
|
|
195
|
+
|
|
196
|
+
// op.create_table(...)
|
|
197
|
+
const createTableMatch = trimmed.match(/op\.create_table\s*\((.+)\)/s);
|
|
198
|
+
if (createTableMatch) {
|
|
199
|
+
const tableName = extractFirstStringArg(createTableMatch[1]);
|
|
200
|
+
operations.push({
|
|
201
|
+
type: 'create_table',
|
|
202
|
+
table: tableName || undefined,
|
|
203
|
+
});
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// op.drop_table(...)
|
|
208
|
+
const dropTableMatch = trimmed.match(/op\.drop_table\s*\((.+)\)/s);
|
|
209
|
+
if (dropTableMatch) {
|
|
210
|
+
const tableName = extractFirstStringArg(dropTableMatch[1]);
|
|
211
|
+
operations.push({
|
|
212
|
+
type: 'drop_table',
|
|
213
|
+
table: tableName || undefined,
|
|
214
|
+
});
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// op.add_column(...)
|
|
219
|
+
const addColMatch = trimmed.match(/op\.add_column\s*\((.+)\)/s);
|
|
220
|
+
if (addColMatch) {
|
|
221
|
+
const { table, column } = extractTableAndColumn(addColMatch[1]);
|
|
222
|
+
operations.push({
|
|
223
|
+
type: 'add_column',
|
|
224
|
+
table: table || undefined,
|
|
225
|
+
column: column || undefined,
|
|
226
|
+
});
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// op.drop_column(...)
|
|
231
|
+
const dropColMatch = trimmed.match(/op\.drop_column\s*\((.+)\)/s);
|
|
232
|
+
if (dropColMatch) {
|
|
233
|
+
const { table, column } = extractTableAndColumn(dropColMatch[1]);
|
|
234
|
+
operations.push({
|
|
235
|
+
type: 'drop_column',
|
|
236
|
+
table: table || undefined,
|
|
237
|
+
column: column || undefined,
|
|
238
|
+
});
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// op.alter_column(...)
|
|
243
|
+
const alterColMatch = trimmed.match(/op\.alter_column\s*\((.+)\)/s);
|
|
244
|
+
if (alterColMatch) {
|
|
245
|
+
const { table, column } = extractTableAndColumn(alterColMatch[1]);
|
|
246
|
+
operations.push({
|
|
247
|
+
type: 'alter_column',
|
|
248
|
+
table: table || undefined,
|
|
249
|
+
column: column || undefined,
|
|
250
|
+
details: trimmed,
|
|
251
|
+
});
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// op.create_index(...)
|
|
256
|
+
const createIdxMatch = trimmed.match(/op\.create_index\s*\((.+)\)/s);
|
|
257
|
+
if (createIdxMatch) {
|
|
258
|
+
const parts = splitTopLevelCommas(createIdxMatch[1]);
|
|
259
|
+
const indexName = parts[0] ? extractFirstStringArg(parts[0]) : null;
|
|
260
|
+
const tableName = parts[1] ? extractFirstStringArg(parts[1]) : null;
|
|
261
|
+
operations.push({
|
|
262
|
+
type: 'create_index',
|
|
263
|
+
table: tableName || undefined,
|
|
264
|
+
details: indexName ? `index: ${indexName}` : undefined,
|
|
265
|
+
});
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Any other op.xxx(...) call
|
|
270
|
+
const otherOpMatch = trimmed.match(/op\.(\w+)\s*\((.+)\)/s);
|
|
271
|
+
if (otherOpMatch) {
|
|
272
|
+
operations.push({
|
|
273
|
+
type: 'other',
|
|
274
|
+
details: `op.${otherOpMatch[1]}(...)`,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return operations;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Join multi-line op.xxx() calls into single lines.
|
|
284
|
+
*/
|
|
285
|
+
function joinOpCalls(body: string): string {
|
|
286
|
+
const lines = body.split('\n');
|
|
287
|
+
const result: string[] = [];
|
|
288
|
+
let current = '';
|
|
289
|
+
let openParens = 0;
|
|
290
|
+
|
|
291
|
+
for (const line of lines) {
|
|
292
|
+
const trimmed = line.trim();
|
|
293
|
+
if (current === '' && !trimmed.startsWith('op.')) {
|
|
294
|
+
result.push(line);
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
current += (current ? ' ' : '') + trimmed;
|
|
299
|
+
|
|
300
|
+
for (const ch of trimmed) {
|
|
301
|
+
if (ch === '(') openParens++;
|
|
302
|
+
else if (ch === ')') openParens = Math.max(0, openParens - 1);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (openParens === 0) {
|
|
306
|
+
result.push(current);
|
|
307
|
+
current = '';
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (current) result.push(current);
|
|
312
|
+
return result.join('\n');
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Parse an Alembic migration file and extract revision info and operations.
|
|
317
|
+
*/
|
|
318
|
+
export function parseAlembicMigration(source: string): ParsedMigration {
|
|
319
|
+
const revision = extractStringVar(source, 'revision') || '';
|
|
320
|
+
const downRevision = extractNullableStringVar(source, 'down_revision');
|
|
321
|
+
const description = extractDescription(source);
|
|
322
|
+
|
|
323
|
+
const upgradeBody = extractFunctionBody(source, 'upgrade');
|
|
324
|
+
const operations = parseOperations(upgradeBody);
|
|
325
|
+
|
|
326
|
+
return {
|
|
327
|
+
revision,
|
|
328
|
+
downRevision,
|
|
329
|
+
description,
|
|
330
|
+
operations,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Copyright (c) 2026 Massu. All rights reserved.
|
|
2
|
+
// Licensed under BSL 1.1 - see LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
import { readFileSync, readdirSync } from 'fs';
|
|
5
|
+
import { join, relative } from 'path';
|
|
6
|
+
import type Database from 'better-sqlite3';
|
|
7
|
+
import { parsePythonModels } from './model-parser.ts';
|
|
8
|
+
import { getProjectRoot } from '../config.ts';
|
|
9
|
+
|
|
10
|
+
function walkPyFiles(dir: string, excludeDirs: string[]): string[] {
|
|
11
|
+
const files: string[] = [];
|
|
12
|
+
try {
|
|
13
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
14
|
+
for (const entry of entries) {
|
|
15
|
+
if (entry.isDirectory()) {
|
|
16
|
+
if (excludeDirs.includes(entry.name)) continue;
|
|
17
|
+
files.push(...walkPyFiles(join(dir, entry.name), excludeDirs));
|
|
18
|
+
} else if (entry.name.endsWith('.py')) {
|
|
19
|
+
files.push(join(dir, entry.name));
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
} catch { /* dir not readable, skip */ }
|
|
23
|
+
return files;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function buildPythonModelIndex(dataDb: Database.Database, pythonRoot: string, excludeDirs: string[] = ['__pycache__', '.venv', 'venv', '.mypy_cache', '.pytest_cache']): number {
|
|
27
|
+
const projectRoot = getProjectRoot();
|
|
28
|
+
const absRoot = join(projectRoot, pythonRoot);
|
|
29
|
+
dataDb.exec('DELETE FROM massu_py_models');
|
|
30
|
+
dataDb.exec('DELETE FROM massu_py_fk_edges');
|
|
31
|
+
|
|
32
|
+
const insertModel = dataDb.prepare(
|
|
33
|
+
'INSERT INTO massu_py_models (class_name, table_name, file, line, columns, relationships, foreign_keys) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
|
34
|
+
);
|
|
35
|
+
const insertFk = dataDb.prepare(
|
|
36
|
+
'INSERT INTO massu_py_fk_edges (source_table, source_column, target_table, target_column) VALUES (?, ?, ?, ?)'
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const files = walkPyFiles(absRoot, excludeDirs);
|
|
40
|
+
let count = 0;
|
|
41
|
+
|
|
42
|
+
dataDb.transaction(() => {
|
|
43
|
+
for (const absFile of files) {
|
|
44
|
+
const relFile = relative(projectRoot, absFile);
|
|
45
|
+
let source: string;
|
|
46
|
+
try { source = readFileSync(absFile, 'utf-8'); } catch { continue; }
|
|
47
|
+
|
|
48
|
+
const models = parsePythonModels(source);
|
|
49
|
+
for (const model of models) {
|
|
50
|
+
insertModel.run(
|
|
51
|
+
model.className, model.tableName, relFile, model.line,
|
|
52
|
+
JSON.stringify(model.columns), JSON.stringify(model.relationships), JSON.stringify(model.foreignKeys)
|
|
53
|
+
);
|
|
54
|
+
count++;
|
|
55
|
+
|
|
56
|
+
// Build FK edges
|
|
57
|
+
if (model.tableName) {
|
|
58
|
+
for (const fk of model.foreignKeys) {
|
|
59
|
+
const [targetTable, targetColumn] = fk.target.split('.');
|
|
60
|
+
if (targetTable && targetColumn) {
|
|
61
|
+
insertFk.run(model.tableName, fk.column, targetTable, targetColumn);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
})();
|
|
68
|
+
|
|
69
|
+
return count;
|
|
70
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// Copyright (c) 2026 Massu. All rights reserved.
|
|
2
|
+
// Licensed under BSL 1.1 - see LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
export interface ModelColumn {
|
|
5
|
+
name: string;
|
|
6
|
+
type: string;
|
|
7
|
+
nullable: boolean;
|
|
8
|
+
primaryKey: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ModelRelationship {
|
|
12
|
+
name: string;
|
|
13
|
+
target: string;
|
|
14
|
+
back_populates: string | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ModelForeignKey {
|
|
18
|
+
column: string;
|
|
19
|
+
target: string; // "table.column"
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ParsedModel {
|
|
23
|
+
className: string;
|
|
24
|
+
tableName: string | null;
|
|
25
|
+
columns: ModelColumn[];
|
|
26
|
+
relationships: ModelRelationship[];
|
|
27
|
+
foreignKeys: ModelForeignKey[];
|
|
28
|
+
line: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Known SA base classes that indicate a model class. */
|
|
32
|
+
const BASE_CLASSES = new Set(['Base', 'DeclarativeBase', 'db.Model']);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Check if a class definition inherits from a known SQLAlchemy base.
|
|
36
|
+
*/
|
|
37
|
+
function isModelClass(bases: string): boolean {
|
|
38
|
+
const parts = bases.split(',').map((s) => s.trim());
|
|
39
|
+
return parts.some((b) => BASE_CLASSES.has(b));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Extract type name from SA 1.x Column() first argument.
|
|
44
|
+
* e.g., Column(Integer, ...) -> "Integer"
|
|
45
|
+
* e.g., Column(String(255), ...) -> "String(255)"
|
|
46
|
+
*/
|
|
47
|
+
function extractColumnType(argsStr: string): string {
|
|
48
|
+
const trimmed = argsStr.trim();
|
|
49
|
+
// Match the first argument which is the type
|
|
50
|
+
// Handle callable types like String(255), Numeric(10,2)
|
|
51
|
+
const m = trimmed.match(/^([A-Za-z_]\w*(?:\([^)]*\))?)/);
|
|
52
|
+
return m ? m[1] : 'Unknown';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Extract type from Mapped[type] annotation.
|
|
57
|
+
* e.g., Mapped[int] -> "int", Mapped[Optional[str]] -> "Optional[str]"
|
|
58
|
+
*/
|
|
59
|
+
function extractMappedType(annotation: string): string {
|
|
60
|
+
const m = annotation.match(/Mapped\[(.+)\]/);
|
|
61
|
+
return m ? m[1] : annotation;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Check if args contain nullable=False or nullable=True.
|
|
66
|
+
* Default depends on context: Column defaults to nullable=True.
|
|
67
|
+
*/
|
|
68
|
+
function extractNullable(argsStr: string): boolean {
|
|
69
|
+
const m = argsStr.match(/nullable\s*=\s*(True|False)/);
|
|
70
|
+
if (m) return m[1] === 'True';
|
|
71
|
+
// Default: nullable=True for Column, for Mapped it depends on Optional
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Check if args contain primary_key=True.
|
|
77
|
+
*/
|
|
78
|
+
function extractPrimaryKey(argsStr: string): boolean {
|
|
79
|
+
return /primary_key\s*=\s*True/.test(argsStr);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Extract ForeignKey target from Column args.
|
|
84
|
+
* e.g., Column(Integer, ForeignKey("users.id")) -> "users.id"
|
|
85
|
+
*/
|
|
86
|
+
function extractForeignKeys(columnName: string, argsStr: string): ModelForeignKey[] {
|
|
87
|
+
const keys: ModelForeignKey[] = [];
|
|
88
|
+
const re = /ForeignKey\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
89
|
+
let match;
|
|
90
|
+
while ((match = re.exec(argsStr)) !== null) {
|
|
91
|
+
keys.push({ column: columnName, target: match[1] });
|
|
92
|
+
}
|
|
93
|
+
return keys;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Extract relationship target and back_populates.
|
|
98
|
+
* e.g., relationship("User", back_populates="posts") -> { target: "User", back_populates: "posts" }
|
|
99
|
+
*/
|
|
100
|
+
function parseRelationship(argsStr: string): { target: string; back_populates: string | null } | null {
|
|
101
|
+
const targetMatch = argsStr.match(/['"](\w+)['"]/);
|
|
102
|
+
if (!targetMatch) return null;
|
|
103
|
+
|
|
104
|
+
const bpMatch = argsStr.match(/back_populates\s*=\s*['"](\w+)['"]/);
|
|
105
|
+
return {
|
|
106
|
+
target: targetMatch[1],
|
|
107
|
+
back_populates: bpMatch ? bpMatch[1] : null,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Find the matching closing paren/bracket, handling nesting.
|
|
113
|
+
*/
|
|
114
|
+
function findClosingParen(text: string, openIndex: number, openChar: string, closeChar: string): number {
|
|
115
|
+
let depth = 1;
|
|
116
|
+
for (let i = openIndex + 1; i < text.length; i++) {
|
|
117
|
+
if (text[i] === openChar) depth++;
|
|
118
|
+
else if (text[i] === closeChar) {
|
|
119
|
+
depth--;
|
|
120
|
+
if (depth === 0) return i;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return -1;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Get the full argument string inside balanced parentheses starting after a keyword.
|
|
128
|
+
* Returns the content between the outermost parens.
|
|
129
|
+
*/
|
|
130
|
+
function getBalancedArgs(line: string, keyword: string): string | null {
|
|
131
|
+
const idx = line.indexOf(keyword);
|
|
132
|
+
if (idx === -1) return null;
|
|
133
|
+
const parenStart = line.indexOf('(', idx + keyword.length);
|
|
134
|
+
if (parenStart === -1) return null;
|
|
135
|
+
const parenEnd = findClosingParen(line, parenStart, '(', ')');
|
|
136
|
+
if (parenEnd === -1) return null;
|
|
137
|
+
return line.substring(parenStart + 1, parenEnd);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Parse SQLAlchemy model definitions from Python source code.
|
|
142
|
+
*/
|
|
143
|
+
export function parsePythonModels(source: string): ParsedModel[] {
|
|
144
|
+
const models: ParsedModel[] = [];
|
|
145
|
+
const lines = source.split('\n');
|
|
146
|
+
|
|
147
|
+
let i = 0;
|
|
148
|
+
while (i < lines.length) {
|
|
149
|
+
const line = lines[i];
|
|
150
|
+
const classMatch = line.match(/^class\s+(\w+)\s*\(([^)]+)\)\s*:/);
|
|
151
|
+
|
|
152
|
+
if (!classMatch) {
|
|
153
|
+
i++;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const className = classMatch[1];
|
|
158
|
+
const bases = classMatch[2];
|
|
159
|
+
const classLine = i + 1; // 1-based
|
|
160
|
+
|
|
161
|
+
// Determine if this is a model class
|
|
162
|
+
let isModel = isModelClass(bases);
|
|
163
|
+
|
|
164
|
+
// We'll also check for __tablename__ inside the class body
|
|
165
|
+
const columns: ModelColumn[] = [];
|
|
166
|
+
const relationships: ModelRelationship[] = [];
|
|
167
|
+
const foreignKeys: ModelForeignKey[] = [];
|
|
168
|
+
let tableName: string | null = null;
|
|
169
|
+
|
|
170
|
+
i++;
|
|
171
|
+
|
|
172
|
+
// Parse class body (indented lines)
|
|
173
|
+
while (i < lines.length) {
|
|
174
|
+
const bodyLine = lines[i];
|
|
175
|
+
|
|
176
|
+
// End of class body: non-empty line at column 0 that isn't a blank line
|
|
177
|
+
if (bodyLine.length > 0 && !bodyLine.startsWith(' ') && !bodyLine.startsWith('\t') && bodyLine.trim() !== '') {
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const trimmed = bodyLine.trim();
|
|
182
|
+
|
|
183
|
+
// Parse __tablename__
|
|
184
|
+
const tnMatch = trimmed.match(/^__tablename__\s*=\s*['"](\w+)['"]/);
|
|
185
|
+
if (tnMatch) {
|
|
186
|
+
tableName = tnMatch[1];
|
|
187
|
+
isModel = true;
|
|
188
|
+
i++;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Join multi-line statements within class body
|
|
193
|
+
let fullLine = trimmed;
|
|
194
|
+
if (fullLine) {
|
|
195
|
+
let openP = 0;
|
|
196
|
+
for (const ch of fullLine) {
|
|
197
|
+
if (ch === '(') openP++;
|
|
198
|
+
else if (ch === ')') openP--;
|
|
199
|
+
}
|
|
200
|
+
while (openP > 0 && i + 1 < lines.length) {
|
|
201
|
+
i++;
|
|
202
|
+
const nextLine = lines[i].trim();
|
|
203
|
+
fullLine += ' ' + nextLine;
|
|
204
|
+
for (const ch of nextLine) {
|
|
205
|
+
if (ch === '(') openP++;
|
|
206
|
+
else if (ch === ')') openP--;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// SA 1.x: name = Column(...)
|
|
212
|
+
const col1Match = fullLine.match(/^(\w+)\s*=\s*Column\s*\(/);
|
|
213
|
+
if (col1Match) {
|
|
214
|
+
const colName = col1Match[1];
|
|
215
|
+
const argsStr = getBalancedArgs(fullLine, 'Column');
|
|
216
|
+
if (argsStr) {
|
|
217
|
+
const colType = extractColumnType(argsStr);
|
|
218
|
+
const nullable = extractNullable(argsStr);
|
|
219
|
+
const primaryKey = extractPrimaryKey(argsStr);
|
|
220
|
+
columns.push({ name: colName, type: colType, nullable, primaryKey });
|
|
221
|
+
foreignKeys.push(...extractForeignKeys(colName, argsStr));
|
|
222
|
+
}
|
|
223
|
+
i++;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// SA 2.0: name: Mapped[type] = mapped_column(...)
|
|
228
|
+
const col2Match = fullLine.match(/^(\w+)\s*:\s*(Mapped\[.+?\])\s*=\s*mapped_column\s*\(/);
|
|
229
|
+
if (col2Match) {
|
|
230
|
+
const colName = col2Match[1];
|
|
231
|
+
const mappedAnnotation = col2Match[2];
|
|
232
|
+
const argsStr = getBalancedArgs(fullLine, 'mapped_column');
|
|
233
|
+
const mappedType = extractMappedType(mappedAnnotation);
|
|
234
|
+
const nullable = mappedType.startsWith('Optional') || extractNullable(argsStr || '');
|
|
235
|
+
const primaryKey = extractPrimaryKey(argsStr || '');
|
|
236
|
+
columns.push({ name: colName, type: mappedType, nullable, primaryKey });
|
|
237
|
+
if (argsStr) {
|
|
238
|
+
foreignKeys.push(...extractForeignKeys(colName, argsStr));
|
|
239
|
+
}
|
|
240
|
+
i++;
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// relationship()
|
|
245
|
+
const relMatch = fullLine.match(/^(\w+)\s*(?::\s*[^=]+)?\s*=\s*relationship\s*\(/);
|
|
246
|
+
if (relMatch) {
|
|
247
|
+
const relName = relMatch[1];
|
|
248
|
+
const argsStr = getBalancedArgs(fullLine, 'relationship');
|
|
249
|
+
if (argsStr) {
|
|
250
|
+
const parsed = parseRelationship(argsStr);
|
|
251
|
+
if (parsed) {
|
|
252
|
+
relationships.push({
|
|
253
|
+
name: relName,
|
|
254
|
+
target: parsed.target,
|
|
255
|
+
back_populates: parsed.back_populates,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
i++;
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
i++;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (isModel) {
|
|
267
|
+
models.push({
|
|
268
|
+
className,
|
|
269
|
+
tableName,
|
|
270
|
+
columns,
|
|
271
|
+
relationships,
|
|
272
|
+
foreignKeys,
|
|
273
|
+
line: classLine,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return models;
|
|
279
|
+
}
|