@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.
- package/README.md +2 -2
- package/dist/hooks/cost-tracker.js +23 -35
- package/dist/hooks/post-edit-context.js +2 -2
- package/dist/hooks/post-tool-use.js +43 -58
- package/dist/hooks/pre-compact.js +23 -38
- package/dist/hooks/pre-delete-check.js +18 -31
- package/dist/hooks/quality-event.js +23 -35
- package/dist/hooks/session-end.js +62 -78
- package/dist/hooks/session-start.js +33 -42
- package/dist/hooks/user-prompt.js +23 -38
- package/package.json +8 -14
- package/src/adr-generator.ts +9 -2
- package/src/analytics.ts +9 -3
- package/src/audit-trail.ts +10 -3
- package/src/cloud-sync.ts +14 -18
- package/src/commands/init.ts +1 -5
- package/src/cost-tracker.ts +11 -6
- package/src/dependency-scorer.ts +9 -2
- package/src/docs-tools.ts +13 -10
- package/src/hooks/post-edit-context.ts +3 -3
- package/src/hooks/session-end.ts +3 -3
- package/src/hooks/session-start.ts +2 -2
- package/src/memory-db.ts +1351 -23
- package/src/memory-tools.ts +14 -15
- package/src/observability-tools.ts +13 -2
- package/src/prompt-analyzer.ts +9 -2
- package/src/regression-detector.ts +9 -3
- package/src/security-scorer.ts +9 -2
- package/src/sentinel-db.ts +43 -88
- package/src/sentinel-tools.ts +8 -11
- package/src/server.ts +1 -2
- package/src/team-knowledge.ts +9 -2
- package/src/tools.ts +771 -35
- package/src/validate-features-runner.ts +0 -1
- package/src/validation-engine.ts +9 -2
- package/dist/cli.js +0 -7890
- package/dist/server.js +0 -7008
- package/src/__tests__/adr-generator.test.ts +0 -260
- package/src/__tests__/analytics.test.ts +0 -282
- package/src/__tests__/audit-trail.test.ts +0 -382
- package/src/__tests__/backfill-sessions.test.ts +0 -690
- package/src/__tests__/cli.test.ts +0 -290
- package/src/__tests__/cloud-sync.test.ts +0 -261
- package/src/__tests__/config-sections.test.ts +0 -359
- package/src/__tests__/config.test.ts +0 -732
- package/src/__tests__/cost-tracker.test.ts +0 -348
- package/src/__tests__/db.test.ts +0 -177
- package/src/__tests__/dependency-scorer.test.ts +0 -325
- package/src/__tests__/docs-integration.test.ts +0 -178
- package/src/__tests__/docs-tools.test.ts +0 -199
- package/src/__tests__/domains.test.ts +0 -236
- package/src/__tests__/hooks.test.ts +0 -221
- package/src/__tests__/import-resolver.test.ts +0 -95
- package/src/__tests__/integration/path-traversal.test.ts +0 -134
- package/src/__tests__/integration/pricing-consistency.test.ts +0 -88
- package/src/__tests__/integration/tool-registration.test.ts +0 -146
- package/src/__tests__/memory-db.test.ts +0 -404
- package/src/__tests__/memory-enhancements.test.ts +0 -316
- package/src/__tests__/memory-tools.test.ts +0 -199
- package/src/__tests__/middleware-tree.test.ts +0 -177
- package/src/__tests__/observability-tools.test.ts +0 -595
- package/src/__tests__/observability.test.ts +0 -437
- package/src/__tests__/observation-extractor.test.ts +0 -167
- package/src/__tests__/page-deps.test.ts +0 -60
- package/src/__tests__/prompt-analyzer.test.ts +0 -298
- package/src/__tests__/regression-detector.test.ts +0 -295
- package/src/__tests__/rules.test.ts +0 -87
- package/src/__tests__/schema-mapper.test.ts +0 -29
- package/src/__tests__/security-scorer.test.ts +0 -238
- package/src/__tests__/security-utils.test.ts +0 -175
- package/src/__tests__/sentinel-db.test.ts +0 -491
- package/src/__tests__/sentinel-scanner.test.ts +0 -750
- package/src/__tests__/sentinel-tools.test.ts +0 -324
- package/src/__tests__/sentinel-types.test.ts +0 -750
- package/src/__tests__/server.test.ts +0 -452
- package/src/__tests__/session-archiver.test.ts +0 -524
- package/src/__tests__/session-state-generator.test.ts +0 -900
- package/src/__tests__/team-knowledge.test.ts +0 -327
- package/src/__tests__/tools.test.ts +0 -340
- package/src/__tests__/transcript-parser.test.ts +0 -195
- package/src/__tests__/trpc-index.test.ts +0 -25
- package/src/__tests__/validate-features-runner.test.ts +0 -517
- package/src/__tests__/validation-engine.test.ts +0 -300
- package/src/core-tools.ts +0 -685
- package/src/memory-queries.ts +0 -804
- package/src/memory-schema.ts +0 -546
- package/src/tool-helpers.ts +0 -41
|
@@ -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
|
-
});
|