@massu/core 0.1.1 → 0.1.2

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 (87) hide show
  1. package/README.md +2 -2
  2. package/dist/hooks/cost-tracker.js +23 -35
  3. package/dist/hooks/post-edit-context.js +2 -2
  4. package/dist/hooks/post-tool-use.js +43 -58
  5. package/dist/hooks/pre-compact.js +23 -38
  6. package/dist/hooks/pre-delete-check.js +18 -31
  7. package/dist/hooks/quality-event.js +23 -35
  8. package/dist/hooks/session-end.js +62 -78
  9. package/dist/hooks/session-start.js +33 -42
  10. package/dist/hooks/user-prompt.js +23 -38
  11. package/package.json +8 -14
  12. package/src/adr-generator.ts +9 -2
  13. package/src/analytics.ts +9 -3
  14. package/src/audit-trail.ts +10 -3
  15. package/src/cloud-sync.ts +14 -18
  16. package/src/commands/init.ts +1 -5
  17. package/src/cost-tracker.ts +11 -6
  18. package/src/dependency-scorer.ts +9 -2
  19. package/src/docs-tools.ts +13 -10
  20. package/src/hooks/post-edit-context.ts +3 -3
  21. package/src/hooks/session-end.ts +3 -3
  22. package/src/hooks/session-start.ts +2 -2
  23. package/src/memory-db.ts +1351 -23
  24. package/src/memory-tools.ts +14 -15
  25. package/src/observability-tools.ts +13 -2
  26. package/src/prompt-analyzer.ts +9 -2
  27. package/src/regression-detector.ts +9 -3
  28. package/src/security-scorer.ts +9 -2
  29. package/src/sentinel-db.ts +43 -88
  30. package/src/sentinel-tools.ts +8 -11
  31. package/src/server.ts +1 -2
  32. package/src/team-knowledge.ts +9 -2
  33. package/src/tools.ts +771 -35
  34. package/src/validate-features-runner.ts +0 -1
  35. package/src/validation-engine.ts +9 -2
  36. package/dist/cli.js +0 -7890
  37. package/dist/server.js +0 -7008
  38. package/src/__tests__/adr-generator.test.ts +0 -260
  39. package/src/__tests__/analytics.test.ts +0 -282
  40. package/src/__tests__/audit-trail.test.ts +0 -382
  41. package/src/__tests__/backfill-sessions.test.ts +0 -690
  42. package/src/__tests__/cli.test.ts +0 -290
  43. package/src/__tests__/cloud-sync.test.ts +0 -261
  44. package/src/__tests__/config-sections.test.ts +0 -359
  45. package/src/__tests__/config.test.ts +0 -732
  46. package/src/__tests__/cost-tracker.test.ts +0 -348
  47. package/src/__tests__/db.test.ts +0 -177
  48. package/src/__tests__/dependency-scorer.test.ts +0 -325
  49. package/src/__tests__/docs-integration.test.ts +0 -178
  50. package/src/__tests__/docs-tools.test.ts +0 -199
  51. package/src/__tests__/domains.test.ts +0 -236
  52. package/src/__tests__/hooks.test.ts +0 -221
  53. package/src/__tests__/import-resolver.test.ts +0 -95
  54. package/src/__tests__/integration/path-traversal.test.ts +0 -134
  55. package/src/__tests__/integration/pricing-consistency.test.ts +0 -88
  56. package/src/__tests__/integration/tool-registration.test.ts +0 -146
  57. package/src/__tests__/memory-db.test.ts +0 -404
  58. package/src/__tests__/memory-enhancements.test.ts +0 -316
  59. package/src/__tests__/memory-tools.test.ts +0 -199
  60. package/src/__tests__/middleware-tree.test.ts +0 -177
  61. package/src/__tests__/observability-tools.test.ts +0 -595
  62. package/src/__tests__/observability.test.ts +0 -437
  63. package/src/__tests__/observation-extractor.test.ts +0 -167
  64. package/src/__tests__/page-deps.test.ts +0 -60
  65. package/src/__tests__/prompt-analyzer.test.ts +0 -298
  66. package/src/__tests__/regression-detector.test.ts +0 -295
  67. package/src/__tests__/rules.test.ts +0 -87
  68. package/src/__tests__/schema-mapper.test.ts +0 -29
  69. package/src/__tests__/security-scorer.test.ts +0 -238
  70. package/src/__tests__/security-utils.test.ts +0 -175
  71. package/src/__tests__/sentinel-db.test.ts +0 -491
  72. package/src/__tests__/sentinel-scanner.test.ts +0 -750
  73. package/src/__tests__/sentinel-tools.test.ts +0 -324
  74. package/src/__tests__/sentinel-types.test.ts +0 -750
  75. package/src/__tests__/server.test.ts +0 -452
  76. package/src/__tests__/session-archiver.test.ts +0 -524
  77. package/src/__tests__/session-state-generator.test.ts +0 -900
  78. package/src/__tests__/team-knowledge.test.ts +0 -327
  79. package/src/__tests__/tools.test.ts +0 -340
  80. package/src/__tests__/transcript-parser.test.ts +0 -195
  81. package/src/__tests__/trpc-index.test.ts +0 -25
  82. package/src/__tests__/validate-features-runner.test.ts +0 -517
  83. package/src/__tests__/validation-engine.test.ts +0 -300
  84. package/src/core-tools.ts +0 -685
  85. package/src/memory-queries.ts +0 -804
  86. package/src/memory-schema.ts +0 -546
  87. package/src/tool-helpers.ts +0 -41
@@ -1,750 +0,0 @@
1
- // Copyright (c) 2026 Massu. All rights reserved.
2
- // Licensed under BSL 1.1 - see LICENSE file for details.
3
-
4
- import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
5
- import Database from 'better-sqlite3';
6
- import { runFeatureScan } from '../sentinel-scanner.ts';
7
- import type { ScanResult } from '../sentinel-scanner.ts';
8
-
9
- // Mock config — domains drive domain-inference logic
10
- vi.mock('../config.ts', () => ({
11
- getConfig: () => ({
12
- toolPrefix: 'massu',
13
- framework: { type: 'typescript', router: 'trpc', orm: 'prisma' },
14
- paths: { source: 'src', components: 'src/components' },
15
- domains: [
16
- {
17
- name: 'auth',
18
- routers: ['auth', 'user'],
19
- pages: ['/auth', '/login'],
20
- tables: [],
21
- allowedImportsFrom: [],
22
- },
23
- {
24
- name: 'orders',
25
- routers: ['orders', 'orderItems'],
26
- pages: ['/orders'],
27
- tables: [],
28
- allowedImportsFrom: [],
29
- },
30
- {
31
- name: 'billing',
32
- routers: ['billing', 'subscription'],
33
- pages: ['/billing'],
34
- tables: [],
35
- allowedImportsFrom: [],
36
- },
37
- ],
38
- }),
39
- getProjectRoot: () => '/test/project',
40
- }));
41
-
42
- // Mock fs so scanComponentExports does not touch the real filesystem
43
- vi.mock('fs', async () => {
44
- const actual = await vi.importActual<typeof import('fs')>('fs');
45
- return {
46
- ...actual,
47
- existsSync: vi.fn().mockReturnValue(false),
48
- readdirSync: vi.fn().mockReturnValue([]),
49
- readFileSync: vi.fn().mockReturnValue(''),
50
- statSync: vi.fn().mockReturnValue({ isDirectory: () => false }),
51
- };
52
- });
53
-
54
- // ============================================================
55
- // Shared DB helper
56
- // ============================================================
57
-
58
- function createTestDb(): Database.Database {
59
- const db = new Database(':memory:');
60
- db.pragma('journal_mode = WAL');
61
- db.pragma('foreign_keys = ON');
62
-
63
- db.exec(`
64
- CREATE TABLE IF NOT EXISTS massu_sentinel (
65
- id INTEGER PRIMARY KEY AUTOINCREMENT,
66
- feature_key TEXT UNIQUE NOT NULL,
67
- domain TEXT NOT NULL,
68
- subdomain TEXT,
69
- title TEXT NOT NULL,
70
- description TEXT,
71
- status TEXT NOT NULL DEFAULT 'active'
72
- CHECK(status IN ('planned', 'active', 'deprecated', 'removed')),
73
- priority TEXT DEFAULT 'standard'
74
- CHECK(priority IN ('critical', 'standard', 'nice-to-have')),
75
- portal_scope TEXT DEFAULT '[]',
76
- created_at TEXT DEFAULT (datetime('now')),
77
- updated_at TEXT DEFAULT (datetime('now')),
78
- removed_at TEXT,
79
- removed_reason TEXT
80
- );
81
-
82
- CREATE INDEX IF NOT EXISTS idx_sentinel_domain ON massu_sentinel(domain);
83
- CREATE INDEX IF NOT EXISTS idx_sentinel_status ON massu_sentinel(status);
84
- CREATE INDEX IF NOT EXISTS idx_sentinel_key ON massu_sentinel(feature_key);
85
-
86
- CREATE TABLE IF NOT EXISTS massu_sentinel_components (
87
- id INTEGER PRIMARY KEY AUTOINCREMENT,
88
- feature_id INTEGER NOT NULL REFERENCES massu_sentinel(id) ON DELETE CASCADE,
89
- component_file TEXT NOT NULL,
90
- component_name TEXT,
91
- role TEXT DEFAULT 'implementation'
92
- CHECK(role IN ('implementation', 'ui', 'data', 'utility')),
93
- is_primary BOOLEAN DEFAULT 0,
94
- UNIQUE(feature_id, component_file, component_name)
95
- );
96
-
97
- CREATE TABLE IF NOT EXISTS massu_sentinel_procedures (
98
- id INTEGER PRIMARY KEY AUTOINCREMENT,
99
- feature_id INTEGER NOT NULL REFERENCES massu_sentinel(id) ON DELETE CASCADE,
100
- router_name TEXT NOT NULL,
101
- procedure_name TEXT NOT NULL,
102
- procedure_type TEXT,
103
- UNIQUE(feature_id, router_name, procedure_name)
104
- );
105
-
106
- CREATE TABLE IF NOT EXISTS massu_sentinel_pages (
107
- id INTEGER PRIMARY KEY AUTOINCREMENT,
108
- feature_id INTEGER NOT NULL REFERENCES massu_sentinel(id) ON DELETE CASCADE,
109
- page_route TEXT NOT NULL,
110
- portal TEXT,
111
- UNIQUE(feature_id, page_route, portal)
112
- );
113
-
114
- CREATE TABLE IF NOT EXISTS massu_sentinel_deps (
115
- id INTEGER PRIMARY KEY AUTOINCREMENT,
116
- feature_id INTEGER NOT NULL REFERENCES massu_sentinel(id) ON DELETE CASCADE,
117
- depends_on_feature_id INTEGER NOT NULL REFERENCES massu_sentinel(id) ON DELETE CASCADE,
118
- dependency_type TEXT DEFAULT 'requires'
119
- CHECK(dependency_type IN ('requires', 'enhances', 'replaces')),
120
- UNIQUE(feature_id, depends_on_feature_id)
121
- );
122
-
123
- CREATE TABLE IF NOT EXISTS massu_sentinel_changelog (
124
- id INTEGER PRIMARY KEY AUTOINCREMENT,
125
- feature_id INTEGER NOT NULL REFERENCES massu_sentinel(id) ON DELETE CASCADE,
126
- change_type TEXT NOT NULL,
127
- changed_by TEXT,
128
- change_detail TEXT,
129
- commit_hash TEXT,
130
- created_at TEXT DEFAULT (datetime('now'))
131
- );
132
-
133
- CREATE INDEX IF NOT EXISTS idx_sentinel_components_file ON massu_sentinel_components(component_file);
134
- CREATE INDEX IF NOT EXISTS idx_sentinel_procedures_router ON massu_sentinel_procedures(router_name);
135
- CREATE INDEX IF NOT EXISTS idx_sentinel_pages_route ON massu_sentinel_pages(page_route);
136
- CREATE INDEX IF NOT EXISTS idx_sentinel_changelog_feature ON massu_sentinel_changelog(feature_id);
137
-
138
- CREATE TABLE IF NOT EXISTS massu_trpc_procedures (
139
- id INTEGER PRIMARY KEY AUTOINCREMENT,
140
- router_name TEXT NOT NULL,
141
- procedure_name TEXT NOT NULL,
142
- procedure_type TEXT,
143
- router_file TEXT
144
- );
145
-
146
- CREATE TABLE IF NOT EXISTS massu_page_deps (
147
- id INTEGER PRIMARY KEY AUTOINCREMENT,
148
- page_file TEXT NOT NULL,
149
- route TEXT NOT NULL,
150
- portal TEXT,
151
- components TEXT DEFAULT '[]',
152
- hooks TEXT DEFAULT '[]',
153
- routers TEXT DEFAULT '[]'
154
- );
155
- `);
156
-
157
- db.exec(`
158
- CREATE VIRTUAL TABLE IF NOT EXISTS massu_sentinel_fts USING fts5(
159
- feature_key, title, description, domain, subdomain,
160
- content=massu_sentinel, content_rowid=id
161
- );
162
- `);
163
-
164
- db.exec(`
165
- CREATE TRIGGER IF NOT EXISTS massu_sentinel_ai AFTER INSERT ON massu_sentinel BEGIN
166
- INSERT INTO massu_sentinel_fts(rowid, feature_key, title, description, domain, subdomain)
167
- VALUES (new.id, new.feature_key, new.title, new.description, new.domain, new.subdomain);
168
- END;
169
-
170
- CREATE TRIGGER IF NOT EXISTS massu_sentinel_ad AFTER DELETE ON massu_sentinel BEGIN
171
- INSERT INTO massu_sentinel_fts(massu_sentinel_fts, rowid, feature_key, title, description, domain, subdomain)
172
- VALUES ('delete', old.id, old.feature_key, old.title, old.description, old.domain, old.subdomain);
173
- END;
174
-
175
- CREATE TRIGGER IF NOT EXISTS massu_sentinel_au AFTER UPDATE ON massu_sentinel BEGIN
176
- INSERT INTO massu_sentinel_fts(massu_sentinel_fts, rowid, feature_key, title, description, domain, subdomain)
177
- VALUES ('delete', old.id, old.feature_key, old.title, old.description, old.domain, old.subdomain);
178
- INSERT INTO massu_sentinel_fts(rowid, feature_key, title, description, domain, subdomain)
179
- VALUES (new.id, new.feature_key, new.title, new.description, new.domain, new.subdomain);
180
- END;
181
- `);
182
-
183
- return db;
184
- }
185
-
186
- // ============================================================
187
- // Helpers
188
- // ============================================================
189
-
190
- function insertProcedure(
191
- db: Database.Database,
192
- routerName: string,
193
- procedureName: string,
194
- procedureType: string,
195
- routerFile: string
196
- ): void {
197
- db.prepare(`
198
- INSERT INTO massu_trpc_procedures (router_name, procedure_name, procedure_type, router_file)
199
- VALUES (?, ?, ?, ?)
200
- `).run(routerName, procedureName, procedureType, routerFile);
201
- }
202
-
203
- function insertPage(
204
- db: Database.Database,
205
- pageFile: string,
206
- route: string,
207
- portal: string | null,
208
- components: string[] = [],
209
- routers: string[] = []
210
- ): void {
211
- db.prepare(`
212
- INSERT INTO massu_page_deps (page_file, route, portal, components, hooks, routers)
213
- VALUES (?, ?, ?, ?, '[]', ?)
214
- `).run(pageFile, route, portal, JSON.stringify(components), JSON.stringify(routers));
215
- }
216
-
217
- function getFeatureRows(db: Database.Database): { feature_key: string; domain: string; subdomain: string | null; title: string }[] {
218
- return db.prepare('SELECT feature_key, domain, subdomain, title FROM massu_sentinel ORDER BY feature_key').all() as any[];
219
- }
220
-
221
- function getComponentRows(db: Database.Database, featureKey: string): { component_file: string; role: string; is_primary: number }[] {
222
- return db.prepare(`
223
- SELECT sc.component_file, sc.role, sc.is_primary
224
- FROM massu_sentinel_components sc
225
- JOIN massu_sentinel s ON s.id = sc.feature_id
226
- WHERE s.feature_key = ?
227
- ORDER BY sc.component_file
228
- `).all(featureKey) as any[];
229
- }
230
-
231
- function getProcedureRows(db: Database.Database, featureKey: string): { router_name: string; procedure_name: string; procedure_type: string }[] {
232
- return db.prepare(`
233
- SELECT sp.router_name, sp.procedure_name, sp.procedure_type
234
- FROM massu_sentinel_procedures sp
235
- JOIN massu_sentinel s ON s.id = sp.feature_id
236
- WHERE s.feature_key = ?
237
- ORDER BY sp.procedure_name
238
- `).all(featureKey) as any[];
239
- }
240
-
241
- function getPageRows(db: Database.Database, featureKey: string): { page_route: string; portal: string | null }[] {
242
- return db.prepare(`
243
- SELECT sp.page_route, sp.portal
244
- FROM massu_sentinel_pages sp
245
- JOIN massu_sentinel s ON s.id = sp.feature_id
246
- WHERE s.feature_key = ?
247
- ORDER BY sp.page_route
248
- `).all(featureKey) as any[];
249
- }
250
-
251
- function getChangelogRows(db: Database.Database, featureKey: string): { change_type: string; changed_by: string | null; change_detail: string | null }[] {
252
- return db.prepare(`
253
- SELECT sc.change_type, sc.changed_by, sc.change_detail
254
- FROM massu_sentinel_changelog sc
255
- JOIN massu_sentinel s ON s.id = sc.feature_id
256
- WHERE s.feature_key = ?
257
- ORDER BY sc.id
258
- `).all(featureKey) as any[];
259
- }
260
-
261
- // ============================================================
262
- // Tests
263
- // ============================================================
264
-
265
- describe('sentinel-scanner / runFeatureScan', () => {
266
- let db: Database.Database;
267
-
268
- beforeEach(() => {
269
- db = createTestDb();
270
- });
271
-
272
- afterEach(() => {
273
- db.close();
274
- });
275
-
276
- // ----------------------------------------------------------
277
- // ScanResult shape
278
- // ----------------------------------------------------------
279
-
280
- describe('ScanResult shape', () => {
281
- it('returns all required ScanResult fields when tables are empty', () => {
282
- const result: ScanResult = runFeatureScan(db);
283
- expect(typeof result.totalDiscovered).toBe('number');
284
- expect(typeof result.fromProcedures).toBe('number');
285
- expect(typeof result.fromPages).toBe('number');
286
- expect(typeof result.fromComponents).toBe('number');
287
- expect(typeof result.registered).toBe('number');
288
- });
289
-
290
- it('returns zeros when no tRPC procedures and no pages exist', () => {
291
- const result = runFeatureScan(db);
292
- expect(result.totalDiscovered).toBe(0);
293
- expect(result.fromProcedures).toBe(0);
294
- expect(result.fromPages).toBe(0);
295
- expect(result.fromComponents).toBe(0);
296
- expect(result.registered).toBe(0);
297
- });
298
-
299
- it('totalDiscovered equals registered when there are no key collisions', () => {
300
- insertProcedure(db, 'auth', 'getUser', 'query', 'src/server/api/routers/auth.ts');
301
- insertPage(db, 'src/app/dashboard/page.tsx', '/dashboard', 'internal');
302
- const result = runFeatureScan(db);
303
- expect(result.totalDiscovered).toBe(result.registered);
304
- });
305
- });
306
-
307
- // ----------------------------------------------------------
308
- // Feature discovery from tRPC procedures
309
- // ----------------------------------------------------------
310
-
311
- describe('Feature discovery from tRPC procedures', () => {
312
- it('discovers one feature per unique procedure', () => {
313
- insertProcedure(db, 'auth', 'getUser', 'query', 'src/server/api/routers/auth.ts');
314
- insertProcedure(db, 'auth', 'createUser', 'mutation', 'src/server/api/routers/auth.ts');
315
-
316
- const result = runFeatureScan(db);
317
- expect(result.fromProcedures).toBe(2);
318
- });
319
-
320
- it('registers features in massu_sentinel for each procedure', () => {
321
- insertProcedure(db, 'auth', 'getUser', 'query', 'src/server/api/routers/auth.ts');
322
-
323
- runFeatureScan(db);
324
-
325
- const rows = getFeatureRows(db);
326
- expect(rows.length).toBeGreaterThanOrEqual(1);
327
- const feature = rows.find(r => r.feature_key === 'auth.getUser');
328
- expect(feature).toBeTruthy();
329
- });
330
-
331
- it('links the router file as a data component', () => {
332
- insertProcedure(db, 'auth', 'getUser', 'query', 'src/server/api/routers/auth.ts');
333
-
334
- runFeatureScan(db);
335
-
336
- const comps = getComponentRows(db, 'auth.getUser');
337
- expect(comps.length).toBeGreaterThanOrEqual(1);
338
- const routerComp = comps.find(c => c.component_file === 'src/server/api/routers/auth.ts');
339
- expect(routerComp).toBeTruthy();
340
- expect(routerComp!.role).toBe('data');
341
- });
342
-
343
- it('does not duplicate router file component when multiple procedures share a router file', () => {
344
- insertProcedure(db, 'auth', 'getUser', 'query', 'src/server/api/routers/auth.ts');
345
- insertProcedure(db, 'auth', 'listUsers', 'query', 'src/server/api/routers/auth.ts');
346
-
347
- runFeatureScan(db);
348
-
349
- // Each feature key is distinct, so each has its own component entry
350
- const compsGet = getComponentRows(db, 'auth.getUser');
351
- const compslist = getComponentRows(db, 'auth.listUsers');
352
- expect(compsGet.length).toBe(1);
353
- expect(compslist.length).toBe(1);
354
- });
355
-
356
- it('links procedure details to the feature', () => {
357
- insertProcedure(db, 'orders', 'createOrder', 'mutation', 'src/server/api/routers/orders.ts');
358
-
359
- runFeatureScan(db);
360
-
361
- const procs = getProcedureRows(db, 'orders.createOrder');
362
- expect(procs.length).toBe(1);
363
- expect(procs[0].router_name).toBe('orders');
364
- expect(procs[0].procedure_name).toBe('createOrder');
365
- expect(procs[0].procedure_type).toBe('mutation');
366
- });
367
-
368
- it('generates a changelog entry per feature with changed_by = scanner', () => {
369
- insertProcedure(db, 'auth', 'getUser', 'query', 'src/server/api/routers/auth.ts');
370
-
371
- runFeatureScan(db);
372
-
373
- const changelog = getChangelogRows(db, 'auth.getUser');
374
- expect(changelog.length).toBeGreaterThanOrEqual(1);
375
- const entry = changelog.find(c => c.changed_by === 'scanner');
376
- expect(entry).toBeTruthy();
377
- expect(entry!.change_type).toBe('created');
378
- });
379
-
380
- it('handles multiple routers independently', () => {
381
- insertProcedure(db, 'auth', 'getUser', 'query', 'src/server/api/routers/auth.ts');
382
- insertProcedure(db, 'orders', 'listOrders', 'query', 'src/server/api/routers/orders.ts');
383
-
384
- const result = runFeatureScan(db);
385
- expect(result.fromProcedures).toBe(2);
386
-
387
- const rows = getFeatureRows(db);
388
- const keys = rows.map(r => r.feature_key);
389
- expect(keys).toContain('auth.getUser');
390
- expect(keys).toContain('orders.listOrders');
391
- });
392
- });
393
-
394
- // ----------------------------------------------------------
395
- // Feature discovery from page routes
396
- // ----------------------------------------------------------
397
-
398
- describe('Feature discovery from page routes', () => {
399
- it('discovers one feature per valid page route', () => {
400
- insertPage(db, 'src/app/dashboard/page.tsx', '/dashboard', 'internal');
401
- insertPage(db, 'src/app/settings/page.tsx', '/settings', 'internal');
402
-
403
- const result = runFeatureScan(db);
404
- expect(result.fromPages).toBe(2);
405
- });
406
-
407
- it('registers page feature with correct feature_key format', () => {
408
- insertPage(db, 'src/app/dashboard/page.tsx', '/dashboard', 'internal');
409
-
410
- runFeatureScan(db);
411
-
412
- const rows = getFeatureRows(db);
413
- const feature = rows.find(r => r.feature_key === 'page.dashboard');
414
- expect(feature).toBeTruthy();
415
- });
416
-
417
- it('sets page feature title to Page: <route>', () => {
418
- insertPage(db, 'src/app/settings/page.tsx', '/settings', 'internal');
419
-
420
- runFeatureScan(db);
421
-
422
- const rows = getFeatureRows(db);
423
- const feature = rows.find(r => r.feature_key === 'page.settings');
424
- expect(feature).toBeTruthy();
425
- expect(feature!.title).toBe('Page: /settings');
426
- });
427
-
428
- it('skips error pages', () => {
429
- insertPage(db, 'src/app/error.tsx', '/error', null);
430
-
431
- const result = runFeatureScan(db);
432
- expect(result.fromPages).toBe(0);
433
- });
434
-
435
- it('skips not-found pages', () => {
436
- insertPage(db, 'src/app/not-found.tsx', '/not-found', null);
437
-
438
- const result = runFeatureScan(db);
439
- expect(result.fromPages).toBe(0);
440
- });
441
-
442
- it('skips the root route /', () => {
443
- insertPage(db, 'src/app/page.tsx', '/', null);
444
-
445
- const result = runFeatureScan(db);
446
- expect(result.fromPages).toBe(0);
447
- });
448
-
449
- it('links the page file as a primary ui component', () => {
450
- insertPage(db, 'src/app/dashboard/page.tsx', '/dashboard', 'internal');
451
-
452
- runFeatureScan(db);
453
-
454
- const comps = getComponentRows(db, 'page.dashboard');
455
- const primary = comps.find(c => c.component_file === 'src/app/dashboard/page.tsx');
456
- expect(primary).toBeTruthy();
457
- expect(primary!.role).toBe('ui');
458
- expect(primary!.is_primary).toBe(1);
459
- });
460
-
461
- it('links additional components from page deps as non-primary ui', () => {
462
- insertPage(
463
- db,
464
- 'src/app/dashboard/page.tsx',
465
- '/dashboard',
466
- 'internal',
467
- ['src/components/Dashboard.tsx', 'src/components/Widget.tsx']
468
- );
469
-
470
- runFeatureScan(db);
471
-
472
- const comps = getComponentRows(db, 'page.dashboard');
473
- expect(comps.length).toBe(3); // page file + 2 components
474
- const nonPrimary = comps.filter(c => c.is_primary === 0);
475
- expect(nonPrimary.length).toBe(2);
476
- expect(nonPrimary.every(c => c.role === 'ui')).toBe(true);
477
- });
478
-
479
- it('links the page route to the feature', () => {
480
- insertPage(db, 'src/app/orders/page.tsx', '/orders', 'internal');
481
-
482
- runFeatureScan(db);
483
-
484
- const pages = getPageRows(db, 'page.orders');
485
- expect(pages.length).toBe(1);
486
- expect(pages[0].page_route).toBe('/orders');
487
- expect(pages[0].portal).toBe('internal');
488
- });
489
-
490
- it('handles nested routes with dynamic segments', () => {
491
- insertPage(db, 'src/app/orders/[id]/page.tsx', '/orders/[id]', 'internal');
492
-
493
- runFeatureScan(db);
494
-
495
- const rows = getFeatureRows(db);
496
- // Feature key replaces [id] with _id_
497
- const feature = rows.find(r => r.feature_key.includes('orders'));
498
- expect(feature).toBeTruthy();
499
- });
500
-
501
- it('handles routes with portal = null', () => {
502
- insertPage(db, 'src/app/public/page.tsx', '/public', null);
503
-
504
- runFeatureScan(db);
505
-
506
- const rows = getFeatureRows(db);
507
- const feature = rows.find(r => r.feature_key === 'page.public');
508
- expect(feature).toBeTruthy();
509
- });
510
- });
511
-
512
- // ----------------------------------------------------------
513
- // Feature discovery from component annotations (fs mocked away)
514
- // ----------------------------------------------------------
515
-
516
- describe('Feature discovery from component annotations', () => {
517
- it('reports zero fromComponents when filesystem is empty (mocked)', () => {
518
- const result = runFeatureScan(db);
519
- expect(result.fromComponents).toBe(0);
520
- });
521
- });
522
-
523
- // ----------------------------------------------------------
524
- // Domain inference
525
- // ----------------------------------------------------------
526
-
527
- describe('Domain inference from file paths', () => {
528
- it('infers "auth" domain from router file containing "auth"', () => {
529
- insertProcedure(db, 'auth', 'getUser', 'query', 'src/server/api/routers/auth.ts');
530
-
531
- runFeatureScan(db);
532
-
533
- const rows = getFeatureRows(db);
534
- const feature = rows.find(r => r.feature_key === 'auth.getUser');
535
- expect(feature).toBeTruthy();
536
- expect(feature!.domain).toBe('auth');
537
- });
538
-
539
- it('infers "orders" domain from router file containing "orders"', () => {
540
- insertProcedure(db, 'orders', 'listOrders', 'query', 'src/server/api/routers/orders.ts');
541
-
542
- runFeatureScan(db);
543
-
544
- const rows = getFeatureRows(db);
545
- const feature = rows.find(r => r.feature_key === 'orders.listOrders');
546
- expect(feature).toBeTruthy();
547
- expect(feature!.domain).toBe('orders');
548
- });
549
-
550
- it('falls back to "system" domain when no domain matches the file path', () => {
551
- insertProcedure(db, 'misc', 'ping', 'query', 'src/server/api/routers/misc.ts');
552
-
553
- runFeatureScan(db);
554
-
555
- const rows = getFeatureRows(db);
556
- const feature = rows.find(r => r.feature_key === 'misc.ping');
557
- expect(feature).toBeTruthy();
558
- expect(feature!.domain).toBe('system');
559
- });
560
-
561
- it('infers "auth" domain from page route containing "auth" keyword', () => {
562
- // Insert a route whose path includes a known domain name word
563
- insertPage(db, 'src/app/auth/login/page.tsx', '/auth/login', 'internal');
564
-
565
- runFeatureScan(db);
566
-
567
- const rows = getFeatureRows(db);
568
- const feature = rows.find(r => r.feature_key === 'page.auth.login');
569
- expect(feature).toBeTruthy();
570
- expect(feature!.domain).toBe('auth');
571
- });
572
-
573
- it('falls back to "system" for routes that do not match any domain', () => {
574
- insertPage(db, 'src/app/about/page.tsx', '/about', null);
575
-
576
- runFeatureScan(db);
577
-
578
- const rows = getFeatureRows(db);
579
- const feature = rows.find(r => r.feature_key === 'page.about');
580
- expect(feature).toBeTruthy();
581
- expect(feature!.domain).toBe('system');
582
- });
583
- });
584
-
585
- // ----------------------------------------------------------
586
- // Kebab-to-title conversion (tested via generated titles)
587
- // ----------------------------------------------------------
588
-
589
- describe('Kebab-to-title conversion in generated feature titles', () => {
590
- it('converts simple kebab router name to title case in feature title', () => {
591
- // Router 'auth' → subdomain 'auth' → title includes 'Auth'
592
- insertProcedure(db, 'auth', 'getUser', 'query', 'src/server/api/routers/auth.ts');
593
-
594
- runFeatureScan(db);
595
-
596
- const rows = getFeatureRows(db);
597
- const feature = rows.find(r => r.feature_key === 'auth.getUser');
598
- expect(feature).toBeTruthy();
599
- // Title should be 'Auth - Get User' or similar capitalised form
600
- expect(feature!.title).toMatch(/Auth/);
601
- });
602
-
603
- it('converts camelCase router to kebab subdomain correctly', () => {
604
- // orderItems router → subdomain: 'order-items'
605
- insertProcedure(db, 'orderItems', 'listItems', 'query', 'src/server/api/routers/orderItems.ts');
606
-
607
- runFeatureScan(db);
608
-
609
- const rows = getFeatureRows(db);
610
- const feature = rows.find(r => r.feature_key === 'order-items.listItems');
611
- expect(feature).toBeTruthy();
612
- expect(feature!.subdomain).toBe('order-items');
613
- });
614
-
615
- it('converts procedure name with camelCase to readable title words', () => {
616
- insertProcedure(db, 'billing', 'createSubscription', 'mutation', 'src/server/api/routers/billing.ts');
617
-
618
- runFeatureScan(db);
619
-
620
- const rows = getFeatureRows(db);
621
- const feature = rows.find(r => r.feature_key === 'billing.createSubscription');
622
- expect(feature).toBeTruthy();
623
- // Title contains capitalised versions from kebabToTitle applied to the procedure name
624
- expect(feature!.title).toBeTruthy();
625
- expect(feature!.title.length).toBeGreaterThan(0);
626
- });
627
-
628
- it('generates page feature title starting with "Page:"', () => {
629
- insertPage(db, 'src/app/settings/page.tsx', '/settings', 'internal');
630
-
631
- runFeatureScan(db);
632
-
633
- const rows = getFeatureRows(db);
634
- const feature = rows.find(r => r.feature_key === 'page.settings');
635
- expect(feature!.title).toMatch(/^Page:/);
636
- });
637
- });
638
-
639
- // ----------------------------------------------------------
640
- // Merge priority (components > pages > procedures)
641
- // ----------------------------------------------------------
642
-
643
- describe('Feature merge priority', () => {
644
- it('page feature overwrites procedure feature with same key', () => {
645
- // This is unlikely in practice but tests the merge order
646
- // Insert a page whose feature_key would collide with a procedure feature
647
- // The page scanner produces 'page.<route>', procedure scanner 'router.proc'
648
- // so we test indirectly: fromProcedures + fromPages are both counted correctly
649
- insertProcedure(db, 'auth', 'getUser', 'query', 'src/server/api/routers/auth.ts');
650
- insertPage(db, 'src/app/dashboard/page.tsx', '/dashboard', 'internal');
651
-
652
- const result = runFeatureScan(db);
653
- // Both are distinct keys: 'auth.getUser' and 'page.dashboard'
654
- expect(result.totalDiscovered).toBe(2);
655
- expect(result.registered).toBe(2);
656
- });
657
-
658
- it('registered count equals totalDiscovered for distinct keys', () => {
659
- insertProcedure(db, 'auth', 'getUser', 'query', 'src/server/api/routers/auth.ts');
660
- insertProcedure(db, 'auth', 'createUser', 'mutation', 'src/server/api/routers/auth.ts');
661
- insertPage(db, 'src/app/profile/page.tsx', '/profile', 'internal');
662
-
663
- const result = runFeatureScan(db);
664
- expect(result.totalDiscovered).toBe(result.registered);
665
- });
666
- });
667
-
668
- // ----------------------------------------------------------
669
- // Edge cases
670
- // ----------------------------------------------------------
671
-
672
- describe('Edge cases', () => {
673
- it('handles empty massu_trpc_procedures gracefully', () => {
674
- expect(() => runFeatureScan(db)).not.toThrow();
675
- });
676
-
677
- it('handles empty massu_page_deps gracefully', () => {
678
- expect(() => runFeatureScan(db)).not.toThrow();
679
- });
680
-
681
- it('is idempotent: running scan twice does not throw (upserts on second run)', () => {
682
- insertProcedure(db, 'auth', 'getUser', 'query', 'src/server/api/routers/auth.ts');
683
-
684
- expect(() => {
685
- runFeatureScan(db);
686
- runFeatureScan(db);
687
- }).not.toThrow();
688
- });
689
-
690
- it('second scan does not duplicate features', () => {
691
- insertProcedure(db, 'auth', 'getUser', 'query', 'src/server/api/routers/auth.ts');
692
-
693
- runFeatureScan(db);
694
- runFeatureScan(db);
695
-
696
- const rows = db.prepare('SELECT COUNT(*) as cnt FROM massu_sentinel WHERE feature_key = ?').get('auth.getUser') as { cnt: number };
697
- expect(rows.cnt).toBe(1);
698
- });
699
-
700
- it('handles a procedure with an empty router_file path', () => {
701
- insertProcedure(db, 'misc', 'healthCheck', 'query', '');
702
-
703
- expect(() => runFeatureScan(db)).not.toThrow();
704
-
705
- const rows = getFeatureRows(db);
706
- const feature = rows.find(r => r.feature_key === 'misc.healthCheck');
707
- expect(feature).toBeTruthy();
708
- });
709
-
710
- it('handles page with empty components list', () => {
711
- insertPage(db, 'src/app/empty/page.tsx', '/empty', null, []);
712
-
713
- runFeatureScan(db);
714
-
715
- const comps = getComponentRows(db, 'page.empty');
716
- // Only the page file itself should be linked
717
- expect(comps.length).toBe(1);
718
- expect(comps[0].component_file).toBe('src/app/empty/page.tsx');
719
- });
720
-
721
- it('returns fromProcedures count reflecting unique feature keys, not raw procedure count', () => {
722
- // Two procedures for the same router → two separate feature keys
723
- insertProcedure(db, 'auth', 'getUser', 'query', 'src/server/api/routers/auth.ts');
724
- insertProcedure(db, 'auth', 'deleteUser', 'mutation', 'src/server/api/routers/auth.ts');
725
-
726
- const result = runFeatureScan(db);
727
- expect(result.fromProcedures).toBe(2);
728
- });
729
-
730
- it('page feature key replaces slashes with dots and strips leading/trailing dots', () => {
731
- insertPage(db, 'src/app/admin/users/page.tsx', '/admin/users', 'internal');
732
-
733
- runFeatureScan(db);
734
-
735
- const rows = getFeatureRows(db);
736
- const feature = rows.find(r => r.feature_key === 'page.admin.users');
737
- expect(feature).toBeTruthy();
738
- });
739
-
740
- it('page feature key handles dynamic route segment notation', () => {
741
- insertPage(db, 'src/app/products/[id]/page.tsx', '/products/[id]', 'internal');
742
-
743
- runFeatureScan(db);
744
-
745
- const rows = getFeatureRows(db);
746
- const feature = rows.find(r => r.feature_key === 'page.products._id_');
747
- expect(feature).toBeTruthy();
748
- });
749
- });
750
- });