@massu/core 1.10.8 → 1.12.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 (178) hide show
  1. package/CHANGELOG.md +1334 -0
  2. package/dist/adapter.js +6 -4
  3. package/dist/cli.js +581 -448
  4. package/dist/detect/adapters/.bundle-shasums.json +4 -7
  5. package/dist/detect/adapters/rails.js +6 -4
  6. package/dist/detect/adapters/spring.js +6 -4
  7. package/dist/detect/adapters/tree-sitter-loader.js +6 -4
  8. package/dist/hooks/auto-learning-pipeline.js +24 -1
  9. package/dist/hooks/classify-failure.js +32 -3
  10. package/dist/hooks/cost-tracker.js +24 -0
  11. package/dist/hooks/fix-detector.js +97 -1
  12. package/dist/hooks/incident-pipeline.js +30 -1
  13. package/dist/hooks/intent-suggester.js +6 -3
  14. package/dist/hooks/post-edit-context.js +24 -1
  15. package/dist/hooks/post-tool-use.js +67 -19
  16. package/dist/hooks/pre-compact.js +28 -3
  17. package/dist/hooks/pre-delete-check.js +53 -27
  18. package/dist/hooks/pre-tool-use-gate.js +962 -0
  19. package/dist/hooks/quality-event.js +24 -0
  20. package/dist/hooks/rule-enforcement-pipeline.js +24 -1
  21. package/dist/hooks/security-gate.js +29 -16
  22. package/dist/hooks/session-end.js +31 -6
  23. package/dist/hooks/session-start.js +8709 -8459
  24. package/dist/hooks/user-prompt.js +25 -1
  25. package/package.json +15 -18
  26. package/commands/README.md +0 -134
  27. package/src/adapter.ts +0 -121
  28. package/src/adr-generator.ts +0 -292
  29. package/src/analytics.ts +0 -373
  30. package/src/audit-trail.ts +0 -450
  31. package/src/backfill-sessions.ts +0 -182
  32. package/src/changelog-generator.ts +0 -178
  33. package/src/cli.ts +0 -246
  34. package/src/cloud-sync.ts +0 -234
  35. package/src/commands/adapters.ts +0 -824
  36. package/src/commands/changelog.ts +0 -165
  37. package/src/commands/config-check-drift.ts +0 -133
  38. package/src/commands/config-refresh.ts +0 -396
  39. package/src/commands/config-upgrade.ts +0 -127
  40. package/src/commands/doctor.ts +0 -485
  41. package/src/commands/hook-runner.ts +0 -145
  42. package/src/commands/init.ts +0 -1823
  43. package/src/commands/install-commands.ts +0 -692
  44. package/src/commands/install-hooks.ts +0 -27
  45. package/src/commands/permissions.ts +0 -150
  46. package/src/commands/refresh-log.ts +0 -37
  47. package/src/commands/show-template.ts +0 -65
  48. package/src/commands/template-engine.ts +0 -301
  49. package/src/commands/watch.ts +0 -430
  50. package/src/config.ts +0 -765
  51. package/src/cost-tracker.ts +0 -355
  52. package/src/db.ts +0 -370
  53. package/src/dependency-scorer.ts +0 -337
  54. package/src/detect/adapters/aspnet.ts +0 -4
  55. package/src/detect/adapters/discover.ts +0 -469
  56. package/src/detect/adapters/file-sampler.ts +0 -225
  57. package/src/detect/adapters/go-chi.ts +0 -4
  58. package/src/detect/adapters/index.ts +0 -51
  59. package/src/detect/adapters/nextjs-trpc.ts +0 -166
  60. package/src/detect/adapters/parse-guard.ts +0 -133
  61. package/src/detect/adapters/phoenix.ts +0 -4
  62. package/src/detect/adapters/python-django.ts +0 -208
  63. package/src/detect/adapters/python-fastapi.ts +0 -223
  64. package/src/detect/adapters/python-flask.ts +0 -235
  65. package/src/detect/adapters/query-helpers.ts +0 -170
  66. package/src/detect/adapters/rails.ts +0 -4
  67. package/src/detect/adapters/runner.ts +0 -284
  68. package/src/detect/adapters/spring.ts +0 -4
  69. package/src/detect/adapters/swift-swiftui.ts +0 -171
  70. package/src/detect/adapters/tree-sitter-loader.ts +0 -517
  71. package/src/detect/adapters/types.ts +0 -191
  72. package/src/detect/codebase-introspector.ts +0 -210
  73. package/src/detect/domain-inferrer.ts +0 -197
  74. package/src/detect/drift.ts +0 -199
  75. package/src/detect/framework-detector.ts +0 -307
  76. package/src/detect/index.ts +0 -209
  77. package/src/detect/manifest-registry.ts +0 -261
  78. package/src/detect/migrate.ts +0 -362
  79. package/src/detect/monorepo-detector.ts +0 -348
  80. package/src/detect/package-detector.ts +0 -828
  81. package/src/detect/passthrough.ts +0 -108
  82. package/src/detect/regex-fallback.ts +0 -449
  83. package/src/detect/source-dir-detector.ts +0 -297
  84. package/src/detect/vr-command-map.ts +0 -167
  85. package/src/docs-map.json +0 -100
  86. package/src/docs-tools.ts +0 -519
  87. package/src/domains.ts +0 -182
  88. package/src/hooks/auto-learning-pipeline.ts +0 -228
  89. package/src/hooks/classify-failure.ts +0 -259
  90. package/src/hooks/cost-tracker.ts +0 -66
  91. package/src/hooks/fix-detector.ts +0 -186
  92. package/src/hooks/incident-pipeline.ts +0 -190
  93. package/src/hooks/intent-suggester.ts +0 -131
  94. package/src/hooks/post-edit-context.ts +0 -91
  95. package/src/hooks/post-tool-use.ts +0 -413
  96. package/src/hooks/pre-compact.ts +0 -168
  97. package/src/hooks/pre-delete-check.ts +0 -243
  98. package/src/hooks/quality-event.ts +0 -127
  99. package/src/hooks/rule-enforcement-pipeline.ts +0 -159
  100. package/src/hooks/security-gate.ts +0 -153
  101. package/src/hooks/session-end.ts +0 -467
  102. package/src/hooks/session-start.ts +0 -436
  103. package/src/hooks/user-prompt.ts +0 -157
  104. package/src/import-resolver.ts +0 -226
  105. package/src/knowledge-db.ts +0 -176
  106. package/src/knowledge-indexer.ts +0 -708
  107. package/src/knowledge-tools.ts +0 -1414
  108. package/src/lib/fileLock.ts +0 -203
  109. package/src/lib/framework-supports.ts +0 -85
  110. package/src/lib/gitToplevel.ts +0 -22
  111. package/src/lib/hook-registry.ts +0 -43
  112. package/src/lib/installLock.ts +0 -66
  113. package/src/lib/memory-path.ts +0 -49
  114. package/src/lib/pidLiveness.ts +0 -67
  115. package/src/lib/settings-local.ts +0 -110
  116. package/src/lib/sql-table-names.ts +0 -71
  117. package/src/license.ts +0 -547
  118. package/src/lsp/auto-detect.ts +0 -98
  119. package/src/lsp/client.ts +0 -776
  120. package/src/lsp/enrich.ts +0 -127
  121. package/src/lsp/types.ts +0 -221
  122. package/src/memory-db.ts +0 -1614
  123. package/src/memory-file-ingest.ts +0 -128
  124. package/src/memory-tools.ts +0 -424
  125. package/src/middleware-tree.ts +0 -71
  126. package/src/observability-tools.ts +0 -343
  127. package/src/observation-extractor.ts +0 -418
  128. package/src/page-deps.ts +0 -285
  129. package/src/permissions.ts +0 -422
  130. package/src/prompt-analyzer.ts +0 -332
  131. package/src/python/coupling-detector.ts +0 -125
  132. package/src/python/domain-enforcer.ts +0 -84
  133. package/src/python/impact-analyzer.ts +0 -96
  134. package/src/python/import-parser.ts +0 -244
  135. package/src/python/import-resolver.ts +0 -136
  136. package/src/python/migration-indexer.ts +0 -116
  137. package/src/python/migration-parser.ts +0 -332
  138. package/src/python/model-indexer.ts +0 -71
  139. package/src/python/model-parser.ts +0 -279
  140. package/src/python/route-indexer.ts +0 -59
  141. package/src/python/route-parser.ts +0 -317
  142. package/src/python-tools.ts +0 -630
  143. package/src/regression-detector.ts +0 -319
  144. package/src/rules.ts +0 -57
  145. package/src/schema-mapper.ts +0 -232
  146. package/src/security/adapter-origin.ts +0 -130
  147. package/src/security/adapter-verifier.ts +0 -319
  148. package/src/security/atomic-write.ts +0 -164
  149. package/src/security/fetcher.ts +0 -200
  150. package/src/security/install-tracking.ts +0 -319
  151. package/src/security/license-pubkey.generated.ts +0 -30
  152. package/src/security/license-response-verifier.ts +0 -149
  153. package/src/security/local-fingerprint.ts +0 -225
  154. package/src/security/manifest-cache.ts +0 -333
  155. package/src/security/manifest-schema.ts +0 -129
  156. package/src/security/registry-pubkey.generated.ts +0 -35
  157. package/src/security/telemetry.ts +0 -320
  158. package/src/security-scorer.ts +0 -405
  159. package/src/security-utils.ts +0 -133
  160. package/src/sentinel-db.ts +0 -580
  161. package/src/sentinel-scanner.ts +0 -406
  162. package/src/sentinel-tools.ts +0 -513
  163. package/src/sentinel-types.ts +0 -140
  164. package/src/server-dispatch.ts +0 -225
  165. package/src/server.ts +0 -107
  166. package/src/session-archiver.ts +0 -111
  167. package/src/session-state-generator.ts +0 -174
  168. package/src/team-knowledge.ts +0 -407
  169. package/src/tool-db-needs.ts +0 -232
  170. package/src/tools.ts +0 -1206
  171. package/src/transcript-parser.ts +0 -458
  172. package/src/trpc-index.ts +0 -215
  173. package/src/validate-features-runner.ts +0 -107
  174. package/src/validation-engine.ts +0 -358
  175. package/src/watch/daemon.ts +0 -385
  176. package/src/watch/lockfile-detector.ts +0 -65
  177. package/src/watch/paths.ts +0 -279
  178. package/src/watch/state.ts +0 -178
@@ -1,580 +0,0 @@
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
- import { sanitizeFts5Query } from './memory-db.ts';
27
- import { t } from './lib/sql-table-names.ts';
28
-
29
- // ============================================================
30
- // Sentinel: Feature Registry Data Access Layer
31
- // ============================================================
32
-
33
- const PROJECT_ROOT = getProjectRoot();
34
-
35
- function parsePortalScope(raw: string | null): string[] {
36
- if (!raw) return [];
37
- try {
38
- return JSON.parse(raw);
39
- } catch {
40
- return [];
41
- }
42
- }
43
-
44
- function toFeature(row: Record<string, unknown>): Feature {
45
- return {
46
- id: row.id as number,
47
- feature_key: row.feature_key as string,
48
- domain: row.domain as string,
49
- subdomain: (row.subdomain as string) || null,
50
- title: row.title as string,
51
- description: (row.description as string) || null,
52
- status: row.status as Feature['status'],
53
- priority: row.priority as Feature['priority'],
54
- portal_scope: parsePortalScope(row.portal_scope as string),
55
- created_at: row.created_at as string,
56
- updated_at: row.updated_at as string,
57
- removed_at: (row.removed_at as string) || null,
58
- removed_reason: (row.removed_reason as string) || null,
59
- };
60
- }
61
-
62
- // ============================================================
63
- // Core CRUD
64
- // ============================================================
65
-
66
- export function upsertFeature(db: Database.Database, input: FeatureInput): number {
67
- const existing = db.prepare(`SELECT id FROM ${t('sentinel')} WHERE feature_key = ?`).get(input.feature_key) as { id: number } | undefined;
68
-
69
- if (existing) {
70
- db.prepare(`
71
- UPDATE ${t('sentinel')} SET
72
- domain = ?, subdomain = ?, title = ?, description = ?,
73
- status = COALESCE(?, status), priority = COALESCE(?, priority),
74
- portal_scope = COALESCE(?, portal_scope),
75
- updated_at = datetime('now')
76
- WHERE id = ?
77
- `).run(
78
- input.domain,
79
- input.subdomain || null,
80
- input.title,
81
- input.description || null,
82
- input.status || null,
83
- input.priority || null,
84
- input.portal_scope ? JSON.stringify(input.portal_scope) : null,
85
- existing.id
86
- );
87
- return existing.id;
88
- }
89
-
90
- const result = db.prepare(`
91
- INSERT INTO ${t('sentinel')} (feature_key, domain, subdomain, title, description, status, priority, portal_scope)
92
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
93
- `).run(
94
- input.feature_key,
95
- input.domain,
96
- input.subdomain || null,
97
- input.title,
98
- input.description || null,
99
- input.status || 'active',
100
- input.priority || 'standard',
101
- JSON.stringify(input.portal_scope || [])
102
- );
103
-
104
- return Number(result.lastInsertRowid);
105
- }
106
-
107
- export function getFeature(db: Database.Database, featureKey: string): Feature | null {
108
- const row = db.prepare(`SELECT * FROM ${t('sentinel')} WHERE feature_key = ?`).get(featureKey) as Record<string, unknown> | undefined;
109
- return row ? toFeature(row) : null;
110
- }
111
-
112
- export function getFeatureById(db: Database.Database, id: number): Feature | null {
113
- const row = db.prepare(`SELECT * FROM ${t('sentinel')} WHERE id = ?`).get(id) as Record<string, unknown> | undefined;
114
- return row ? toFeature(row) : null;
115
- }
116
-
117
- // ============================================================
118
- // Search & Query
119
- // ============================================================
120
-
121
- export function searchFeatures(
122
- db: Database.Database,
123
- query: string,
124
- filters?: { domain?: string; subdomain?: string; status?: string; portal?: string; page_route?: string }
125
- ): FeatureWithCounts[] {
126
- let sql: string;
127
- const params: unknown[] = [];
128
-
129
- if (query && query.trim()) {
130
- // FTS5 search
131
- sql = `
132
- SELECT s.*, fts.rank,
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 ?
139
- `;
140
- params.push(sanitizeFts5Query(query));
141
- } else {
142
- sql = `
143
- SELECT s.*,
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
148
- WHERE 1=1
149
- `;
150
- }
151
-
152
- if (filters?.domain) {
153
- sql += ' AND s.domain = ?';
154
- params.push(filters.domain);
155
- }
156
- if (filters?.subdomain) {
157
- sql += ' AND s.subdomain = ?';
158
- params.push(filters.subdomain);
159
- }
160
- if (filters?.status) {
161
- sql += ' AND s.status = ?';
162
- params.push(filters.status);
163
- }
164
- if (filters?.portal) {
165
- sql += ` AND s.portal_scope LIKE ? ESCAPE '\\'`;
166
- const escapedPortal = filters.portal.replace(/[%_]/g, '\\$&');
167
- params.push(`%"${escapedPortal}"%`);
168
- }
169
- if (filters?.page_route) {
170
- sql += ` AND s.id IN (SELECT feature_id FROM ${t('sentinel_pages')} WHERE page_route = ?)`;
171
- params.push(filters.page_route);
172
- }
173
-
174
- sql += query && query.trim() ? ' ORDER BY fts.rank LIMIT 100' : ' ORDER BY s.domain, s.subdomain, s.feature_key LIMIT 100';
175
-
176
- const rows = db.prepare(sql).all(...params) as (Record<string, unknown>)[];
177
- return rows.map(row => ({
178
- ...toFeature(row),
179
- component_count: row.component_count as number,
180
- procedure_count: row.procedure_count as number,
181
- page_count: row.page_count as number,
182
- }));
183
- }
184
-
185
- export function getFeaturesByDomain(db: Database.Database, domain: string): Feature[] {
186
- const rows = db.prepare(`SELECT * FROM ${t('sentinel')} WHERE domain = ? ORDER BY subdomain, feature_key`).all(domain) as Record<string, unknown>[];
187
- return rows.map(toFeature);
188
- }
189
-
190
- export function getFeaturesByFile(db: Database.Database, filePath: string): Feature[] {
191
- const rows = db.prepare(`
192
- SELECT DISTINCT s.* FROM ${t('sentinel')} s
193
- JOIN ${t('sentinel_components')} c ON c.feature_id = s.id
194
- WHERE c.component_file = ?
195
- ORDER BY s.feature_key
196
- `).all(filePath) as Record<string, unknown>[];
197
- return rows.map(toFeature);
198
- }
199
-
200
- export function getFeaturesByRoute(db: Database.Database, route: string): Feature[] {
201
- const rows = db.prepare(`
202
- SELECT DISTINCT s.* FROM ${t('sentinel')} s
203
- JOIN ${t('sentinel_pages')} p ON p.feature_id = s.id
204
- WHERE p.page_route = ?
205
- ORDER BY s.feature_key
206
- `).all(route) as Record<string, unknown>[];
207
- return rows.map(toFeature);
208
- }
209
-
210
- // ============================================================
211
- // Feature Detail (full join)
212
- // ============================================================
213
-
214
- export function getFeatureDetail(db: Database.Database, featureKeyOrId: string | number): FeatureDetail | null {
215
- let feature: Feature | null;
216
- if (typeof featureKeyOrId === 'number') {
217
- feature = getFeatureById(db, featureKeyOrId);
218
- } else {
219
- feature = getFeature(db, featureKeyOrId);
220
- }
221
- if (!feature) return null;
222
-
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[];
228
-
229
- return { ...feature, components, procedures, pages, dependencies, changelog };
230
- }
231
-
232
- // ============================================================
233
- // Orphan & Impact Detection
234
- // ============================================================
235
-
236
- export function getOrphanedFeatures(db: Database.Database): Feature[] {
237
- // Active features with no living primary component files
238
- const features = db.prepare(`
239
- SELECT s.* FROM ${t('sentinel')} s
240
- WHERE s.status = 'active'
241
- AND NOT EXISTS (
242
- SELECT 1 FROM ${t('sentinel_components')} c
243
- WHERE c.feature_id = s.id AND c.is_primary = 1
244
- )
245
- ORDER BY s.domain, s.feature_key
246
- `).all() as Record<string, unknown>[];
247
- return features.map(toFeature);
248
- }
249
-
250
- export function getFeatureImpact(db: Database.Database, filePaths: string[]): ImpactReport {
251
- const fileSet = new Set(filePaths);
252
- const affectedFeatureIds = new Set<number>();
253
-
254
- // Find all features linked to these files
255
- for (const filePath of filePaths) {
256
- const links = db.prepare(
257
- `SELECT feature_id FROM ${t('sentinel_components')} WHERE component_file = ?`
258
- ).all(filePath) as { feature_id: number }[];
259
- for (const link of links) {
260
- affectedFeatureIds.add(link.feature_id);
261
- }
262
- }
263
-
264
- const orphaned: ImpactItem[] = [];
265
- const degraded: ImpactItem[] = [];
266
- const unaffected: ImpactItem[] = [];
267
-
268
- for (const featureId of affectedFeatureIds) {
269
- const feature = getFeatureById(db, featureId);
270
- if (!feature || feature.status !== 'active') continue;
271
-
272
- const allComponents = db.prepare(
273
- `SELECT component_file, is_primary FROM ${t('sentinel_components')} WHERE feature_id = ?`
274
- ).all(featureId) as { component_file: string; is_primary: number }[];
275
-
276
- const affected = allComponents.filter(c => fileSet.has(c.component_file));
277
- const remaining = allComponents.filter(c => !fileSet.has(c.component_file));
278
- const primaryAffected = affected.some(c => c.is_primary);
279
-
280
- const item: ImpactItem = {
281
- feature,
282
- affected_files: affected.map(c => c.component_file),
283
- remaining_files: remaining.map(c => c.component_file),
284
- status: 'unaffected',
285
- };
286
-
287
- if (primaryAffected && remaining.filter(c => c.is_primary).length === 0) {
288
- item.status = 'orphaned';
289
- orphaned.push(item);
290
- } else if (affected.length > 0) {
291
- item.status = 'degraded';
292
- degraded.push(item);
293
- } else {
294
- unaffected.push(item);
295
- }
296
- }
297
-
298
- const hasCriticalOrphans = orphaned.some(o => o.feature.priority === 'critical');
299
- const hasStandardOrphans = orphaned.some(o => o.feature.priority === 'standard');
300
-
301
- return {
302
- files_analyzed: filePaths,
303
- orphaned,
304
- degraded,
305
- unaffected,
306
- blocked: hasCriticalOrphans || hasStandardOrphans,
307
- block_reason: hasCriticalOrphans
308
- ? `BLOCKED: ${orphaned.length} features would be orphaned (includes critical features). Create migration plan first.`
309
- : hasStandardOrphans
310
- ? `BLOCKED: ${orphaned.length} standard features would be orphaned. Create migration plan first.`
311
- : null,
312
- };
313
- }
314
-
315
- // ============================================================
316
- // Links: Components, Procedures, Pages
317
- // ============================================================
318
-
319
- export function linkComponent(
320
- db: Database.Database,
321
- featureId: number,
322
- filePath: string,
323
- componentName: string | null,
324
- role: ComponentRole = 'implementation',
325
- isPrimary: boolean = false
326
- ): void {
327
- db.prepare(`
328
- INSERT OR REPLACE INTO ${t('sentinel_components')} (feature_id, component_file, component_name, role, is_primary)
329
- VALUES (?, ?, ?, ?, ?)
330
- `).run(featureId, filePath, componentName, role, isPrimary ? 1 : 0);
331
- }
332
-
333
- export function linkProcedure(
334
- db: Database.Database,
335
- featureId: number,
336
- routerName: string,
337
- procedureName: string,
338
- procedureType?: string
339
- ): void {
340
- db.prepare(`
341
- INSERT OR REPLACE INTO ${t('sentinel_procedures')} (feature_id, router_name, procedure_name, procedure_type)
342
- VALUES (?, ?, ?, ?)
343
- `).run(featureId, routerName, procedureName, procedureType || null);
344
- }
345
-
346
- export function linkPage(
347
- db: Database.Database,
348
- featureId: number,
349
- route: string,
350
- portal?: string
351
- ): void {
352
- db.prepare(`
353
- INSERT OR REPLACE INTO ${t('sentinel_pages')} (feature_id, page_route, portal)
354
- VALUES (?, ?, ?)
355
- `).run(featureId, route, portal || null);
356
- }
357
-
358
- // ============================================================
359
- // Changelog
360
- // ============================================================
361
-
362
- export function logChange(
363
- db: Database.Database,
364
- featureId: number,
365
- changeType: string,
366
- detail: string | null,
367
- commitHash?: string,
368
- changedBy: string = 'claude-code'
369
- ): void {
370
- db.prepare(`
371
- INSERT INTO ${t('sentinel_changelog')} (feature_id, change_type, changed_by, change_detail, commit_hash)
372
- VALUES (?, ?, ?, ?, ?)
373
- `).run(featureId, changeType, changedBy, detail, commitHash || null);
374
- }
375
-
376
- // ============================================================
377
- // Validation
378
- // ============================================================
379
-
380
- export function validateFeatures(db: Database.Database, domainFilter?: string): ValidationReport {
381
- let sql = `SELECT * FROM ${t('sentinel')} WHERE status = 'active'`;
382
- const params: unknown[] = [];
383
- if (domainFilter) {
384
- sql += ' AND domain = ?';
385
- params.push(domainFilter);
386
- }
387
- sql += ' ORDER BY domain, feature_key';
388
-
389
- const features = db.prepare(sql).all(...params) as Record<string, unknown>[];
390
- const details: ValidationItem[] = [];
391
- let alive = 0;
392
- let orphaned = 0;
393
- let degradedCount = 0;
394
-
395
- for (const row of features) {
396
- const feature = toFeature(row);
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[];
400
-
401
- const missingComponents: string[] = [];
402
- const missingProcedures: { router: string; procedure: string }[] = [];
403
- const missingPages: string[] = [];
404
-
405
- // Check component files exist on disk
406
- for (const comp of components) {
407
- const absPath = resolve(PROJECT_ROOT, comp.component_file);
408
- if (!existsSync(absPath)) {
409
- missingComponents.push(comp.component_file);
410
- }
411
- }
412
-
413
- // Check procedure files exist and contain the procedure
414
- for (const proc of procedures) {
415
- // Look for the router file based on convention
416
- const routerPath = resolve(PROJECT_ROOT, `src/server/api/routers/${proc.router_name}.ts`);
417
- if (!existsSync(routerPath)) {
418
- missingProcedures.push({ router: proc.router_name, procedure: proc.procedure_name });
419
- }
420
- }
421
-
422
- // Check page routes exist
423
- for (const page of pages) {
424
- const routeToPath = page.page_route
425
- .replace(/^\/(portal-[^/]+\/)?/, 'src/app/')
426
- .replace(/\/$/, '')
427
- + '/page.tsx';
428
- const absPath = resolve(PROJECT_ROOT, routeToPath);
429
- // Page route checking is approximate - don't flag as missing if path conversion is ambiguous
430
- if (page.page_route.startsWith('/') && !existsSync(absPath)) {
431
- // Try alternative path patterns
432
- const altPath = resolve(PROJECT_ROOT, `src/app${page.page_route}/page.tsx`);
433
- if (!existsSync(altPath)) {
434
- missingPages.push(page.page_route);
435
- }
436
- }
437
- }
438
-
439
- const hasMissing = missingComponents.length > 0 || missingProcedures.length > 0;
440
- const primaryMissing = components.filter(c => c.is_primary && missingComponents.includes(c.component_file)).length > 0;
441
- const allPrimaryMissing = components.filter(c => c.is_primary).length > 0 &&
442
- components.filter(c => c.is_primary).every(c => missingComponents.includes(c.component_file));
443
-
444
- let status: 'alive' | 'orphaned' | 'degraded';
445
- if (allPrimaryMissing || (components.length > 0 && missingComponents.length === components.length)) {
446
- status = 'orphaned';
447
- orphaned++;
448
- } else if (hasMissing) {
449
- status = 'degraded';
450
- degradedCount++;
451
- } else {
452
- status = 'alive';
453
- alive++;
454
- }
455
-
456
- details.push({
457
- feature,
458
- missing_components: missingComponents,
459
- missing_procedures: missingProcedures,
460
- missing_pages: missingPages,
461
- status,
462
- });
463
- }
464
-
465
- return { alive, orphaned, degraded: degradedCount, details };
466
- }
467
-
468
- // ============================================================
469
- // Parity Check (for rebuild scenarios)
470
- // ============================================================
471
-
472
- export function checkParity(db: Database.Database, oldFiles: string[], newFiles: string[]): ParityReport {
473
- const oldFileSet = new Set(oldFiles);
474
- const newFileSet = new Set(newFiles);
475
-
476
- // Find features linked to old files
477
- const oldFeatureIds = new Set<number>();
478
- for (const file of oldFiles) {
479
- const links = db.prepare(`SELECT feature_id FROM ${t('sentinel_components')} WHERE component_file = ?`).all(file) as { feature_id: number }[];
480
- for (const link of links) {
481
- oldFeatureIds.add(link.feature_id);
482
- }
483
- }
484
-
485
- // Find features linked to new files
486
- const newFeatureIds = new Set<number>();
487
- for (const file of newFiles) {
488
- const links = db.prepare(`SELECT feature_id FROM ${t('sentinel_components')} WHERE component_file = ?`).all(file) as { feature_id: number }[];
489
- for (const link of links) {
490
- newFeatureIds.add(link.feature_id);
491
- }
492
- }
493
-
494
- const done: ParityItem[] = [];
495
- const gaps: ParityItem[] = [];
496
- const newFeatures: ParityItem[] = [];
497
-
498
- // Features in old that are also in new = DONE
499
- // Features in old but NOT in new = GAP
500
- for (const fId of oldFeatureIds) {
501
- const feature = getFeatureById(db, fId);
502
- if (!feature) continue;
503
-
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 }[];
506
-
507
- const item: ParityItem = {
508
- feature_key: feature.feature_key,
509
- title: feature.title,
510
- status: newFeatureIds.has(fId) ? 'DONE' : 'GAP',
511
- old_files: oldComps.map(c => c.component_file),
512
- new_files: newComps.map(c => c.component_file),
513
- };
514
-
515
- if (item.status === 'DONE') {
516
- done.push(item);
517
- } else {
518
- gaps.push(item);
519
- }
520
- }
521
-
522
- // Features only in new = NEW
523
- for (const fId of newFeatureIds) {
524
- if (oldFeatureIds.has(fId)) continue;
525
- const feature = getFeatureById(db, fId);
526
- if (!feature) continue;
527
-
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 }[];
529
-
530
- newFeatures.push({
531
- feature_key: feature.feature_key,
532
- title: feature.title,
533
- status: 'NEW',
534
- old_files: [],
535
- new_files: newComps.map(c => c.component_file),
536
- });
537
- }
538
-
539
- const total = done.length + gaps.length;
540
- const parityPercentage = total > 0 ? Math.round((done.length / total) * 100) : 100;
541
-
542
- return { done, gaps, new_features: newFeatures, parity_percentage: parityPercentage };
543
- }
544
-
545
- // ============================================================
546
- // Bulk operations for scanner
547
- // ============================================================
548
-
549
- export function clearAutoDiscoveredFeatures(db: Database.Database): void {
550
- // Only clear features that were auto-discovered (not manually registered)
551
- // We use the changelog to distinguish: auto-discovered ones have changed_by = 'scanner'
552
- const autoIds = db.prepare(`
553
- SELECT DISTINCT feature_id FROM ${t('sentinel_changelog')}
554
- WHERE changed_by = 'scanner' AND change_type = 'created'
555
- `).all() as { feature_id: number }[];
556
-
557
- // Don't delete features that also have manual changes
558
- for (const { feature_id } of autoIds) {
559
- const hasManualChanges = db.prepare(`
560
- SELECT 1 FROM ${t('sentinel_changelog')}
561
- WHERE feature_id = ? AND changed_by != 'scanner'
562
- `).get(feature_id);
563
-
564
- if (!hasManualChanges) {
565
- db.prepare(`DELETE FROM ${t('sentinel')} WHERE id = ?`).run(feature_id);
566
- }
567
- }
568
- }
569
-
570
- export function bulkUpsertFeatures(db: Database.Database, features: FeatureInput[]): number {
571
- let count = 0;
572
- const upsert = db.transaction((items: FeatureInput[]) => {
573
- for (const item of items) {
574
- upsertFeature(db, item);
575
- count++;
576
- }
577
- });
578
- upsert(features);
579
- return count;
580
- }