@massu/core 1.10.6 → 1.10.8
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/dist/cli.js +405 -337
- package/dist/hooks/post-edit-context.js +6 -1
- package/dist/hooks/pre-delete-check.js +12 -7
- package/package.json +1 -1
- package/src/db.ts +63 -62
- package/src/domains.ts +2 -1
- package/src/hooks/pre-delete-check.ts +7 -5
- package/src/import-resolver.ts +3 -2
- package/src/knowledge-tools.ts +3 -2
- package/src/lib/framework-supports.ts +85 -0
- package/src/lib/sql-table-names.ts +71 -0
- package/src/middleware-tree.ts +6 -5
- package/src/page-deps.ts +9 -8
- package/src/python/coupling-detector.ts +4 -3
- package/src/python/domain-enforcer.ts +3 -2
- package/src/python/impact-analyzer.ts +7 -6
- package/src/python/import-resolver.ts +3 -2
- package/src/python/migration-indexer.ts +5 -4
- package/src/python/model-indexer.ts +5 -4
- package/src/python/route-indexer.ts +4 -3
- package/src/python-tools.ts +30 -29
- package/src/security/license-pubkey.generated.ts +1 -1
- package/src/security/registry-pubkey.generated.ts +1 -1
- package/src/sentinel-db.ts +47 -46
- package/src/sentinel-scanner.ts +3 -2
- package/src/sentinel-tools.ts +2 -1
- package/src/tools.ts +18 -16
- package/src/trpc-index.ts +5 -4
- package/src/validate-features-runner.ts +5 -4
package/src/python-tools.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type Database from 'better-sqlite3';
|
|
|
5
5
|
import type { ToolDefinition, ToolResult } from './tools.ts';
|
|
6
6
|
import { getConfig } from './config.ts';
|
|
7
7
|
import { classifyPythonFileDomain } from './python/domain-enforcer.ts';
|
|
8
|
+
import { t } from './lib/sql-table-names.ts';
|
|
8
9
|
|
|
9
10
|
/** Get the configured tool prefix */
|
|
10
11
|
function p(name: string): string {
|
|
@@ -195,7 +196,7 @@ function handlePyImports(args: Record<string, unknown>, dataDb: Database.Databas
|
|
|
195
196
|
if (depth > maxDepth || visited.has(f)) return;
|
|
196
197
|
visited.add(f);
|
|
197
198
|
const imports = dataDb.prepare(
|
|
198
|
-
|
|
199
|
+
`SELECT target_file, imported_names FROM ${t('py_imports')} WHERE source_file = ?`
|
|
199
200
|
).all(f) as { target_file: string; imported_names: string }[];
|
|
200
201
|
for (const imp of imports) {
|
|
201
202
|
const indent = ' '.repeat(depth);
|
|
@@ -207,7 +208,7 @@ function handlePyImports(args: Record<string, unknown>, dataDb: Database.Databas
|
|
|
207
208
|
traverse(file, 0);
|
|
208
209
|
} else {
|
|
209
210
|
const imports = dataDb.prepare(
|
|
210
|
-
|
|
211
|
+
`SELECT target_file, import_type, imported_names, line FROM ${t('py_imports')} WHERE source_file = ? ORDER BY line`
|
|
211
212
|
).all(file) as { target_file: string; import_type: string; imported_names: string; line: number }[];
|
|
212
213
|
lines.push(`## Imports from ${file} (${imports.length} edges)`);
|
|
213
214
|
for (const imp of imports) {
|
|
@@ -217,7 +218,7 @@ function handlePyImports(args: Record<string, unknown>, dataDb: Database.Databas
|
|
|
217
218
|
}
|
|
218
219
|
} else if (importedBy) {
|
|
219
220
|
const importers = dataDb.prepare(
|
|
220
|
-
|
|
221
|
+
`SELECT source_file, imported_names, line FROM ${t('py_imports')} WHERE target_file = ? ORDER BY source_file`
|
|
221
222
|
).all(importedBy) as { source_file: string; imported_names: string; line: number }[];
|
|
222
223
|
lines.push(`## Files importing ${importedBy} (${importers.length} edges)`);
|
|
223
224
|
for (const imp of importers) {
|
|
@@ -225,8 +226,8 @@ function handlePyImports(args: Record<string, unknown>, dataDb: Database.Databas
|
|
|
225
226
|
lines.push(`- ${imp.source_file}:${imp.line}${names.length > 0 ? ' (' + names.join(', ') + ')' : ''}`);
|
|
226
227
|
}
|
|
227
228
|
} else {
|
|
228
|
-
const total = (dataDb.prepare(
|
|
229
|
-
const files = (dataDb.prepare(
|
|
229
|
+
const total = (dataDb.prepare(`SELECT COUNT(*) as cnt FROM ${t('py_imports')}`).get() as { cnt: number }).cnt;
|
|
230
|
+
const files = (dataDb.prepare(`SELECT COUNT(DISTINCT source_file) as cnt FROM ${t('py_imports')}`).get() as { cnt: number }).cnt;
|
|
230
231
|
lines.push('## Python Import Index Summary');
|
|
231
232
|
lines.push(`- Total import edges: ${total}`);
|
|
232
233
|
lines.push(`- Files with imports: ${files}`);
|
|
@@ -241,7 +242,7 @@ function handlePyImports(args: Record<string, unknown>, dataDb: Database.Databas
|
|
|
241
242
|
function handlePyRoutes(args: Record<string, unknown>, dataDb: Database.Database): ToolResult {
|
|
242
243
|
const lines: string[] = [];
|
|
243
244
|
const limit = Math.min(Math.max((args.limit as number) || 500, 1), 5000);
|
|
244
|
-
let query =
|
|
245
|
+
let query = `SELECT * FROM ${t('py_routes')} WHERE 1=1`;
|
|
245
246
|
const params: unknown[] = [];
|
|
246
247
|
|
|
247
248
|
if (args.method) { query += ' AND method = ?'; params.push((args.method as string).toUpperCase()); }
|
|
@@ -258,7 +259,7 @@ function handlePyRoutes(args: Record<string, unknown>, dataDb: Database.Database
|
|
|
258
259
|
}[];
|
|
259
260
|
|
|
260
261
|
if (args.uncoupled) {
|
|
261
|
-
const callerCountStmt = dataDb.prepare(
|
|
262
|
+
const callerCountStmt = dataDb.prepare(`SELECT COUNT(*) as cnt FROM ${t('py_route_callers')} WHERE route_id = ?`);
|
|
262
263
|
const uncoupledRoutes = routes.filter(r => {
|
|
263
264
|
const callers = (callerCountStmt.get(r.id) as { cnt: number }).cnt;
|
|
264
265
|
return callers === 0;
|
|
@@ -286,8 +287,8 @@ function handlePyCoupling(args: Record<string, unknown>, dataDb: Database.Databa
|
|
|
286
287
|
let query = `
|
|
287
288
|
SELECT rc.frontend_file, rc.call_pattern, rc.line as caller_line,
|
|
288
289
|
r.method, r.path, r.function_name, r.file as backend_file
|
|
289
|
-
FROM
|
|
290
|
-
JOIN
|
|
290
|
+
FROM ${t('py_route_callers')} rc
|
|
291
|
+
JOIN ${t('py_routes')} r ON rc.route_id = r.id
|
|
291
292
|
WHERE 1=1
|
|
292
293
|
`;
|
|
293
294
|
const params: unknown[] = [];
|
|
@@ -319,7 +320,7 @@ function handlePyModels(args: Record<string, unknown>, dataDb: Database.Database
|
|
|
319
320
|
const lines: string[] = [];
|
|
320
321
|
|
|
321
322
|
if (args.fk_graph) {
|
|
322
|
-
const edges = dataDb.prepare(
|
|
323
|
+
const edges = dataDb.prepare(`SELECT * FROM ${t('py_fk_edges')} ORDER BY source_table`).all() as {
|
|
323
324
|
source_table: string; source_column: string; target_table: string; target_column: string;
|
|
324
325
|
}[];
|
|
325
326
|
lines.push(`## Foreign Key Graph (${edges.length} edges)`);
|
|
@@ -329,7 +330,7 @@ function handlePyModels(args: Record<string, unknown>, dataDb: Database.Database
|
|
|
329
330
|
} else if (args.model || args.table) {
|
|
330
331
|
const search = (args.model || args.table) as string;
|
|
331
332
|
const model = dataDb.prepare(
|
|
332
|
-
|
|
333
|
+
`SELECT * FROM ${t('py_models')} WHERE class_name = ? OR table_name = ?`
|
|
333
334
|
).get(search, search) as {
|
|
334
335
|
class_name: string; table_name: string; file: string; line: number;
|
|
335
336
|
columns: string; relationships: string; foreign_keys: string;
|
|
@@ -365,7 +366,7 @@ function handlePyModels(args: Record<string, unknown>, dataDb: Database.Database
|
|
|
365
366
|
}
|
|
366
367
|
} else {
|
|
367
368
|
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
|
|
369
|
+
const models = dataDb.prepare(`SELECT class_name, table_name, file, line FROM ${t('py_models')} ORDER BY class_name LIMIT ${limit}`).all() as {
|
|
369
370
|
class_name: string; table_name: string; file: string; line: number;
|
|
370
371
|
}[];
|
|
371
372
|
lines.push(`## Python Models (${models.length})`);
|
|
@@ -382,10 +383,10 @@ function handlePyMigrations(args: Record<string, unknown>, dataDb: Database.Data
|
|
|
382
383
|
|
|
383
384
|
if (args.drift) {
|
|
384
385
|
// Compare models against migration operations
|
|
385
|
-
const models = dataDb.prepare(
|
|
386
|
+
const models = dataDb.prepare(`SELECT class_name, table_name, columns FROM ${t('py_models')}`).all() as {
|
|
386
387
|
class_name: string; table_name: string; columns: string;
|
|
387
388
|
}[];
|
|
388
|
-
const migrations = dataDb.prepare(
|
|
389
|
+
const migrations = dataDb.prepare(`SELECT revision, operations FROM ${t('py_migrations')}`).all() as {
|
|
389
390
|
revision: string; operations: string;
|
|
390
391
|
}[];
|
|
391
392
|
|
|
@@ -409,7 +410,7 @@ function handlePyMigrations(args: Record<string, unknown>, dataDb: Database.Data
|
|
|
409
410
|
}
|
|
410
411
|
} else if (args.chain) {
|
|
411
412
|
const limit = Math.min(Math.max((args.limit as number) || 500, 1), 5000);
|
|
412
|
-
const migrations = dataDb.prepare(`SELECT * FROM
|
|
413
|
+
const migrations = dataDb.prepare(`SELECT * FROM ${t('py_migrations')} ORDER BY id LIMIT ${limit}`).all() as {
|
|
413
414
|
revision: string; down_revision: string | null; file: string; description: string | null; is_head: number;
|
|
414
415
|
}[];
|
|
415
416
|
lines.push(`## Migration Chain (${migrations.length} revisions)`);
|
|
@@ -418,7 +419,7 @@ function handlePyMigrations(args: Record<string, unknown>, dataDb: Database.Data
|
|
|
418
419
|
lines.push(`- ${m.revision}${head} ← ${m.down_revision || 'BASE'}: ${m.description || '(no description)'} (${m.file})`);
|
|
419
420
|
}
|
|
420
421
|
} else if (args.revision) {
|
|
421
|
-
const m = dataDb.prepare(
|
|
422
|
+
const m = dataDb.prepare(`SELECT * FROM ${t('py_migrations')} WHERE revision = ?`).get(args.revision) as {
|
|
422
423
|
revision: string; down_revision: string | null; file: string; description: string | null;
|
|
423
424
|
operations: string; is_head: number;
|
|
424
425
|
} | undefined;
|
|
@@ -435,8 +436,8 @@ function handlePyMigrations(args: Record<string, unknown>, dataDb: Database.Data
|
|
|
435
436
|
}
|
|
436
437
|
}
|
|
437
438
|
} else {
|
|
438
|
-
const total = (dataDb.prepare(
|
|
439
|
-
const head = dataDb.prepare(
|
|
439
|
+
const total = (dataDb.prepare(`SELECT COUNT(*) as cnt FROM ${t('py_migrations')}`).get() as { cnt: number }).cnt;
|
|
440
|
+
const head = dataDb.prepare(`SELECT revision FROM ${t('py_migrations')} WHERE is_head = 1`).get() as { revision: string } | undefined;
|
|
440
441
|
lines.push('## Migration Summary');
|
|
441
442
|
lines.push(`- Total revisions: ${total}`);
|
|
442
443
|
lines.push(`- Current head: ${head?.revision || 'N/A'}`);
|
|
@@ -460,7 +461,7 @@ function handlePyDomains(args: Record<string, unknown>, dataDb: Database.Databas
|
|
|
460
461
|
lines.push(`Allowed imports from: ${domainConfig.allowed_imports_from.join(', ') || 'any'}`);
|
|
461
462
|
}
|
|
462
463
|
} else if (args.crossings) {
|
|
463
|
-
const imports = dataDb.prepare(
|
|
464
|
+
const imports = dataDb.prepare(`SELECT source_file, target_file FROM ${t('py_imports')}`).all() as {
|
|
464
465
|
source_file: string; target_file: string;
|
|
465
466
|
}[];
|
|
466
467
|
const violations: string[] = [];
|
|
@@ -481,7 +482,7 @@ function handlePyDomains(args: Record<string, unknown>, dataDb: Database.Databas
|
|
|
481
482
|
if (violations.length > 50) lines.push(`... and ${violations.length - 50} more`);
|
|
482
483
|
} else if (args.domain) {
|
|
483
484
|
const domainName = args.domain as string;
|
|
484
|
-
const allFiles = dataDb.prepare(
|
|
485
|
+
const allFiles = dataDb.prepare(`SELECT DISTINCT source_file FROM ${t('py_imports')} UNION SELECT DISTINCT target_file FROM ${t('py_imports')}`).all() as { source_file: string }[];
|
|
485
486
|
const filesInDomain = allFiles.filter(f => classifyPythonFileDomain(f.source_file) === domainName);
|
|
486
487
|
lines.push(`## Domain: ${domainName} (${filesInDomain.length} files)`);
|
|
487
488
|
for (const f of filesInDomain) {
|
|
@@ -503,7 +504,7 @@ function handlePyImpact(args: Record<string, unknown>, dataDb: Database.Database
|
|
|
503
504
|
|
|
504
505
|
// 1. Who imports this file
|
|
505
506
|
const importedBy = dataDb.prepare(
|
|
506
|
-
|
|
507
|
+
`SELECT source_file FROM ${t('py_imports')} WHERE target_file = ?`
|
|
507
508
|
).all(file) as { source_file: string }[];
|
|
508
509
|
lines.push(`### Imported By (${importedBy.length} files)`);
|
|
509
510
|
for (const imp of importedBy.slice(0, 20)) {
|
|
@@ -512,7 +513,7 @@ function handlePyImpact(args: Record<string, unknown>, dataDb: Database.Database
|
|
|
512
513
|
|
|
513
514
|
// 2. Routes in this file
|
|
514
515
|
const routes = dataDb.prepare(
|
|
515
|
-
|
|
516
|
+
`SELECT method, path, function_name FROM ${t('py_routes')} WHERE file = ?`
|
|
516
517
|
).all(file) as { method: string; path: string; function_name: string }[];
|
|
517
518
|
if (routes.length > 0) {
|
|
518
519
|
lines.push(`\n### Routes Defined (${routes.length})`);
|
|
@@ -523,7 +524,7 @@ function handlePyImpact(args: Record<string, unknown>, dataDb: Database.Database
|
|
|
523
524
|
|
|
524
525
|
// 3. Models in this file
|
|
525
526
|
const models = dataDb.prepare(
|
|
526
|
-
|
|
527
|
+
`SELECT class_name, table_name FROM ${t('py_models')} WHERE file = ?`
|
|
527
528
|
).all(file) as { class_name: string; table_name: string }[];
|
|
528
529
|
if (models.length > 0) {
|
|
529
530
|
lines.push(`\n### Models Defined (${models.length})`);
|
|
@@ -533,11 +534,11 @@ function handlePyImpact(args: Record<string, unknown>, dataDb: Database.Database
|
|
|
533
534
|
}
|
|
534
535
|
|
|
535
536
|
// 4. Frontend callers (via routes)
|
|
536
|
-
const routeIds = dataDb.prepare(
|
|
537
|
+
const routeIds = dataDb.prepare(`SELECT id FROM ${t('py_routes')} WHERE file = ?`).all(file) as { id: number }[];
|
|
537
538
|
if (routeIds.length > 0) {
|
|
538
539
|
const placeholders = routeIds.map(() => '?').join(',');
|
|
539
540
|
const callers = dataDb.prepare(
|
|
540
|
-
`SELECT DISTINCT frontend_file FROM
|
|
541
|
+
`SELECT DISTINCT frontend_file FROM ${t('py_route_callers')} WHERE route_id IN (${placeholders})`
|
|
541
542
|
).all(...routeIds.map(r => r.id)) as { frontend_file: string }[];
|
|
542
543
|
if (callers.length > 0) {
|
|
543
544
|
lines.push(`\n### Frontend Callers (${callers.length} files)`);
|
|
@@ -566,7 +567,7 @@ function handlePyContext(args: Record<string, unknown>, dataDb: Database.Databas
|
|
|
566
567
|
|
|
567
568
|
// Imports
|
|
568
569
|
const imports = dataDb.prepare(
|
|
569
|
-
|
|
570
|
+
`SELECT target_file, imported_names FROM ${t('py_imports')} WHERE source_file = ? LIMIT 20`
|
|
570
571
|
).all(file) as { target_file: string; imported_names: string }[];
|
|
571
572
|
if (imports.length > 0) {
|
|
572
573
|
lines.push('\n### Imports');
|
|
@@ -578,7 +579,7 @@ function handlePyContext(args: Record<string, unknown>, dataDb: Database.Databas
|
|
|
578
579
|
|
|
579
580
|
// Imported by
|
|
580
581
|
const importedBy = dataDb.prepare(
|
|
581
|
-
|
|
582
|
+
`SELECT source_file FROM ${t('py_imports')} WHERE target_file = ? LIMIT 10`
|
|
582
583
|
).all(file) as { source_file: string }[];
|
|
583
584
|
if (importedBy.length > 0) {
|
|
584
585
|
lines.push('\n### Imported By');
|
|
@@ -588,7 +589,7 @@ function handlePyContext(args: Record<string, unknown>, dataDb: Database.Databas
|
|
|
588
589
|
}
|
|
589
590
|
|
|
590
591
|
// Routes
|
|
591
|
-
const routes = dataDb.prepare(
|
|
592
|
+
const routes = dataDb.prepare(`SELECT method, path, function_name, line FROM ${t('py_routes')} WHERE file = ?`)
|
|
592
593
|
.all(file) as { method: string; path: string; function_name: string; line: number }[];
|
|
593
594
|
if (routes.length > 0) {
|
|
594
595
|
lines.push('\n### Routes');
|
|
@@ -598,7 +599,7 @@ function handlePyContext(args: Record<string, unknown>, dataDb: Database.Databas
|
|
|
598
599
|
}
|
|
599
600
|
|
|
600
601
|
// Models
|
|
601
|
-
const models = dataDb.prepare(
|
|
602
|
+
const models = dataDb.prepare(`SELECT class_name, table_name, line FROM ${t('py_models')} WHERE file = ?`)
|
|
602
603
|
.all(file) as { class_name: string; table_name: string; line: number }[];
|
|
603
604
|
if (models.length > 0) {
|
|
604
605
|
lines.push('\n### Models');
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// AUTO-GENERATED by scripts/bundle-license-pubkey.mjs at 2026-05-
|
|
1
|
+
// AUTO-GENERATED by scripts/bundle-license-pubkey.mjs at 2026-05-17T16:00:08.526Z.
|
|
2
2
|
// Source pem: packages/core/security/license-pubkey.pem
|
|
3
3
|
// RAW-bytes sha256: 18a63d64fdec9e5a368fc45feaa49bed6ced815967e582bc7b8af534f22a9475
|
|
4
4
|
// DO NOT EDIT — regenerate via `node scripts/bundle-license-pubkey.mjs` or
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// AUTO-GENERATED by scripts/bundle-pubkey.mjs at 2026-05-
|
|
1
|
+
// AUTO-GENERATED by scripts/bundle-pubkey.mjs at 2026-05-17T16:00:08.460Z.
|
|
2
2
|
// Source pem: packages/core/security/registry-pubkey.pem
|
|
3
3
|
// RAW-bytes sha256: 3b6226d036c472e533110d11a7d0cd2773ce1d7d4f1003517d5bd69c5418ed4c
|
|
4
4
|
// DO NOT EDIT — regenerate via `node scripts/bundle-pubkey.mjs` or
|
package/src/sentinel-db.ts
CHANGED
|
@@ -24,6 +24,7 @@ import type {
|
|
|
24
24
|
} from './sentinel-types.ts';
|
|
25
25
|
import { getProjectRoot } from './config.ts';
|
|
26
26
|
import { sanitizeFts5Query } from './memory-db.ts';
|
|
27
|
+
import { t } from './lib/sql-table-names.ts';
|
|
27
28
|
|
|
28
29
|
// ============================================================
|
|
29
30
|
// Sentinel: Feature Registry Data Access Layer
|
|
@@ -63,11 +64,11 @@ function toFeature(row: Record<string, unknown>): Feature {
|
|
|
63
64
|
// ============================================================
|
|
64
65
|
|
|
65
66
|
export function upsertFeature(db: Database.Database, input: FeatureInput): number {
|
|
66
|
-
const existing = db.prepare(
|
|
67
|
+
const existing = db.prepare(`SELECT id FROM ${t('sentinel')} WHERE feature_key = ?`).get(input.feature_key) as { id: number } | undefined;
|
|
67
68
|
|
|
68
69
|
if (existing) {
|
|
69
70
|
db.prepare(`
|
|
70
|
-
UPDATE
|
|
71
|
+
UPDATE ${t('sentinel')} SET
|
|
71
72
|
domain = ?, subdomain = ?, title = ?, description = ?,
|
|
72
73
|
status = COALESCE(?, status), priority = COALESCE(?, priority),
|
|
73
74
|
portal_scope = COALESCE(?, portal_scope),
|
|
@@ -87,7 +88,7 @@ export function upsertFeature(db: Database.Database, input: FeatureInput): numbe
|
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
const result = db.prepare(`
|
|
90
|
-
INSERT INTO
|
|
91
|
+
INSERT INTO ${t('sentinel')} (feature_key, domain, subdomain, title, description, status, priority, portal_scope)
|
|
91
92
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
92
93
|
`).run(
|
|
93
94
|
input.feature_key,
|
|
@@ -104,12 +105,12 @@ export function upsertFeature(db: Database.Database, input: FeatureInput): numbe
|
|
|
104
105
|
}
|
|
105
106
|
|
|
106
107
|
export function getFeature(db: Database.Database, featureKey: string): Feature | null {
|
|
107
|
-
const row = db.prepare(
|
|
108
|
+
const row = db.prepare(`SELECT * FROM ${t('sentinel')} WHERE feature_key = ?`).get(featureKey) as Record<string, unknown> | undefined;
|
|
108
109
|
return row ? toFeature(row) : null;
|
|
109
110
|
}
|
|
110
111
|
|
|
111
112
|
export function getFeatureById(db: Database.Database, id: number): Feature | null {
|
|
112
|
-
const row = db.prepare(
|
|
113
|
+
const row = db.prepare(`SELECT * FROM ${t('sentinel')} WHERE id = ?`).get(id) as Record<string, unknown> | undefined;
|
|
113
114
|
return row ? toFeature(row) : null;
|
|
114
115
|
}
|
|
115
116
|
|
|
@@ -129,21 +130,21 @@ export function searchFeatures(
|
|
|
129
130
|
// FTS5 search
|
|
130
131
|
sql = `
|
|
131
132
|
SELECT s.*, fts.rank,
|
|
132
|
-
(SELECT COUNT(*) FROM
|
|
133
|
-
(SELECT COUNT(*) FROM
|
|
134
|
-
(SELECT COUNT(*) FROM
|
|
135
|
-
FROM
|
|
136
|
-
JOIN
|
|
137
|
-
WHERE
|
|
133
|
+
(SELECT COUNT(*) FROM ${t('sentinel_components')} WHERE feature_id = s.id) as component_count,
|
|
134
|
+
(SELECT COUNT(*) FROM ${t('sentinel_procedures')} WHERE feature_id = s.id) as procedure_count,
|
|
135
|
+
(SELECT COUNT(*) FROM ${t('sentinel_pages')} WHERE feature_id = s.id) as page_count
|
|
136
|
+
FROM ${t('sentinel')} s
|
|
137
|
+
JOIN ${t('sentinel_fts')} fts ON s.id = fts.rowid
|
|
138
|
+
WHERE ${t('sentinel_fts')} MATCH ?
|
|
138
139
|
`;
|
|
139
140
|
params.push(sanitizeFts5Query(query));
|
|
140
141
|
} else {
|
|
141
142
|
sql = `
|
|
142
143
|
SELECT s.*,
|
|
143
|
-
(SELECT COUNT(*) FROM
|
|
144
|
-
(SELECT COUNT(*) FROM
|
|
145
|
-
(SELECT COUNT(*) FROM
|
|
146
|
-
FROM
|
|
144
|
+
(SELECT COUNT(*) FROM ${t('sentinel_components')} WHERE feature_id = s.id) as component_count,
|
|
145
|
+
(SELECT COUNT(*) FROM ${t('sentinel_procedures')} WHERE feature_id = s.id) as procedure_count,
|
|
146
|
+
(SELECT COUNT(*) FROM ${t('sentinel_pages')} WHERE feature_id = s.id) as page_count
|
|
147
|
+
FROM ${t('sentinel')} s
|
|
147
148
|
WHERE 1=1
|
|
148
149
|
`;
|
|
149
150
|
}
|
|
@@ -166,7 +167,7 @@ export function searchFeatures(
|
|
|
166
167
|
params.push(`%"${escapedPortal}"%`);
|
|
167
168
|
}
|
|
168
169
|
if (filters?.page_route) {
|
|
169
|
-
sql +=
|
|
170
|
+
sql += ` AND s.id IN (SELECT feature_id FROM ${t('sentinel_pages')} WHERE page_route = ?)`;
|
|
170
171
|
params.push(filters.page_route);
|
|
171
172
|
}
|
|
172
173
|
|
|
@@ -182,14 +183,14 @@ export function searchFeatures(
|
|
|
182
183
|
}
|
|
183
184
|
|
|
184
185
|
export function getFeaturesByDomain(db: Database.Database, domain: string): Feature[] {
|
|
185
|
-
const rows = db.prepare(
|
|
186
|
+
const rows = db.prepare(`SELECT * FROM ${t('sentinel')} WHERE domain = ? ORDER BY subdomain, feature_key`).all(domain) as Record<string, unknown>[];
|
|
186
187
|
return rows.map(toFeature);
|
|
187
188
|
}
|
|
188
189
|
|
|
189
190
|
export function getFeaturesByFile(db: Database.Database, filePath: string): Feature[] {
|
|
190
191
|
const rows = db.prepare(`
|
|
191
|
-
SELECT DISTINCT s.* FROM
|
|
192
|
-
JOIN
|
|
192
|
+
SELECT DISTINCT s.* FROM ${t('sentinel')} s
|
|
193
|
+
JOIN ${t('sentinel_components')} c ON c.feature_id = s.id
|
|
193
194
|
WHERE c.component_file = ?
|
|
194
195
|
ORDER BY s.feature_key
|
|
195
196
|
`).all(filePath) as Record<string, unknown>[];
|
|
@@ -198,8 +199,8 @@ export function getFeaturesByFile(db: Database.Database, filePath: string): Feat
|
|
|
198
199
|
|
|
199
200
|
export function getFeaturesByRoute(db: Database.Database, route: string): Feature[] {
|
|
200
201
|
const rows = db.prepare(`
|
|
201
|
-
SELECT DISTINCT s.* FROM
|
|
202
|
-
JOIN
|
|
202
|
+
SELECT DISTINCT s.* FROM ${t('sentinel')} s
|
|
203
|
+
JOIN ${t('sentinel_pages')} p ON p.feature_id = s.id
|
|
203
204
|
WHERE p.page_route = ?
|
|
204
205
|
ORDER BY s.feature_key
|
|
205
206
|
`).all(route) as Record<string, unknown>[];
|
|
@@ -219,11 +220,11 @@ export function getFeatureDetail(db: Database.Database, featureKeyOrId: string |
|
|
|
219
220
|
}
|
|
220
221
|
if (!feature) return null;
|
|
221
222
|
|
|
222
|
-
const components = db.prepare(
|
|
223
|
-
const procedures = db.prepare(
|
|
224
|
-
const pages = db.prepare(
|
|
225
|
-
const dependencies = db.prepare(
|
|
226
|
-
const changelog = db.prepare(
|
|
223
|
+
const components = db.prepare(`SELECT * FROM ${t('sentinel_components')} WHERE feature_id = ?`).all(feature.id) as FeatureComponent[];
|
|
224
|
+
const procedures = db.prepare(`SELECT * FROM ${t('sentinel_procedures')} WHERE feature_id = ?`).all(feature.id) as FeatureProcedure[];
|
|
225
|
+
const pages = db.prepare(`SELECT * FROM ${t('sentinel_pages')} WHERE feature_id = ?`).all(feature.id) as FeaturePage[];
|
|
226
|
+
const dependencies = db.prepare(`SELECT * FROM ${t('sentinel_deps')} WHERE feature_id = ?`).all(feature.id) as FeatureDep[];
|
|
227
|
+
const changelog = db.prepare(`SELECT * FROM ${t('sentinel_changelog')} WHERE feature_id = ? ORDER BY created_at DESC LIMIT 50`).all(feature.id) as FeatureChangeLog[];
|
|
227
228
|
|
|
228
229
|
return { ...feature, components, procedures, pages, dependencies, changelog };
|
|
229
230
|
}
|
|
@@ -235,10 +236,10 @@ export function getFeatureDetail(db: Database.Database, featureKeyOrId: string |
|
|
|
235
236
|
export function getOrphanedFeatures(db: Database.Database): Feature[] {
|
|
236
237
|
// Active features with no living primary component files
|
|
237
238
|
const features = db.prepare(`
|
|
238
|
-
SELECT s.* FROM
|
|
239
|
+
SELECT s.* FROM ${t('sentinel')} s
|
|
239
240
|
WHERE s.status = 'active'
|
|
240
241
|
AND NOT EXISTS (
|
|
241
|
-
SELECT 1 FROM
|
|
242
|
+
SELECT 1 FROM ${t('sentinel_components')} c
|
|
242
243
|
WHERE c.feature_id = s.id AND c.is_primary = 1
|
|
243
244
|
)
|
|
244
245
|
ORDER BY s.domain, s.feature_key
|
|
@@ -253,7 +254,7 @@ export function getFeatureImpact(db: Database.Database, filePaths: string[]): Im
|
|
|
253
254
|
// Find all features linked to these files
|
|
254
255
|
for (const filePath of filePaths) {
|
|
255
256
|
const links = db.prepare(
|
|
256
|
-
|
|
257
|
+
`SELECT feature_id FROM ${t('sentinel_components')} WHERE component_file = ?`
|
|
257
258
|
).all(filePath) as { feature_id: number }[];
|
|
258
259
|
for (const link of links) {
|
|
259
260
|
affectedFeatureIds.add(link.feature_id);
|
|
@@ -269,7 +270,7 @@ export function getFeatureImpact(db: Database.Database, filePaths: string[]): Im
|
|
|
269
270
|
if (!feature || feature.status !== 'active') continue;
|
|
270
271
|
|
|
271
272
|
const allComponents = db.prepare(
|
|
272
|
-
|
|
273
|
+
`SELECT component_file, is_primary FROM ${t('sentinel_components')} WHERE feature_id = ?`
|
|
273
274
|
).all(featureId) as { component_file: string; is_primary: number }[];
|
|
274
275
|
|
|
275
276
|
const affected = allComponents.filter(c => fileSet.has(c.component_file));
|
|
@@ -324,7 +325,7 @@ export function linkComponent(
|
|
|
324
325
|
isPrimary: boolean = false
|
|
325
326
|
): void {
|
|
326
327
|
db.prepare(`
|
|
327
|
-
INSERT OR REPLACE INTO
|
|
328
|
+
INSERT OR REPLACE INTO ${t('sentinel_components')} (feature_id, component_file, component_name, role, is_primary)
|
|
328
329
|
VALUES (?, ?, ?, ?, ?)
|
|
329
330
|
`).run(featureId, filePath, componentName, role, isPrimary ? 1 : 0);
|
|
330
331
|
}
|
|
@@ -337,7 +338,7 @@ export function linkProcedure(
|
|
|
337
338
|
procedureType?: string
|
|
338
339
|
): void {
|
|
339
340
|
db.prepare(`
|
|
340
|
-
INSERT OR REPLACE INTO
|
|
341
|
+
INSERT OR REPLACE INTO ${t('sentinel_procedures')} (feature_id, router_name, procedure_name, procedure_type)
|
|
341
342
|
VALUES (?, ?, ?, ?)
|
|
342
343
|
`).run(featureId, routerName, procedureName, procedureType || null);
|
|
343
344
|
}
|
|
@@ -349,7 +350,7 @@ export function linkPage(
|
|
|
349
350
|
portal?: string
|
|
350
351
|
): void {
|
|
351
352
|
db.prepare(`
|
|
352
|
-
INSERT OR REPLACE INTO
|
|
353
|
+
INSERT OR REPLACE INTO ${t('sentinel_pages')} (feature_id, page_route, portal)
|
|
353
354
|
VALUES (?, ?, ?)
|
|
354
355
|
`).run(featureId, route, portal || null);
|
|
355
356
|
}
|
|
@@ -367,7 +368,7 @@ export function logChange(
|
|
|
367
368
|
changedBy: string = 'claude-code'
|
|
368
369
|
): void {
|
|
369
370
|
db.prepare(`
|
|
370
|
-
INSERT INTO
|
|
371
|
+
INSERT INTO ${t('sentinel_changelog')} (feature_id, change_type, changed_by, change_detail, commit_hash)
|
|
371
372
|
VALUES (?, ?, ?, ?, ?)
|
|
372
373
|
`).run(featureId, changeType, changedBy, detail, commitHash || null);
|
|
373
374
|
}
|
|
@@ -377,7 +378,7 @@ export function logChange(
|
|
|
377
378
|
// ============================================================
|
|
378
379
|
|
|
379
380
|
export function validateFeatures(db: Database.Database, domainFilter?: string): ValidationReport {
|
|
380
|
-
let sql = `SELECT * FROM
|
|
381
|
+
let sql = `SELECT * FROM ${t('sentinel')} WHERE status = 'active'`;
|
|
381
382
|
const params: unknown[] = [];
|
|
382
383
|
if (domainFilter) {
|
|
383
384
|
sql += ' AND domain = ?';
|
|
@@ -393,9 +394,9 @@ export function validateFeatures(db: Database.Database, domainFilter?: string):
|
|
|
393
394
|
|
|
394
395
|
for (const row of features) {
|
|
395
396
|
const feature = toFeature(row);
|
|
396
|
-
const components = db.prepare(
|
|
397
|
-
const procedures = db.prepare(
|
|
398
|
-
const pages = db.prepare(
|
|
397
|
+
const components = db.prepare(`SELECT * FROM ${t('sentinel_components')} WHERE feature_id = ?`).all(feature.id) as FeatureComponent[];
|
|
398
|
+
const procedures = db.prepare(`SELECT * FROM ${t('sentinel_procedures')} WHERE feature_id = ?`).all(feature.id) as FeatureProcedure[];
|
|
399
|
+
const pages = db.prepare(`SELECT * FROM ${t('sentinel_pages')} WHERE feature_id = ?`).all(feature.id) as FeaturePage[];
|
|
399
400
|
|
|
400
401
|
const missingComponents: string[] = [];
|
|
401
402
|
const missingProcedures: { router: string; procedure: string }[] = [];
|
|
@@ -475,7 +476,7 @@ export function checkParity(db: Database.Database, oldFiles: string[], newFiles:
|
|
|
475
476
|
// Find features linked to old files
|
|
476
477
|
const oldFeatureIds = new Set<number>();
|
|
477
478
|
for (const file of oldFiles) {
|
|
478
|
-
const links = db.prepare(
|
|
479
|
+
const links = db.prepare(`SELECT feature_id FROM ${t('sentinel_components')} WHERE component_file = ?`).all(file) as { feature_id: number }[];
|
|
479
480
|
for (const link of links) {
|
|
480
481
|
oldFeatureIds.add(link.feature_id);
|
|
481
482
|
}
|
|
@@ -484,7 +485,7 @@ export function checkParity(db: Database.Database, oldFiles: string[], newFiles:
|
|
|
484
485
|
// Find features linked to new files
|
|
485
486
|
const newFeatureIds = new Set<number>();
|
|
486
487
|
for (const file of newFiles) {
|
|
487
|
-
const links = db.prepare(
|
|
488
|
+
const links = db.prepare(`SELECT feature_id FROM ${t('sentinel_components')} WHERE component_file = ?`).all(file) as { feature_id: number }[];
|
|
488
489
|
for (const link of links) {
|
|
489
490
|
newFeatureIds.add(link.feature_id);
|
|
490
491
|
}
|
|
@@ -500,8 +501,8 @@ export function checkParity(db: Database.Database, oldFiles: string[], newFiles:
|
|
|
500
501
|
const feature = getFeatureById(db, fId);
|
|
501
502
|
if (!feature) continue;
|
|
502
503
|
|
|
503
|
-
const oldComps = db.prepare(
|
|
504
|
-
const newComps = db.prepare(
|
|
504
|
+
const oldComps = db.prepare(`SELECT component_file FROM ${t('sentinel_components')} WHERE feature_id = ? AND component_file IN (` + oldFiles.map(() => '?').join(',') + ')').all(fId, ...oldFiles) as { component_file: string }[];
|
|
505
|
+
const newComps = db.prepare(`SELECT component_file FROM ${t('sentinel_components')} WHERE feature_id = ? AND component_file IN (` + newFiles.map(() => '?').join(',') + ')').all(fId, ...newFiles) as { component_file: string }[];
|
|
505
506
|
|
|
506
507
|
const item: ParityItem = {
|
|
507
508
|
feature_key: feature.feature_key,
|
|
@@ -524,7 +525,7 @@ export function checkParity(db: Database.Database, oldFiles: string[], newFiles:
|
|
|
524
525
|
const feature = getFeatureById(db, fId);
|
|
525
526
|
if (!feature) continue;
|
|
526
527
|
|
|
527
|
-
const newComps = db.prepare(
|
|
528
|
+
const newComps = db.prepare(`SELECT component_file FROM ${t('sentinel_components')} WHERE feature_id = ? AND component_file IN (` + newFiles.map(() => '?').join(',') + ')').all(fId, ...newFiles) as { component_file: string }[];
|
|
528
529
|
|
|
529
530
|
newFeatures.push({
|
|
530
531
|
feature_key: feature.feature_key,
|
|
@@ -549,19 +550,19 @@ export function clearAutoDiscoveredFeatures(db: Database.Database): void {
|
|
|
549
550
|
// Only clear features that were auto-discovered (not manually registered)
|
|
550
551
|
// We use the changelog to distinguish: auto-discovered ones have changed_by = 'scanner'
|
|
551
552
|
const autoIds = db.prepare(`
|
|
552
|
-
SELECT DISTINCT feature_id FROM
|
|
553
|
+
SELECT DISTINCT feature_id FROM ${t('sentinel_changelog')}
|
|
553
554
|
WHERE changed_by = 'scanner' AND change_type = 'created'
|
|
554
555
|
`).all() as { feature_id: number }[];
|
|
555
556
|
|
|
556
557
|
// Don't delete features that also have manual changes
|
|
557
558
|
for (const { feature_id } of autoIds) {
|
|
558
559
|
const hasManualChanges = db.prepare(`
|
|
559
|
-
SELECT 1 FROM
|
|
560
|
+
SELECT 1 FROM ${t('sentinel_changelog')}
|
|
560
561
|
WHERE feature_id = ? AND changed_by != 'scanner'
|
|
561
562
|
`).get(feature_id);
|
|
562
563
|
|
|
563
564
|
if (!hasManualChanges) {
|
|
564
|
-
db.prepare(
|
|
565
|
+
db.prepare(`DELETE FROM ${t('sentinel')} WHERE id = ?`).run(feature_id);
|
|
565
566
|
}
|
|
566
567
|
}
|
|
567
568
|
}
|
package/src/sentinel-scanner.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from './sentinel-db.ts';
|
|
14
14
|
import type { FeatureInput, FeaturePriority } from './sentinel-types.ts';
|
|
15
15
|
import { getConfig, getProjectRoot } from './config.ts';
|
|
16
|
+
import { t } from './lib/sql-table-names.ts';
|
|
16
17
|
|
|
17
18
|
// ============================================================
|
|
18
19
|
// Sentinel: Feature Auto-Discovery Scanner
|
|
@@ -114,7 +115,7 @@ function scanTrpcProcedures(dataDb: Database.Database): DiscoveredFeature[] {
|
|
|
114
115
|
|
|
115
116
|
const procedures = dataDb.prepare(`
|
|
116
117
|
SELECT router_name, procedure_name, procedure_type, router_file
|
|
117
|
-
FROM
|
|
118
|
+
FROM ${t('trpc_procedures')}
|
|
118
119
|
ORDER BY router_name, procedure_name
|
|
119
120
|
`).all() as { router_name: string; procedure_name: string; procedure_type: string; router_file: string }[];
|
|
120
121
|
|
|
@@ -171,7 +172,7 @@ function scanPageRoutes(dataDb: Database.Database): DiscoveredFeature[] {
|
|
|
171
172
|
|
|
172
173
|
const pages = dataDb.prepare(`
|
|
173
174
|
SELECT page_file, route, portal, components, hooks, routers
|
|
174
|
-
FROM
|
|
175
|
+
FROM ${t('page_deps')}
|
|
175
176
|
ORDER BY route
|
|
176
177
|
`).all() as { page_file: string; route: string; portal: string; components: string; hooks: string; routers: string }[];
|
|
177
178
|
|
package/src/sentinel-tools.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
} from './sentinel-db.ts';
|
|
18
18
|
import type { ComponentRole, FeatureStatus, FeaturePriority } from './sentinel-types.ts';
|
|
19
19
|
import { getConfig } from './config.ts';
|
|
20
|
+
import { t } from './lib/sql-table-names.ts';
|
|
20
21
|
|
|
21
22
|
/** Prefix a base tool name with the configured tool prefix. */
|
|
22
23
|
function p(baseName: string): string {
|
|
@@ -400,7 +401,7 @@ function handleValidate(args: Record<string, unknown>, db: Database.Database): T
|
|
|
400
401
|
if (fix) {
|
|
401
402
|
lines.push('### Auto-Fix Applied');
|
|
402
403
|
for (const item of issues.filter(i => i.status === 'orphaned')) {
|
|
403
|
-
db.prepare(
|
|
404
|
+
db.prepare(`UPDATE ${t('sentinel')} SET status = 'deprecated', updated_at = datetime('now') WHERE id = ?`).run(item.feature.id);
|
|
404
405
|
logChange(db, item.feature.id, 'deprecated', 'Auto-deprecated: all primary components missing');
|
|
405
406
|
lines.push(`- Deprecated: ${item.feature.feature_key}`);
|
|
406
407
|
}
|