@massu/core 0.1.0 → 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/LICENSE +71 -0
- package/README.md +2 -2
- package/dist/hooks/cost-tracker.js +149 -11527
- package/dist/hooks/post-edit-context.js +127 -11493
- package/dist/hooks/post-tool-use.js +169 -11550
- package/dist/hooks/pre-compact.js +149 -11530
- package/dist/hooks/pre-delete-check.js +144 -11523
- package/dist/hooks/quality-event.js +149 -11527
- package/dist/hooks/session-end.js +188 -11570
- package/dist/hooks/session-start.js +159 -11534
- package/dist/hooks/user-prompt.js +149 -11530
- package/package.json +14 -19
- package/src/adr-generator.ts +292 -0
- package/src/analytics.ts +373 -0
- package/src/audit-trail.ts +450 -0
- package/src/backfill-sessions.ts +180 -0
- package/src/cli.ts +105 -0
- package/src/cloud-sync.ts +190 -0
- package/src/commands/doctor.ts +300 -0
- package/src/commands/init.ts +395 -0
- package/src/commands/install-hooks.ts +26 -0
- package/src/config.ts +357 -0
- package/src/cost-tracker.ts +355 -0
- package/src/db.ts +233 -0
- package/src/dependency-scorer.ts +337 -0
- package/src/docs-map.json +100 -0
- package/src/docs-tools.ts +517 -0
- package/src/domains.ts +181 -0
- package/src/hooks/cost-tracker.ts +66 -0
- package/src/hooks/intent-suggester.ts +131 -0
- package/src/hooks/post-edit-context.ts +91 -0
- package/src/hooks/post-tool-use.ts +175 -0
- package/src/hooks/pre-compact.ts +146 -0
- package/src/hooks/pre-delete-check.ts +153 -0
- package/src/hooks/quality-event.ts +127 -0
- package/src/hooks/security-gate.ts +121 -0
- package/src/hooks/session-end.ts +467 -0
- package/src/hooks/session-start.ts +210 -0
- package/src/hooks/user-prompt.ts +91 -0
- package/src/import-resolver.ts +224 -0
- package/src/memory-db.ts +1376 -0
- package/src/memory-tools.ts +391 -0
- package/src/middleware-tree.ts +70 -0
- package/src/observability-tools.ts +343 -0
- package/src/observation-extractor.ts +411 -0
- package/src/page-deps.ts +283 -0
- package/src/prompt-analyzer.ts +332 -0
- package/src/regression-detector.ts +319 -0
- package/src/rules.ts +57 -0
- package/src/schema-mapper.ts +232 -0
- package/src/security-scorer.ts +405 -0
- package/src/security-utils.ts +133 -0
- package/src/sentinel-db.ts +578 -0
- package/src/sentinel-scanner.ts +405 -0
- package/src/sentinel-tools.ts +512 -0
- package/src/sentinel-types.ts +140 -0
- package/src/server.ts +189 -0
- package/src/session-archiver.ts +112 -0
- package/src/session-state-generator.ts +174 -0
- package/src/team-knowledge.ts +407 -0
- package/src/tools.ts +847 -0
- package/src/transcript-parser.ts +458 -0
- package/src/trpc-index.ts +214 -0
- package/src/validate-features-runner.ts +106 -0
- package/src/validation-engine.ts +358 -0
- package/dist/cli.js +0 -7890
- package/dist/server.js +0 -7008
|
@@ -0,0 +1,405 @@
|
|
|
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 { readFileSync, existsSync, readdirSync, statSync } from 'fs';
|
|
6
|
+
import { resolve, join, basename, dirname, relative } from 'path';
|
|
7
|
+
import {
|
|
8
|
+
upsertFeature,
|
|
9
|
+
linkComponent,
|
|
10
|
+
linkProcedure,
|
|
11
|
+
linkPage,
|
|
12
|
+
logChange,
|
|
13
|
+
} from './sentinel-db.ts';
|
|
14
|
+
import type { FeatureInput, FeaturePriority } from './sentinel-types.ts';
|
|
15
|
+
import { getConfig, getProjectRoot } from './config.ts';
|
|
16
|
+
|
|
17
|
+
// ============================================================
|
|
18
|
+
// Sentinel: Feature Auto-Discovery Scanner
|
|
19
|
+
// ============================================================
|
|
20
|
+
|
|
21
|
+
interface DiscoveredFeature extends FeatureInput {
|
|
22
|
+
components: { file: string; name: string | null; role: 'implementation' | 'ui' | 'data' | 'utility'; isPrimary: boolean }[];
|
|
23
|
+
procedures: { router: string; procedure: string; type: string }[];
|
|
24
|
+
pages: { route: string; portal: string | null }[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ============================================================
|
|
28
|
+
// Domain inference from file path
|
|
29
|
+
// ============================================================
|
|
30
|
+
|
|
31
|
+
function inferDomain(filePath: string): string {
|
|
32
|
+
const domains = getConfig().domains;
|
|
33
|
+
const path = filePath.toLowerCase();
|
|
34
|
+
|
|
35
|
+
// Try to match against configured domain page patterns and router patterns
|
|
36
|
+
for (const domain of domains) {
|
|
37
|
+
const domainLower = domain.name.toLowerCase();
|
|
38
|
+
// Check if any router name appears in the path
|
|
39
|
+
for (const router of domain.routers) {
|
|
40
|
+
const routerLower = router.replace(/\*/g, '').toLowerCase();
|
|
41
|
+
if (routerLower && path.includes(routerLower)) {
|
|
42
|
+
return domainLower;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// Check if domain name keyword appears in the path
|
|
46
|
+
const nameWords = domainLower.split(/[\/\s]+/);
|
|
47
|
+
for (const word of nameWords) {
|
|
48
|
+
if (word.length > 2 && path.includes('/' + word + '/')) {
|
|
49
|
+
return domainLower;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return 'system';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function inferSubdomain(routerName: string, procedureName: string): string {
|
|
58
|
+
// Convert camelCase router to kebab-case subdomain
|
|
59
|
+
return routerName.replace(/([A-Z])/g, '-$1').toLowerCase().replace(/^-/, '');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function kebabToTitle(kebab: string): string {
|
|
63
|
+
return kebab
|
|
64
|
+
.split(/[-_.]/)
|
|
65
|
+
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
|
|
66
|
+
.join(' ');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ============================================================
|
|
70
|
+
// @feature annotation parser
|
|
71
|
+
// ============================================================
|
|
72
|
+
|
|
73
|
+
interface FeatureAnnotation {
|
|
74
|
+
featureKey: string;
|
|
75
|
+
title?: string;
|
|
76
|
+
priority?: string;
|
|
77
|
+
description?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function parseFeatureAnnotations(source: string): FeatureAnnotation[] {
|
|
81
|
+
const annotations: FeatureAnnotation[] = [];
|
|
82
|
+
const regex = /@feature\s+([\w.-]+)/g;
|
|
83
|
+
const titleRegex = /@feature-title\s+(.+)/g;
|
|
84
|
+
const priorityRegex = /@feature-priority\s+(\w+)/g;
|
|
85
|
+
|
|
86
|
+
let match;
|
|
87
|
+
while ((match = regex.exec(source)) !== null) {
|
|
88
|
+
const annotation: FeatureAnnotation = { featureKey: match[1] };
|
|
89
|
+
|
|
90
|
+
// Look for companion annotations in nearby text
|
|
91
|
+
const contextStart = Math.max(0, match.index - 200);
|
|
92
|
+
const contextEnd = Math.min(source.length, match.index + 300);
|
|
93
|
+
const context = source.substring(contextStart, contextEnd);
|
|
94
|
+
|
|
95
|
+
const titleMatch = /@feature-title\s+(.+)/.exec(context);
|
|
96
|
+
if (titleMatch) annotation.title = titleMatch[1].trim();
|
|
97
|
+
|
|
98
|
+
const priorityMatch = /@feature-priority\s+(\w+)/.exec(context);
|
|
99
|
+
if (priorityMatch) annotation.priority = priorityMatch[1].trim();
|
|
100
|
+
|
|
101
|
+
annotations.push(annotation);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return annotations;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ============================================================
|
|
108
|
+
// Scanner: tRPC Procedures -> Features
|
|
109
|
+
// ============================================================
|
|
110
|
+
|
|
111
|
+
function scanTrpcProcedures(dataDb: Database.Database): DiscoveredFeature[] {
|
|
112
|
+
const features: DiscoveredFeature[] = [];
|
|
113
|
+
const featureMap = new Map<string, DiscoveredFeature>();
|
|
114
|
+
|
|
115
|
+
const procedures = dataDb.prepare(`
|
|
116
|
+
SELECT router_name, procedure_name, procedure_type, router_file
|
|
117
|
+
FROM massu_trpc_procedures
|
|
118
|
+
ORDER BY router_name, procedure_name
|
|
119
|
+
`).all() as { router_name: string; procedure_name: string; procedure_type: string; router_file: string }[];
|
|
120
|
+
|
|
121
|
+
for (const proc of procedures) {
|
|
122
|
+
const subdomain = inferSubdomain(proc.router_name, proc.procedure_name);
|
|
123
|
+
const domain = inferDomain(proc.router_file);
|
|
124
|
+
|
|
125
|
+
// Group procedures by router into features
|
|
126
|
+
const featureKey = `${subdomain}.${proc.procedure_name}`;
|
|
127
|
+
const routerFeatureKey = `${subdomain}.crud`;
|
|
128
|
+
|
|
129
|
+
// Individual procedure-level feature
|
|
130
|
+
if (!featureMap.has(featureKey)) {
|
|
131
|
+
featureMap.set(featureKey, {
|
|
132
|
+
feature_key: featureKey,
|
|
133
|
+
domain,
|
|
134
|
+
subdomain,
|
|
135
|
+
title: `${kebabToTitle(subdomain)} - ${kebabToTitle(proc.procedure_name)}`,
|
|
136
|
+
status: 'active',
|
|
137
|
+
priority: 'standard',
|
|
138
|
+
components: [],
|
|
139
|
+
procedures: [],
|
|
140
|
+
pages: [],
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const feature = featureMap.get(featureKey)!;
|
|
145
|
+
feature.procedures.push({
|
|
146
|
+
router: proc.router_name,
|
|
147
|
+
procedure: proc.procedure_name,
|
|
148
|
+
type: proc.procedure_type,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// Link the router file as a component
|
|
152
|
+
if (!feature.components.some(c => c.file === proc.router_file)) {
|
|
153
|
+
feature.components.push({
|
|
154
|
+
file: proc.router_file,
|
|
155
|
+
name: null,
|
|
156
|
+
role: 'data',
|
|
157
|
+
isPrimary: false,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return Array.from(featureMap.values());
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ============================================================
|
|
166
|
+
// Scanner: Page Routes -> Features
|
|
167
|
+
// ============================================================
|
|
168
|
+
|
|
169
|
+
function scanPageRoutes(dataDb: Database.Database): DiscoveredFeature[] {
|
|
170
|
+
const features: DiscoveredFeature[] = [];
|
|
171
|
+
|
|
172
|
+
const pages = dataDb.prepare(`
|
|
173
|
+
SELECT page_file, route, portal, components, hooks, routers
|
|
174
|
+
FROM massu_page_deps
|
|
175
|
+
ORDER BY route
|
|
176
|
+
`).all() as { page_file: string; route: string; portal: string; components: string; hooks: string; routers: string }[];
|
|
177
|
+
|
|
178
|
+
for (const page of pages) {
|
|
179
|
+
const domain = inferDomain(page.route);
|
|
180
|
+
const routeParts = page.route.split('/').filter(Boolean);
|
|
181
|
+
const subdomain = routeParts.length > 1 ? routeParts.slice(0, 2).join('-') : routeParts[0] || 'root';
|
|
182
|
+
|
|
183
|
+
// Skip layout pages, error pages, etc.
|
|
184
|
+
if (page.route.includes('error') || page.route.includes('not-found') || page.route === '/') continue;
|
|
185
|
+
|
|
186
|
+
const featureKey = `page.${page.route.replace(/\//g, '.').replace(/^\.|\.$/g, '').replace(/\[(\w+)\]/g, '_$1_')}`;
|
|
187
|
+
|
|
188
|
+
const components = JSON.parse(page.components || '[]') as string[];
|
|
189
|
+
const routers = JSON.parse(page.routers || '[]') as string[];
|
|
190
|
+
|
|
191
|
+
const feature: DiscoveredFeature = {
|
|
192
|
+
feature_key: featureKey,
|
|
193
|
+
domain,
|
|
194
|
+
subdomain: subdomain.replace(/\//g, '-'),
|
|
195
|
+
title: `Page: ${page.route}`,
|
|
196
|
+
status: 'active',
|
|
197
|
+
priority: 'standard',
|
|
198
|
+
portal_scope: [page.portal],
|
|
199
|
+
components: [
|
|
200
|
+
{ file: page.page_file, name: null, role: 'ui', isPrimary: true },
|
|
201
|
+
...components.map(c => ({ file: c, name: null as string | null, role: 'ui' as const, isPrimary: false })),
|
|
202
|
+
],
|
|
203
|
+
procedures: [],
|
|
204
|
+
pages: [{ route: page.route, portal: page.portal }],
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
features.push(feature);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return features;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ============================================================
|
|
214
|
+
// Scanner: Component Exports -> Features
|
|
215
|
+
// ============================================================
|
|
216
|
+
|
|
217
|
+
function scanComponentExports(dataDb: Database.Database): DiscoveredFeature[] {
|
|
218
|
+
const features: DiscoveredFeature[] = [];
|
|
219
|
+
|
|
220
|
+
// Scan component directories for interactive features
|
|
221
|
+
const config = getConfig();
|
|
222
|
+
const projectRoot = getProjectRoot();
|
|
223
|
+
const componentsBase = config.paths.components ?? (config.paths.source + '/components');
|
|
224
|
+
|
|
225
|
+
// Build component dirs from domain names + a generic scan
|
|
226
|
+
const componentDirs: string[] = [];
|
|
227
|
+
const basePath = resolve(projectRoot, componentsBase);
|
|
228
|
+
if (existsSync(basePath)) {
|
|
229
|
+
try {
|
|
230
|
+
const entries = readdirSync(basePath, { withFileTypes: true });
|
|
231
|
+
for (const entry of entries) {
|
|
232
|
+
if (entry.isDirectory()) {
|
|
233
|
+
componentDirs.push(componentsBase + '/' + entry.name);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
} catch {
|
|
237
|
+
// Skip if unreadable
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
for (const dir of componentDirs) {
|
|
242
|
+
const absDir = resolve(projectRoot, dir);
|
|
243
|
+
if (!existsSync(absDir)) continue;
|
|
244
|
+
|
|
245
|
+
const files = walkDir(absDir).filter(f => f.endsWith('.tsx') || f.endsWith('.ts'));
|
|
246
|
+
for (const file of files) {
|
|
247
|
+
const relPath = relative(projectRoot, file);
|
|
248
|
+
const source = readFileSync(file, 'utf-8');
|
|
249
|
+
|
|
250
|
+
// Parse @feature annotations
|
|
251
|
+
const annotations = parseFeatureAnnotations(source);
|
|
252
|
+
if (annotations.length > 0) {
|
|
253
|
+
for (const ann of annotations) {
|
|
254
|
+
const domain = inferDomain(relPath);
|
|
255
|
+
const parts = ann.featureKey.split('.');
|
|
256
|
+
const feature: DiscoveredFeature = {
|
|
257
|
+
feature_key: ann.featureKey,
|
|
258
|
+
domain,
|
|
259
|
+
subdomain: parts[0],
|
|
260
|
+
title: ann.title || kebabToTitle(ann.featureKey),
|
|
261
|
+
description: ann.description,
|
|
262
|
+
status: 'active',
|
|
263
|
+
priority: (ann.priority as FeaturePriority) || 'standard',
|
|
264
|
+
components: [{ file: relPath, name: null, role: 'implementation', isPrimary: true }],
|
|
265
|
+
procedures: [],
|
|
266
|
+
pages: [],
|
|
267
|
+
};
|
|
268
|
+
features.push(feature);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Detect interactive features: exported functions with mutation/handler patterns
|
|
273
|
+
const hasHandlers = /onClick|onSubmit|useMutation|api\.\w+\.\w+\.use/.test(source);
|
|
274
|
+
const exportMatch = /export\s+(?:default\s+)?(?:function|const)\s+(\w+)/.exec(source);
|
|
275
|
+
|
|
276
|
+
if (hasHandlers && exportMatch) {
|
|
277
|
+
const componentName = exportMatch[1];
|
|
278
|
+
const domain = inferDomain(relPath);
|
|
279
|
+
const subdomain = basename(dirname(relPath));
|
|
280
|
+
const featureKey = `component.${subdomain}.${componentName.replace(/([A-Z])/g, '-$1').toLowerCase().replace(/^-/, '')}`;
|
|
281
|
+
|
|
282
|
+
// Only add if not already covered by annotation
|
|
283
|
+
if (!annotations.some(a => a.featureKey === featureKey)) {
|
|
284
|
+
features.push({
|
|
285
|
+
feature_key: featureKey,
|
|
286
|
+
domain,
|
|
287
|
+
subdomain,
|
|
288
|
+
title: `${componentName}`,
|
|
289
|
+
status: 'active',
|
|
290
|
+
priority: 'standard',
|
|
291
|
+
components: [{ file: relPath, name: componentName, role: 'implementation', isPrimary: true }],
|
|
292
|
+
procedures: [],
|
|
293
|
+
pages: [],
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return features;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ============================================================
|
|
304
|
+
// Utility: Walk directory recursively
|
|
305
|
+
// ============================================================
|
|
306
|
+
|
|
307
|
+
function walkDir(dir: string): string[] {
|
|
308
|
+
const results: string[] = [];
|
|
309
|
+
try {
|
|
310
|
+
const entries = readdirSync(dir);
|
|
311
|
+
for (const entry of entries) {
|
|
312
|
+
const fullPath = join(dir, entry);
|
|
313
|
+
try {
|
|
314
|
+
const stat = statSync(fullPath);
|
|
315
|
+
if (stat.isDirectory()) {
|
|
316
|
+
results.push(...walkDir(fullPath));
|
|
317
|
+
} else {
|
|
318
|
+
results.push(fullPath);
|
|
319
|
+
}
|
|
320
|
+
} catch {
|
|
321
|
+
// Skip unreadable entries
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
} catch {
|
|
325
|
+
// Skip unreadable directories
|
|
326
|
+
}
|
|
327
|
+
return results;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ============================================================
|
|
331
|
+
// Main Scanner Entry Point
|
|
332
|
+
// ============================================================
|
|
333
|
+
|
|
334
|
+
export interface ScanResult {
|
|
335
|
+
totalDiscovered: number;
|
|
336
|
+
fromProcedures: number;
|
|
337
|
+
fromPages: number;
|
|
338
|
+
fromComponents: number;
|
|
339
|
+
registered: number;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export function runFeatureScan(dataDb: Database.Database): ScanResult {
|
|
343
|
+
const procedureFeatures = scanTrpcProcedures(dataDb);
|
|
344
|
+
const pageFeatures = scanPageRoutes(dataDb);
|
|
345
|
+
const componentFeatures = scanComponentExports(dataDb);
|
|
346
|
+
|
|
347
|
+
// Merge: annotations take priority, then components, then pages, then procedures
|
|
348
|
+
const allFeatures = new Map<string, DiscoveredFeature>();
|
|
349
|
+
|
|
350
|
+
// Add procedures first (lowest priority)
|
|
351
|
+
for (const f of procedureFeatures) {
|
|
352
|
+
allFeatures.set(f.feature_key, f);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Add pages
|
|
356
|
+
for (const f of pageFeatures) {
|
|
357
|
+
allFeatures.set(f.feature_key, f);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Add components (highest priority from scanning)
|
|
361
|
+
for (const f of componentFeatures) {
|
|
362
|
+
allFeatures.set(f.feature_key, f);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Register all discovered features
|
|
366
|
+
let registered = 0;
|
|
367
|
+
for (const feature of allFeatures.values()) {
|
|
368
|
+
const featureId = upsertFeature(dataDb, {
|
|
369
|
+
feature_key: feature.feature_key,
|
|
370
|
+
domain: feature.domain,
|
|
371
|
+
subdomain: feature.subdomain,
|
|
372
|
+
title: feature.title,
|
|
373
|
+
description: feature.description,
|
|
374
|
+
status: feature.status,
|
|
375
|
+
priority: feature.priority,
|
|
376
|
+
portal_scope: feature.portal_scope,
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
// Link components
|
|
380
|
+
for (const comp of feature.components) {
|
|
381
|
+
linkComponent(dataDb, featureId, comp.file, comp.name, comp.role, comp.isPrimary);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Link procedures
|
|
385
|
+
for (const proc of feature.procedures) {
|
|
386
|
+
linkProcedure(dataDb, featureId, proc.router, proc.procedure, proc.type);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Link pages
|
|
390
|
+
for (const page of feature.pages) {
|
|
391
|
+
linkPage(dataDb, featureId, page.route, page.portal ?? undefined);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
logChange(dataDb, featureId, 'created', 'Auto-discovered by sentinel scanner', undefined, 'scanner');
|
|
395
|
+
registered++;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return {
|
|
399
|
+
totalDiscovered: allFeatures.size,
|
|
400
|
+
fromProcedures: procedureFeatures.length,
|
|
401
|
+
fromPages: pageFeatures.length,
|
|
402
|
+
fromComponents: componentFeatures.length,
|
|
403
|
+
registered,
|
|
404
|
+
};
|
|
405
|
+
}
|