@massu/core 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/LICENSE +71 -0
  2. package/dist/hooks/cost-tracker.js +127 -11493
  3. package/dist/hooks/post-edit-context.js +125 -11491
  4. package/dist/hooks/post-tool-use.js +127 -11493
  5. package/dist/hooks/pre-compact.js +127 -11493
  6. package/dist/hooks/pre-delete-check.js +126 -11492
  7. package/dist/hooks/quality-event.js +127 -11493
  8. package/dist/hooks/session-end.js +127 -11493
  9. package/dist/hooks/session-start.js +127 -11493
  10. package/dist/hooks/user-prompt.js +127 -11493
  11. package/package.json +9 -8
  12. package/src/__tests__/adr-generator.test.ts +260 -0
  13. package/src/__tests__/analytics.test.ts +282 -0
  14. package/src/__tests__/audit-trail.test.ts +382 -0
  15. package/src/__tests__/backfill-sessions.test.ts +690 -0
  16. package/src/__tests__/cli.test.ts +290 -0
  17. package/src/__tests__/cloud-sync.test.ts +261 -0
  18. package/src/__tests__/config-sections.test.ts +359 -0
  19. package/src/__tests__/config.test.ts +732 -0
  20. package/src/__tests__/cost-tracker.test.ts +348 -0
  21. package/src/__tests__/db.test.ts +177 -0
  22. package/src/__tests__/dependency-scorer.test.ts +325 -0
  23. package/src/__tests__/docs-integration.test.ts +178 -0
  24. package/src/__tests__/docs-tools.test.ts +199 -0
  25. package/src/__tests__/domains.test.ts +236 -0
  26. package/src/__tests__/hooks.test.ts +221 -0
  27. package/src/__tests__/import-resolver.test.ts +95 -0
  28. package/src/__tests__/integration/path-traversal.test.ts +134 -0
  29. package/src/__tests__/integration/pricing-consistency.test.ts +88 -0
  30. package/src/__tests__/integration/tool-registration.test.ts +146 -0
  31. package/src/__tests__/memory-db.test.ts +404 -0
  32. package/src/__tests__/memory-enhancements.test.ts +316 -0
  33. package/src/__tests__/memory-tools.test.ts +199 -0
  34. package/src/__tests__/middleware-tree.test.ts +177 -0
  35. package/src/__tests__/observability-tools.test.ts +595 -0
  36. package/src/__tests__/observability.test.ts +437 -0
  37. package/src/__tests__/observation-extractor.test.ts +167 -0
  38. package/src/__tests__/page-deps.test.ts +60 -0
  39. package/src/__tests__/prompt-analyzer.test.ts +298 -0
  40. package/src/__tests__/regression-detector.test.ts +295 -0
  41. package/src/__tests__/rules.test.ts +87 -0
  42. package/src/__tests__/schema-mapper.test.ts +29 -0
  43. package/src/__tests__/security-scorer.test.ts +238 -0
  44. package/src/__tests__/security-utils.test.ts +175 -0
  45. package/src/__tests__/sentinel-db.test.ts +491 -0
  46. package/src/__tests__/sentinel-scanner.test.ts +750 -0
  47. package/src/__tests__/sentinel-tools.test.ts +324 -0
  48. package/src/__tests__/sentinel-types.test.ts +750 -0
  49. package/src/__tests__/server.test.ts +452 -0
  50. package/src/__tests__/session-archiver.test.ts +524 -0
  51. package/src/__tests__/session-state-generator.test.ts +900 -0
  52. package/src/__tests__/team-knowledge.test.ts +327 -0
  53. package/src/__tests__/tools.test.ts +340 -0
  54. package/src/__tests__/transcript-parser.test.ts +195 -0
  55. package/src/__tests__/trpc-index.test.ts +25 -0
  56. package/src/__tests__/validate-features-runner.test.ts +517 -0
  57. package/src/__tests__/validation-engine.test.ts +300 -0
  58. package/src/adr-generator.ts +285 -0
  59. package/src/analytics.ts +367 -0
  60. package/src/audit-trail.ts +443 -0
  61. package/src/backfill-sessions.ts +180 -0
  62. package/src/cli.ts +105 -0
  63. package/src/cloud-sync.ts +194 -0
  64. package/src/commands/doctor.ts +300 -0
  65. package/src/commands/init.ts +399 -0
  66. package/src/commands/install-hooks.ts +26 -0
  67. package/src/config.ts +357 -0
  68. package/src/core-tools.ts +685 -0
  69. package/src/cost-tracker.ts +350 -0
  70. package/src/db.ts +233 -0
  71. package/src/dependency-scorer.ts +330 -0
  72. package/src/docs-map.json +100 -0
  73. package/src/docs-tools.ts +514 -0
  74. package/src/domains.ts +181 -0
  75. package/src/hooks/cost-tracker.ts +66 -0
  76. package/src/hooks/intent-suggester.ts +131 -0
  77. package/src/hooks/post-edit-context.ts +91 -0
  78. package/src/hooks/post-tool-use.ts +175 -0
  79. package/src/hooks/pre-compact.ts +146 -0
  80. package/src/hooks/pre-delete-check.ts +153 -0
  81. package/src/hooks/quality-event.ts +127 -0
  82. package/src/hooks/security-gate.ts +121 -0
  83. package/src/hooks/session-end.ts +467 -0
  84. package/src/hooks/session-start.ts +210 -0
  85. package/src/hooks/user-prompt.ts +91 -0
  86. package/src/import-resolver.ts +224 -0
  87. package/src/memory-db.ts +48 -0
  88. package/src/memory-queries.ts +804 -0
  89. package/src/memory-schema.ts +546 -0
  90. package/src/memory-tools.ts +392 -0
  91. package/src/middleware-tree.ts +70 -0
  92. package/src/observability-tools.ts +332 -0
  93. package/src/observation-extractor.ts +411 -0
  94. package/src/page-deps.ts +283 -0
  95. package/src/prompt-analyzer.ts +325 -0
  96. package/src/regression-detector.ts +313 -0
  97. package/src/rules.ts +57 -0
  98. package/src/schema-mapper.ts +232 -0
  99. package/src/security-scorer.ts +398 -0
  100. package/src/security-utils.ts +133 -0
  101. package/src/sentinel-db.ts +623 -0
  102. package/src/sentinel-scanner.ts +405 -0
  103. package/src/sentinel-tools.ts +515 -0
  104. package/src/sentinel-types.ts +140 -0
  105. package/src/server.ts +190 -0
  106. package/src/session-archiver.ts +112 -0
  107. package/src/session-state-generator.ts +174 -0
  108. package/src/team-knowledge.ts +400 -0
  109. package/src/tool-helpers.ts +41 -0
  110. package/src/tools.ts +111 -0
  111. package/src/transcript-parser.ts +458 -0
  112. package/src/trpc-index.ts +214 -0
  113. package/src/validate-features-runner.ts +107 -0
  114. package/src/validation-engine.ts +351 -0
@@ -0,0 +1,623 @@
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 { existsSync } from 'fs';
6
+ import { resolve } from 'path';
7
+ import type {
8
+ Feature,
9
+ FeatureInput,
10
+ FeatureComponent,
11
+ FeatureProcedure,
12
+ FeaturePage,
13
+ FeatureDep,
14
+ FeatureChangeLog,
15
+ FeatureWithCounts,
16
+ FeatureDetail,
17
+ ImpactReport,
18
+ ImpactItem,
19
+ ValidationReport,
20
+ ValidationItem,
21
+ ParityReport,
22
+ ParityItem,
23
+ ComponentRole,
24
+ } from './sentinel-types.ts';
25
+ import { getProjectRoot } from './config.ts';
26
+
27
+ // ============================================================
28
+ // Sentinel: Feature Registry Data Access Layer
29
+ // ============================================================
30
+
31
+ const PROJECT_ROOT = getProjectRoot();
32
+
33
+ function parsePortalScope(raw: string | null): string[] {
34
+ if (!raw) return [];
35
+ try {
36
+ return JSON.parse(raw);
37
+ } catch {
38
+ return [];
39
+ }
40
+ }
41
+
42
+ function toFeature(row: Record<string, unknown>): Feature {
43
+ return {
44
+ id: row.id as number,
45
+ feature_key: row.feature_key as string,
46
+ domain: row.domain as string,
47
+ subdomain: (row.subdomain as string) || null,
48
+ title: row.title as string,
49
+ description: (row.description as string) || null,
50
+ status: row.status as Feature['status'],
51
+ priority: row.priority as Feature['priority'],
52
+ portal_scope: parsePortalScope(row.portal_scope as string),
53
+ created_at: row.created_at as string,
54
+ updated_at: row.updated_at as string,
55
+ removed_at: (row.removed_at as string) || null,
56
+ removed_reason: (row.removed_reason as string) || null,
57
+ };
58
+ }
59
+
60
+ // ============================================================
61
+ // Core CRUD
62
+ // ============================================================
63
+
64
+ export function upsertFeature(db: Database.Database, input: FeatureInput): number {
65
+ const existing = db.prepare('SELECT id FROM massu_sentinel WHERE feature_key = ?').get(input.feature_key) as { id: number } | undefined;
66
+
67
+ if (existing) {
68
+ db.prepare(`
69
+ UPDATE massu_sentinel SET
70
+ domain = ?, subdomain = ?, title = ?, description = ?,
71
+ status = COALESCE(?, status), priority = COALESCE(?, priority),
72
+ portal_scope = COALESCE(?, portal_scope),
73
+ updated_at = datetime('now')
74
+ WHERE id = ?
75
+ `).run(
76
+ input.domain,
77
+ input.subdomain || null,
78
+ input.title,
79
+ input.description || null,
80
+ input.status || null,
81
+ input.priority || null,
82
+ input.portal_scope ? JSON.stringify(input.portal_scope) : null,
83
+ existing.id
84
+ );
85
+ return existing.id;
86
+ }
87
+
88
+ const result = db.prepare(`
89
+ INSERT INTO massu_sentinel (feature_key, domain, subdomain, title, description, status, priority, portal_scope)
90
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
91
+ `).run(
92
+ input.feature_key,
93
+ input.domain,
94
+ input.subdomain || null,
95
+ input.title,
96
+ input.description || null,
97
+ input.status || 'active',
98
+ input.priority || 'standard',
99
+ JSON.stringify(input.portal_scope || [])
100
+ );
101
+
102
+ return Number(result.lastInsertRowid);
103
+ }
104
+
105
+ export function getFeature(db: Database.Database, featureKey: string): Feature | null {
106
+ const row = db.prepare('SELECT * FROM massu_sentinel WHERE feature_key = ?').get(featureKey) as Record<string, unknown> | undefined;
107
+ return row ? toFeature(row) : null;
108
+ }
109
+
110
+ export function getFeatureById(db: Database.Database, id: number): Feature | null {
111
+ const row = db.prepare('SELECT * FROM massu_sentinel WHERE id = ?').get(id) as Record<string, unknown> | undefined;
112
+ return row ? toFeature(row) : null;
113
+ }
114
+
115
+ // ============================================================
116
+ // Search & Query
117
+ // ============================================================
118
+
119
+ export function searchFeatures(
120
+ db: Database.Database,
121
+ query: string,
122
+ filters?: { domain?: string; subdomain?: string; status?: string; portal?: string; page_route?: string }
123
+ ): FeatureWithCounts[] {
124
+ let sql: string;
125
+ const params: unknown[] = [];
126
+
127
+ if (query && query.trim()) {
128
+ // FTS5 search
129
+ sql = `
130
+ SELECT s.*, fts.rank,
131
+ (SELECT COUNT(*) FROM massu_sentinel_components WHERE feature_id = s.id) as component_count,
132
+ (SELECT COUNT(*) FROM massu_sentinel_procedures WHERE feature_id = s.id) as procedure_count,
133
+ (SELECT COUNT(*) FROM massu_sentinel_pages WHERE feature_id = s.id) as page_count
134
+ FROM massu_sentinel s
135
+ JOIN massu_sentinel_fts fts ON s.id = fts.rowid
136
+ WHERE massu_sentinel_fts MATCH ?
137
+ `;
138
+ params.push(query);
139
+ } else {
140
+ sql = `
141
+ SELECT s.*,
142
+ (SELECT COUNT(*) FROM massu_sentinel_components WHERE feature_id = s.id) as component_count,
143
+ (SELECT COUNT(*) FROM massu_sentinel_procedures WHERE feature_id = s.id) as procedure_count,
144
+ (SELECT COUNT(*) FROM massu_sentinel_pages WHERE feature_id = s.id) as page_count
145
+ FROM massu_sentinel s
146
+ WHERE 1=1
147
+ `;
148
+ }
149
+
150
+ if (filters?.domain) {
151
+ sql += ' AND s.domain = ?';
152
+ params.push(filters.domain);
153
+ }
154
+ if (filters?.subdomain) {
155
+ sql += ' AND s.subdomain = ?';
156
+ params.push(filters.subdomain);
157
+ }
158
+ if (filters?.status) {
159
+ sql += ' AND s.status = ?';
160
+ params.push(filters.status);
161
+ }
162
+ if (filters?.portal) {
163
+ sql += ` AND s.portal_scope LIKE ? ESCAPE '\\'`;
164
+ const escapedPortal = filters.portal.replace(/[%_]/g, '\\$&');
165
+ params.push(`%"${escapedPortal}"%`);
166
+ }
167
+ if (filters?.page_route) {
168
+ sql += ' AND s.id IN (SELECT feature_id FROM massu_sentinel_pages WHERE page_route = ?)';
169
+ params.push(filters.page_route);
170
+ }
171
+
172
+ sql += query && query.trim() ? ' ORDER BY fts.rank LIMIT 100' : ' ORDER BY s.domain, s.subdomain, s.feature_key LIMIT 100';
173
+
174
+ const rows = db.prepare(sql).all(...params) as (Record<string, unknown>)[];
175
+ return rows.map(row => ({
176
+ ...toFeature(row),
177
+ component_count: row.component_count as number,
178
+ procedure_count: row.procedure_count as number,
179
+ page_count: row.page_count as number,
180
+ }));
181
+ }
182
+
183
+ export function getFeaturesByDomain(db: Database.Database, domain: string): Feature[] {
184
+ const rows = db.prepare('SELECT * FROM massu_sentinel WHERE domain = ? ORDER BY subdomain, feature_key').all(domain) as Record<string, unknown>[];
185
+ return rows.map(toFeature);
186
+ }
187
+
188
+ export function getFeaturesByFile(db: Database.Database, filePath: string): Feature[] {
189
+ const rows = db.prepare(`
190
+ SELECT DISTINCT s.* FROM massu_sentinel s
191
+ JOIN massu_sentinel_components c ON c.feature_id = s.id
192
+ WHERE c.component_file = ?
193
+ ORDER BY s.feature_key
194
+ `).all(filePath) as Record<string, unknown>[];
195
+ return rows.map(toFeature);
196
+ }
197
+
198
+ export function getFeaturesByRoute(db: Database.Database, route: string): Feature[] {
199
+ const rows = db.prepare(`
200
+ SELECT DISTINCT s.* FROM massu_sentinel s
201
+ JOIN massu_sentinel_pages p ON p.feature_id = s.id
202
+ WHERE p.page_route = ?
203
+ ORDER BY s.feature_key
204
+ `).all(route) as Record<string, unknown>[];
205
+ return rows.map(toFeature);
206
+ }
207
+
208
+ // ============================================================
209
+ // Feature Detail (full join)
210
+ // ============================================================
211
+
212
+ export function getFeatureDetail(db: Database.Database, featureKeyOrId: string | number): FeatureDetail | null {
213
+ let feature: Feature | null;
214
+ if (typeof featureKeyOrId === 'number') {
215
+ feature = getFeatureById(db, featureKeyOrId);
216
+ } else {
217
+ feature = getFeature(db, featureKeyOrId);
218
+ }
219
+ if (!feature) return null;
220
+
221
+ const components = db.prepare('SELECT * FROM massu_sentinel_components WHERE feature_id = ?').all(feature.id) as FeatureComponent[];
222
+ const procedures = db.prepare('SELECT * FROM massu_sentinel_procedures WHERE feature_id = ?').all(feature.id) as FeatureProcedure[];
223
+ const pages = db.prepare('SELECT * FROM massu_sentinel_pages WHERE feature_id = ?').all(feature.id) as FeaturePage[];
224
+ const dependencies = db.prepare('SELECT * FROM massu_sentinel_deps WHERE feature_id = ?').all(feature.id) as FeatureDep[];
225
+ const changelog = db.prepare('SELECT * FROM massu_sentinel_changelog WHERE feature_id = ? ORDER BY created_at DESC LIMIT 50').all(feature.id) as FeatureChangeLog[];
226
+
227
+ return { ...feature, components, procedures, pages, dependencies, changelog };
228
+ }
229
+
230
+ // ============================================================
231
+ // Orphan & Impact Detection
232
+ // ============================================================
233
+
234
+ export function getOrphanedFeatures(db: Database.Database): Feature[] {
235
+ // Active features with no living primary component files
236
+ const features = db.prepare(`
237
+ SELECT s.* FROM massu_sentinel s
238
+ WHERE s.status = 'active'
239
+ AND NOT EXISTS (
240
+ SELECT 1 FROM massu_sentinel_components c
241
+ WHERE c.feature_id = s.id AND c.is_primary = 1
242
+ )
243
+ ORDER BY s.domain, s.feature_key
244
+ `).all() as Record<string, unknown>[];
245
+ return features.map(toFeature);
246
+ }
247
+
248
+ export function getFeatureImpact(db: Database.Database, filePaths: string[]): ImpactReport {
249
+ if (filePaths.length === 0) {
250
+ return { files_analyzed: [], orphaned: [], degraded: [], unaffected: [], blocked: false, block_reason: null };
251
+ }
252
+
253
+ const fileSet = new Set(filePaths);
254
+
255
+ // Batch: find all features linked to these files in a single query
256
+ const placeholders = filePaths.map(() => '?').join(',');
257
+ const featureLinks = db.prepare(
258
+ `SELECT DISTINCT feature_id FROM massu_sentinel_components WHERE component_file IN (${placeholders})`
259
+ ).all(...filePaths) as { feature_id: number }[];
260
+ const affectedFeatureIds = featureLinks.map(l => l.feature_id);
261
+
262
+ if (affectedFeatureIds.length === 0) {
263
+ return { files_analyzed: filePaths, orphaned: [], degraded: [], unaffected: [], blocked: false, block_reason: null };
264
+ }
265
+
266
+ // Batch: load all affected features in a single query
267
+ const featurePlaceholders = affectedFeatureIds.map(() => '?').join(',');
268
+ const featureRows = db.prepare(
269
+ `SELECT * FROM massu_sentinel WHERE id IN (${featurePlaceholders}) AND status = 'active'`
270
+ ).all(...affectedFeatureIds) as Record<string, unknown>[];
271
+ const featuresById = new Map(featureRows.map(r => [r.id as number, toFeature(r)]));
272
+
273
+ // Batch: load all components for affected features in a single query
274
+ const allComponents = db.prepare(
275
+ `SELECT feature_id, component_file, is_primary FROM massu_sentinel_components WHERE feature_id IN (${featurePlaceholders})`
276
+ ).all(...affectedFeatureIds) as { feature_id: number; component_file: string; is_primary: number }[];
277
+
278
+ // Group components by feature
279
+ const componentsByFeature = new Map<number, typeof allComponents>();
280
+ for (const comp of allComponents) {
281
+ let list = componentsByFeature.get(comp.feature_id);
282
+ if (!list) { list = []; componentsByFeature.set(comp.feature_id, list); }
283
+ list.push(comp);
284
+ }
285
+
286
+ const orphaned: ImpactItem[] = [];
287
+ const degraded: ImpactItem[] = [];
288
+ const unaffected: ImpactItem[] = [];
289
+
290
+ for (const featureId of affectedFeatureIds) {
291
+ const feature = featuresById.get(featureId);
292
+ if (!feature) continue;
293
+
294
+ const comps = componentsByFeature.get(featureId) ?? [];
295
+ const affected = comps.filter(c => fileSet.has(c.component_file));
296
+ const remaining = comps.filter(c => !fileSet.has(c.component_file));
297
+ const primaryAffected = affected.some(c => c.is_primary);
298
+
299
+ const item: ImpactItem = {
300
+ feature,
301
+ affected_files: affected.map(c => c.component_file),
302
+ remaining_files: remaining.map(c => c.component_file),
303
+ status: 'unaffected',
304
+ };
305
+
306
+ if (primaryAffected && remaining.filter(c => c.is_primary).length === 0) {
307
+ item.status = 'orphaned';
308
+ orphaned.push(item);
309
+ } else if (affected.length > 0) {
310
+ item.status = 'degraded';
311
+ degraded.push(item);
312
+ } else {
313
+ unaffected.push(item);
314
+ }
315
+ }
316
+
317
+ const hasCriticalOrphans = orphaned.some(o => o.feature.priority === 'critical');
318
+ const hasStandardOrphans = orphaned.some(o => o.feature.priority === 'standard');
319
+
320
+ return {
321
+ files_analyzed: filePaths,
322
+ orphaned,
323
+ degraded,
324
+ unaffected,
325
+ blocked: hasCriticalOrphans || hasStandardOrphans,
326
+ block_reason: hasCriticalOrphans
327
+ ? `BLOCKED: ${orphaned.length} features would be orphaned (includes critical features). Create migration plan first.`
328
+ : hasStandardOrphans
329
+ ? `BLOCKED: ${orphaned.length} standard features would be orphaned. Create migration plan first.`
330
+ : null,
331
+ };
332
+ }
333
+
334
+ // ============================================================
335
+ // Links: Components, Procedures, Pages
336
+ // ============================================================
337
+
338
+ export function linkComponent(
339
+ db: Database.Database,
340
+ featureId: number,
341
+ filePath: string,
342
+ componentName: string | null,
343
+ role: ComponentRole = 'implementation',
344
+ isPrimary: boolean = false
345
+ ): void {
346
+ db.prepare(`
347
+ INSERT OR REPLACE INTO massu_sentinel_components (feature_id, component_file, component_name, role, is_primary)
348
+ VALUES (?, ?, ?, ?, ?)
349
+ `).run(featureId, filePath, componentName, role, isPrimary ? 1 : 0);
350
+ }
351
+
352
+ export function linkProcedure(
353
+ db: Database.Database,
354
+ featureId: number,
355
+ routerName: string,
356
+ procedureName: string,
357
+ procedureType?: string
358
+ ): void {
359
+ db.prepare(`
360
+ INSERT OR REPLACE INTO massu_sentinel_procedures (feature_id, router_name, procedure_name, procedure_type)
361
+ VALUES (?, ?, ?, ?)
362
+ `).run(featureId, routerName, procedureName, procedureType || null);
363
+ }
364
+
365
+ export function linkPage(
366
+ db: Database.Database,
367
+ featureId: number,
368
+ route: string,
369
+ portal?: string
370
+ ): void {
371
+ db.prepare(`
372
+ INSERT OR REPLACE INTO massu_sentinel_pages (feature_id, page_route, portal)
373
+ VALUES (?, ?, ?)
374
+ `).run(featureId, route, portal || null);
375
+ }
376
+
377
+ // ============================================================
378
+ // Changelog
379
+ // ============================================================
380
+
381
+ export function logChange(
382
+ db: Database.Database,
383
+ featureId: number,
384
+ changeType: string,
385
+ detail: string | null,
386
+ commitHash?: string,
387
+ changedBy: string = 'claude-code'
388
+ ): void {
389
+ db.prepare(`
390
+ INSERT INTO massu_sentinel_changelog (feature_id, change_type, changed_by, change_detail, commit_hash)
391
+ VALUES (?, ?, ?, ?, ?)
392
+ `).run(featureId, changeType, changedBy, detail, commitHash || null);
393
+ }
394
+
395
+ // ============================================================
396
+ // Validation
397
+ // ============================================================
398
+
399
+ export function validateFeatures(db: Database.Database, domainFilter?: string): ValidationReport {
400
+ let sql = `SELECT * FROM massu_sentinel WHERE status = 'active'`;
401
+ const params: unknown[] = [];
402
+ if (domainFilter) {
403
+ sql += ' AND domain = ?';
404
+ params.push(domainFilter);
405
+ }
406
+ sql += ' ORDER BY domain, feature_key LIMIT 1000';
407
+
408
+ const features = db.prepare(sql).all(...params) as Record<string, unknown>[];
409
+ const details: ValidationItem[] = [];
410
+ let alive = 0;
411
+ let orphaned = 0;
412
+ let degradedCount = 0;
413
+
414
+ for (const row of features) {
415
+ const feature = toFeature(row);
416
+ const components = db.prepare('SELECT * FROM massu_sentinel_components WHERE feature_id = ?').all(feature.id) as FeatureComponent[];
417
+ const procedures = db.prepare('SELECT * FROM massu_sentinel_procedures WHERE feature_id = ?').all(feature.id) as FeatureProcedure[];
418
+ const pages = db.prepare('SELECT * FROM massu_sentinel_pages WHERE feature_id = ?').all(feature.id) as FeaturePage[];
419
+
420
+ const missingComponents: string[] = [];
421
+ const missingProcedures: { router: string; procedure: string }[] = [];
422
+ const missingPages: string[] = [];
423
+
424
+ // Check component files exist on disk
425
+ for (const comp of components) {
426
+ const absPath = resolve(PROJECT_ROOT, comp.component_file);
427
+ if (!existsSync(absPath)) {
428
+ missingComponents.push(comp.component_file);
429
+ }
430
+ }
431
+
432
+ // Check procedure files exist and contain the procedure
433
+ for (const proc of procedures) {
434
+ // Look for the router file based on convention
435
+ const routerPath = resolve(PROJECT_ROOT, `src/server/api/routers/${proc.router_name}.ts`);
436
+ if (!existsSync(routerPath)) {
437
+ missingProcedures.push({ router: proc.router_name, procedure: proc.procedure_name });
438
+ }
439
+ }
440
+
441
+ // Check page routes exist
442
+ for (const page of pages) {
443
+ const routeToPath = page.page_route
444
+ .replace(/^\/(portal-[^/]+\/)?/, 'src/app/')
445
+ .replace(/\/$/, '')
446
+ + '/page.tsx';
447
+ const absPath = resolve(PROJECT_ROOT, routeToPath);
448
+ // Page route checking is approximate - don't flag as missing if path conversion is ambiguous
449
+ if (page.page_route.startsWith('/') && !existsSync(absPath)) {
450
+ // Try alternative path patterns
451
+ const altPath = resolve(PROJECT_ROOT, `src/app${page.page_route}/page.tsx`);
452
+ if (!existsSync(altPath)) {
453
+ missingPages.push(page.page_route);
454
+ }
455
+ }
456
+ }
457
+
458
+ const hasMissing = missingComponents.length > 0 || missingProcedures.length > 0;
459
+ const primaryMissing = components.filter(c => c.is_primary && missingComponents.includes(c.component_file)).length > 0;
460
+ const allPrimaryMissing = components.filter(c => c.is_primary).length > 0 &&
461
+ components.filter(c => c.is_primary).every(c => missingComponents.includes(c.component_file));
462
+
463
+ let status: 'alive' | 'orphaned' | 'degraded';
464
+ if (allPrimaryMissing || (components.length > 0 && missingComponents.length === components.length)) {
465
+ status = 'orphaned';
466
+ orphaned++;
467
+ } else if (hasMissing) {
468
+ status = 'degraded';
469
+ degradedCount++;
470
+ } else {
471
+ status = 'alive';
472
+ alive++;
473
+ }
474
+
475
+ details.push({
476
+ feature,
477
+ missing_components: missingComponents,
478
+ missing_procedures: missingProcedures,
479
+ missing_pages: missingPages,
480
+ status,
481
+ });
482
+ }
483
+
484
+ return { alive, orphaned, degraded: degradedCount, details };
485
+ }
486
+
487
+ // ============================================================
488
+ // Parity Check (for rebuild scenarios)
489
+ // ============================================================
490
+
491
+ export function checkParity(db: Database.Database, oldFiles: string[], newFiles: string[]): ParityReport {
492
+ if (oldFiles.length === 0 && newFiles.length === 0) {
493
+ return { done: [], gaps: [], new_features: [], parity_percentage: 100 };
494
+ }
495
+
496
+ // Batch: find features linked to old files in a single query
497
+ const oldFeatureIds = new Set<number>();
498
+ if (oldFiles.length > 0) {
499
+ const ph = oldFiles.map(() => '?').join(',');
500
+ const links = db.prepare(`SELECT DISTINCT feature_id FROM massu_sentinel_components WHERE component_file IN (${ph})`).all(...oldFiles) as { feature_id: number }[];
501
+ for (const link of links) oldFeatureIds.add(link.feature_id);
502
+ }
503
+
504
+ // Batch: find features linked to new files in a single query
505
+ const newFeatureIds = new Set<number>();
506
+ if (newFiles.length > 0) {
507
+ const ph = newFiles.map(() => '?').join(',');
508
+ const links = db.prepare(`SELECT DISTINCT feature_id FROM massu_sentinel_components WHERE component_file IN (${ph})`).all(...newFiles) as { feature_id: number }[];
509
+ for (const link of links) newFeatureIds.add(link.feature_id);
510
+ }
511
+
512
+ // Batch: load all referenced features in a single query
513
+ const allIds = [...new Set([...oldFeatureIds, ...newFeatureIds])];
514
+ const featuresById = new Map<number, Feature>();
515
+ if (allIds.length > 0) {
516
+ const ph = allIds.map(() => '?').join(',');
517
+ const rows = db.prepare(`SELECT * FROM massu_sentinel WHERE id IN (${ph})`).all(...allIds) as Record<string, unknown>[];
518
+ for (const row of rows) {
519
+ const f = toFeature(row);
520
+ featuresById.set(f.id, f);
521
+ }
522
+ }
523
+
524
+ // Batch: load all components for referenced features
525
+ const componentsByFeature = new Map<number, Array<{ component_file: string }>>();
526
+ if (allIds.length > 0) {
527
+ const ph = allIds.map(() => '?').join(',');
528
+ const comps = db.prepare(`SELECT feature_id, component_file FROM massu_sentinel_components WHERE feature_id IN (${ph})`).all(...allIds) as { feature_id: number; component_file: string }[];
529
+ for (const comp of comps) {
530
+ let list = componentsByFeature.get(comp.feature_id);
531
+ if (!list) { list = []; componentsByFeature.set(comp.feature_id, list); }
532
+ list.push(comp);
533
+ }
534
+ }
535
+
536
+ const oldFileSet = new Set(oldFiles);
537
+ const newFileSet = new Set(newFiles);
538
+ const done: ParityItem[] = [];
539
+ const gaps: ParityItem[] = [];
540
+ const newFeaturesList: ParityItem[] = [];
541
+
542
+ for (const fId of oldFeatureIds) {
543
+ const feature = featuresById.get(fId);
544
+ if (!feature) continue;
545
+
546
+ const comps = componentsByFeature.get(fId) ?? [];
547
+ const oldComps = comps.filter(c => oldFileSet.has(c.component_file));
548
+ const newComps = comps.filter(c => newFileSet.has(c.component_file));
549
+
550
+ const item: ParityItem = {
551
+ feature_key: feature.feature_key,
552
+ title: feature.title,
553
+ status: newFeatureIds.has(fId) ? 'DONE' : 'GAP',
554
+ old_files: oldComps.map(c => c.component_file),
555
+ new_files: newComps.map(c => c.component_file),
556
+ };
557
+
558
+ if (item.status === 'DONE') {
559
+ done.push(item);
560
+ } else {
561
+ gaps.push(item);
562
+ }
563
+ }
564
+
565
+ for (const fId of newFeatureIds) {
566
+ if (oldFeatureIds.has(fId)) continue;
567
+ const feature = featuresById.get(fId);
568
+ if (!feature) continue;
569
+
570
+ const comps = componentsByFeature.get(fId) ?? [];
571
+ const newComps = comps.filter(c => newFileSet.has(c.component_file));
572
+
573
+ newFeaturesList.push({
574
+ feature_key: feature.feature_key,
575
+ title: feature.title,
576
+ status: 'NEW',
577
+ old_files: [],
578
+ new_files: newComps.map(c => c.component_file),
579
+ });
580
+ }
581
+
582
+ const total = done.length + gaps.length;
583
+ const parityPercentage = total > 0 ? Math.round((done.length / total) * 100) : 100;
584
+
585
+ return { done, gaps, new_features: newFeaturesList, parity_percentage: parityPercentage };
586
+ }
587
+
588
+ // ============================================================
589
+ // Bulk operations for scanner
590
+ // ============================================================
591
+
592
+ export function clearAutoDiscoveredFeatures(db: Database.Database): void {
593
+ // Only clear features that were auto-discovered (not manually registered)
594
+ // We use the changelog to distinguish: auto-discovered ones have changed_by = 'scanner'
595
+ const autoIds = db.prepare(`
596
+ SELECT DISTINCT feature_id FROM massu_sentinel_changelog
597
+ WHERE changed_by = 'scanner' AND change_type = 'created'
598
+ `).all() as { feature_id: number }[];
599
+
600
+ // Don't delete features that also have manual changes
601
+ for (const { feature_id } of autoIds) {
602
+ const hasManualChanges = db.prepare(`
603
+ SELECT 1 FROM massu_sentinel_changelog
604
+ WHERE feature_id = ? AND changed_by != 'scanner'
605
+ `).get(feature_id);
606
+
607
+ if (!hasManualChanges) {
608
+ db.prepare('DELETE FROM massu_sentinel WHERE id = ?').run(feature_id);
609
+ }
610
+ }
611
+ }
612
+
613
+ export function bulkUpsertFeatures(db: Database.Database, features: FeatureInput[]): number {
614
+ let count = 0;
615
+ const upsert = db.transaction((items: FeatureInput[]) => {
616
+ for (const item of items) {
617
+ upsertFeature(db, item);
618
+ count++;
619
+ }
620
+ });
621
+ upsert(features);
622
+ return count;
623
+ }