@taodigital/app-brain 0.0.0-stage → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +13 -2
  2. package/dist/archetype-classification.d.ts +54 -0
  3. package/dist/archetype-classification.js +95 -0
  4. package/dist/cloud-storage.d.ts +106 -0
  5. package/dist/cloud-storage.js +539 -0
  6. package/dist/email.d.ts +21 -0
  7. package/dist/email.js +234 -0
  8. package/dist/grounding.d.ts +30 -0
  9. package/dist/grounding.js +138 -0
  10. package/dist/index.d.ts +13 -0
  11. package/dist/index.js +37 -0
  12. package/dist/nav-graph.d.ts +42 -0
  13. package/dist/nav-graph.js +248 -0
  14. package/dist/notifications.d.ts +26 -0
  15. package/dist/notifications.js +105 -0
  16. package/dist/orphan-classification.d.ts +32 -0
  17. package/dist/orphan-classification.js +40 -0
  18. package/dist/repository.d.ts +3185 -0
  19. package/dist/repository.js +5927 -0
  20. package/dist/run-notifications.d.ts +16 -0
  21. package/dist/run-notifications.js +244 -0
  22. package/dist/skill-file-kinds.d.ts +5 -0
  23. package/dist/skill-file-kinds.js +50 -0
  24. package/dist/token-ledger.d.ts +23 -0
  25. package/dist/token-ledger.js +178 -0
  26. package/dist/traceability.d.ts +213 -0
  27. package/dist/traceability.js +339 -0
  28. package/package.json +24 -4
  29. package/src/archetype-classification.test.ts +91 -0
  30. package/src/archetype-classification.ts +132 -0
  31. package/src/cloud-storage.test.ts +83 -0
  32. package/src/cloud-storage.ts +627 -0
  33. package/src/email.ts +268 -0
  34. package/src/grounding.ts +172 -0
  35. package/src/index.ts +18 -0
  36. package/src/nav-graph.ts +295 -0
  37. package/src/notifications.ts +156 -0
  38. package/src/orphan-classification.test.ts +61 -0
  39. package/src/orphan-classification.ts +65 -0
  40. package/src/orphan-screens.test.ts +88 -0
  41. package/src/repository.ts +7397 -0
  42. package/src/run-notifications.ts +281 -0
  43. package/src/skill-file-kinds.ts +51 -0
  44. package/src/token-ledger.ts +209 -0
  45. package/src/traceability.test.ts +75 -0
  46. package/src/traceability.ts +477 -0
  47. package/tsconfig.json +9 -0
@@ -0,0 +1,295 @@
1
+ import { withTenant, Prisma } from '@taodigital/database';
2
+
3
+ export interface NavGraphNode {
4
+ id: string;
5
+ name: string;
6
+ url: string | null;
7
+ section: string; // Screen group name or classified section
8
+ sectionColor?: string | null;
9
+ groupId?: string | null;
10
+ depth: number | null;
11
+ entryPoint: boolean;
12
+ discoveryMethod: string;
13
+ }
14
+
15
+ export interface NavGraphEdge {
16
+ id: string;
17
+ fromScreenId: string;
18
+ toScreenId: string | null;
19
+ href: string;
20
+ text: string | null;
21
+ landmark: string | null;
22
+ linkType: string;
23
+ source: string;
24
+ confidence: number;
25
+ isBackEdge: boolean;
26
+ }
27
+
28
+ export interface NavGraph {
29
+ nodes: NavGraphNode[];
30
+ edges: NavGraphEdge[];
31
+ entryPoints: string[]; // screen IDs
32
+ orphans: string[]; // screen IDs with no inbound edges
33
+ sections: Record<string, string[]>; // section -> [screenId]
34
+ sectionColors?: Record<string, string>; // section -> color
35
+ stats: {
36
+ totalNodes: number;
37
+ totalEdges: number;
38
+ maxDepth: number | null;
39
+ sections: string[];
40
+ };
41
+ }
42
+
43
+ function classifySection(url: string | null, name: string): string {
44
+ const text = (url ?? name).toLowerCase();
45
+ if (/\/(login|signin|sign-in|register|signup|sign-up|auth|forgot|reset|verify|otp|mfa|sso)/.test(text)) return 'Authentication';
46
+ if (/\/(admin|administration|manage|management|superuser)/.test(text)) return 'Administration';
47
+ if (/\/(settings|preferences|profile|account|config|configuration)/.test(text)) return 'Settings';
48
+ if (/\/(dashboard|home|overview|summary|analytics|reports|metrics)/.test(text)) return 'Dashboard';
49
+ if (/\/(checkout|cart|basket|payment|order-review|billing)/.test(text)) return 'Checkout';
50
+ if (/\/(product|products|item|items|catalog|shop|store|collection)/.test(text)) return 'Catalog';
51
+ if (/\/(order|orders|history|purchases)/.test(text)) return 'Orders';
52
+ if (/\/(user|users|member|members|team|customers)/.test(text)) return 'Users';
53
+
54
+ // Extract primary URL path segment if available (e.g. /inventory -> Inventory)
55
+ if (url) {
56
+ try {
57
+ const parsed = new URL(url.startsWith('http') ? url : `https://example.com${url.startsWith('/') ? '' : '/'}${url}`);
58
+ const segments = parsed.pathname.split('/').filter(Boolean);
59
+ if (segments.length > 0) {
60
+ const seg = segments[0].replace(/[^a-zA-Z0-9_-]/g, '');
61
+ if (seg.length > 1 && !/^[0-9a-f-]{8,}$/i.test(seg)) {
62
+ return seg.charAt(0).toUpperCase() + seg.slice(1).replace(/[-_]/g, ' ');
63
+ }
64
+ }
65
+ } catch {}
66
+ }
67
+ return 'Application';
68
+ }
69
+
70
+ export async function buildNavigationGraph(
71
+ tenantId: string,
72
+ projectId: string,
73
+ opts: { jobId?: string; maxDepth?: number } = {},
74
+ ): Promise<NavGraph> {
75
+ return withTenant(tenantId, async (tx: Prisma.TransactionClient) => {
76
+ const [screens, screenGroups] = await Promise.all([
77
+ (tx as any).screen.findMany({
78
+ where: { tenantId, projectId, status: 'ACTIVE' },
79
+ select: {
80
+ id: true,
81
+ name: true,
82
+ url: true,
83
+ depth: true,
84
+ discoveryMethod: true,
85
+ groupId: true,
86
+ group: { select: { id: true, name: true, slug: true, color: true } },
87
+ },
88
+ }),
89
+ (tx as any).screenGroup.findMany({
90
+ where: { tenantId, projectId },
91
+ orderBy: { order: 'asc' },
92
+ include: {
93
+ screens: {
94
+ where: { status: 'ACTIVE' },
95
+ select: { id: true, name: true, url: true },
96
+ },
97
+ },
98
+ }),
99
+ ]);
100
+
101
+ const linksWhere: any = { tenantId, projectId };
102
+ if (opts.jobId) linksWhere.jobId = opts.jobId;
103
+ const links = await (tx as any).discoveredLink.findMany({
104
+ where: linksWhere,
105
+ select: {
106
+ id: true,
107
+ fromScreenId: true,
108
+ toScreenId: true,
109
+ href: true,
110
+ text: true,
111
+ landmark: true,
112
+ linkType: true,
113
+ source: true,
114
+ confidence: true,
115
+ },
116
+ });
117
+
118
+ const screenIds = new Set(screens.map((s: any) => s.id));
119
+ const inboundCount = new Map<string, number>();
120
+ for (const s of screens) inboundCount.set(s.id, 0);
121
+ for (const l of links) {
122
+ if (l.toScreenId && screenIds.has(l.toScreenId)) {
123
+ inboundCount.set(l.toScreenId, (inboundCount.get(l.toScreenId) ?? 0) + 1);
124
+ }
125
+ }
126
+
127
+ // Build screen-to-group map and section colors
128
+ const screenToGroupMap = new Map<string, { id: string; name: string; color: string }>();
129
+ const sectionColors: Record<string, string> = {};
130
+
131
+ for (const g of screenGroups) {
132
+ if (g.name && g.color) {
133
+ sectionColors[g.name] = g.color;
134
+ }
135
+ for (const sc of (g.screens ?? [])) {
136
+ screenToGroupMap.set(sc.id, { id: g.id, name: g.name, color: g.color });
137
+ }
138
+ }
139
+
140
+ // Compute BFS depth from entry points via discovered link edges
141
+ const adjacency = new Map<string, string[]>(); // fromScreenId -> [toScreenId]
142
+ for (const l of links) {
143
+ if (!l.toScreenId) continue;
144
+ if (!adjacency.has(l.fromScreenId)) adjacency.set(l.fromScreenId, []);
145
+ adjacency.get(l.fromScreenId)!.push(l.toScreenId);
146
+ }
147
+ const depths = new Map<string, number>();
148
+ const entryIds = screens
149
+ .filter((s: any) => s.discoveryMethod === 'ENTRY_POINT' || s.discoveryMethod === 'SEED_URL')
150
+ .map((s: any) => s.id);
151
+ const bfsQueue: string[] = [...entryIds];
152
+ for (const id of entryIds) depths.set(id, 0);
153
+ while (bfsQueue.length > 0) {
154
+ const cur = bfsQueue.shift()!;
155
+ const curDepth = depths.get(cur)!;
156
+ for (const next of (adjacency.get(cur) ?? [])) {
157
+ if (!depths.has(next)) {
158
+ depths.set(next, curDepth + 1);
159
+ bfsQueue.push(next);
160
+ }
161
+ }
162
+ }
163
+
164
+ const nodes: NavGraphNode[] = screens.map((s: any) => {
165
+ const assignedGroup = s.group ?? screenToGroupMap.get(s.id);
166
+ let section = assignedGroup?.name;
167
+ let sectionColor = assignedGroup?.color ?? null;
168
+
169
+ if (!section && screenGroups.length > 0) {
170
+ const text = `${s.url ?? ''} ${s.name ?? ''}`.toLowerCase();
171
+ const matched = screenGroups.find((g: any) =>
172
+ (g.slug && text.includes(g.slug.toLowerCase())) ||
173
+ (g.name && text.includes(g.name.toLowerCase()))
174
+ );
175
+ if (matched) {
176
+ section = matched.name;
177
+ sectionColor = matched.color;
178
+ }
179
+ }
180
+
181
+ if (!section) {
182
+ section = classifySection(s.url, s.name);
183
+ }
184
+
185
+ return {
186
+ id: s.id,
187
+ name: s.name,
188
+ url: s.url ?? null,
189
+ section,
190
+ sectionColor,
191
+ groupId: assignedGroup?.id ?? s.groupId ?? null,
192
+ depth: depths.get(s.id) ?? s.depth ?? null,
193
+ entryPoint: s.discoveryMethod === 'ENTRY_POINT' || s.discoveryMethod === 'SEED_URL',
194
+ discoveryMethod: s.discoveryMethod,
195
+ };
196
+ });
197
+
198
+ // Detect back-edges: edge goes to a node with lower or equal depth
199
+ const edges: NavGraphEdge[] = links.map((l: any) => {
200
+ const fromDepth = depths.get(l.fromScreenId) ?? null;
201
+ const toDepth = l.toScreenId ? (depths.get(l.toScreenId) ?? null) : null;
202
+ const isBackEdge = fromDepth !== null && toDepth !== null && toDepth <= fromDepth;
203
+ return {
204
+ id: l.id,
205
+ fromScreenId: l.fromScreenId,
206
+ toScreenId: l.toScreenId ?? null,
207
+ href: l.href,
208
+ text: l.text ?? null,
209
+ landmark: l.landmark ?? null,
210
+ linkType: l.linkType,
211
+ source: l.source,
212
+ confidence: l.confidence,
213
+ isBackEdge,
214
+ };
215
+ });
216
+
217
+ const entryPoints = nodes.filter((n) => n.entryPoint).map((n) => n.id);
218
+ const orphans = nodes
219
+ .filter((n) => !n.entryPoint && (inboundCount.get(n.id) ?? 0) === 0)
220
+ .map((n) => n.id);
221
+
222
+ const sectionMap: Record<string, string[]> = {};
223
+ for (const node of nodes) {
224
+ if (!sectionMap[node.section]) sectionMap[node.section] = [];
225
+ sectionMap[node.section]!.push(node.id);
226
+ }
227
+ // Ensure all discovered screen groups with matching screens are reflected in sectionMap
228
+ for (const g of screenGroups) {
229
+ if (g.name && !sectionMap[g.name] && (g.screens?.length ?? 0) > 0) {
230
+ const matchingIds = g.screens.map((sc: any) => sc.id).filter((id: string) => screenIds.has(id));
231
+ if (matchingIds.length > 0) {
232
+ sectionMap[g.name] = matchingIds;
233
+ }
234
+ }
235
+ }
236
+
237
+ const depths2 = nodes.map((n) => n.depth).filter((d): d is number => d !== null);
238
+ const maxDepth = depths2.length > 0 ? Math.max(...depths2) : null;
239
+
240
+ return {
241
+ nodes,
242
+ edges,
243
+ entryPoints,
244
+ orphans,
245
+ sections: sectionMap,
246
+ sectionColors,
247
+ stats: {
248
+ totalNodes: nodes.length,
249
+ totalEdges: edges.length,
250
+ maxDepth,
251
+ sections: Object.keys(sectionMap),
252
+ },
253
+ };
254
+ });
255
+ }
256
+
257
+ export function toMermaid(graph: NavGraph): string {
258
+ const lines: string[] = ['flowchart TD'];
259
+
260
+ // Group nodes by section as subgraphs
261
+ const sectionLabels: Record<string, string> = {
262
+ auth: 'Authentication',
263
+ dashboard: 'Dashboard',
264
+ settings: 'Settings',
265
+ admin: 'Admin',
266
+ app: 'Application',
267
+ };
268
+
269
+ for (const [section, nodeIds] of Object.entries(graph.sections)) {
270
+ if (!nodeIds.length) continue;
271
+ const subId = section.replace(/[^a-zA-Z0-9_]/g, '_') || 'sec';
272
+ lines.push(` subgraph ${subId}["${sanitizeMermaid(sectionLabels[section] ?? section)}"]`);
273
+ for (const nodeId of nodeIds) {
274
+ const node = graph.nodes.find((n) => n.id === nodeId);
275
+ if (!node) continue;
276
+ const shape = node.entryPoint ? `([${sanitizeMermaid(node.name)}])` : `[${sanitizeMermaid(node.name)}]`;
277
+ lines.push(` ${nodeId}${shape}`);
278
+ }
279
+ lines.push(' end');
280
+ }
281
+
282
+ // Edges
283
+ for (const edge of graph.edges) {
284
+ if (!edge.toScreenId) continue;
285
+ const label = edge.text ? `|"${sanitizeMermaid(edge.text.slice(0, 30))}"| ` : '';
286
+ const arrow = edge.isBackEdge ? '-.->' : '-->';
287
+ lines.push(` ${edge.fromScreenId} ${arrow} ${label}${edge.toScreenId}`);
288
+ }
289
+
290
+ return lines.join('\n');
291
+ }
292
+
293
+ function sanitizeMermaid(s: string): string {
294
+ return s.replace(/["[\](){}|>]/g, ' ').trim();
295
+ }
@@ -0,0 +1,156 @@
1
+ import crypto from 'node:crypto';
2
+ import { withTenant } from '@taodigital/database';
3
+ import { sendEmail } from './email.js';
4
+
5
+ export type NotificationEvent =
6
+ | 'invite.created'
7
+ | 'invite.received'
8
+ | 'crawl.done'
9
+ | 'crawl.failed'
10
+ | 'test.generated'
11
+ | 'test.run.done'
12
+ | 'test.run.failed'
13
+ | 'review.assigned'
14
+ | 'token.budget.alert'
15
+ | string;
16
+
17
+ export interface DispatchOptions {
18
+ tenantId: string;
19
+ userId: string; // recipient
20
+ type: NotificationEvent;
21
+ title: string;
22
+ body: string;
23
+ metadata?: Record<string, unknown>;
24
+ /** extra email fields — only used if email pref enabled */
25
+ email?: {
26
+ recipientEmail: string;
27
+ recipientName?: string;
28
+ };
29
+ }
30
+
31
+ export interface DispatchResult {
32
+ emailSent: boolean;
33
+ emailReason?: string;
34
+ }
35
+
36
+ /** Dispatch a single notification (in-app + email if prefs allow). Fire-and-forget safe. */
37
+ export async function dispatchNotification(opts: DispatchOptions): Promise<DispatchResult> {
38
+ // 1. Check user preference (default: both enabled if no row exists)
39
+ // Check exact eventType, or normalized (dots vs underscores, e.g. test.run.failed vs test_run_failed)
40
+ const eventTypesToCheck = [
41
+ opts.type,
42
+ opts.type.replace(/_/g, '.'),
43
+ opts.type.replace(/\./g, '_'),
44
+ ];
45
+ if (opts.type === 'invite.created') eventTypesToCheck.push('invite.received');
46
+ if (opts.type === 'invite.received') eventTypesToCheck.push('invite.created');
47
+
48
+ const pref = await withTenant(opts.tenantId, (tx) =>
49
+ tx.notificationPreference.findFirst({
50
+ where: {
51
+ userId: opts.userId,
52
+ tenantId: opts.tenantId,
53
+ eventType: { in: eventTypesToCheck },
54
+ },
55
+ })
56
+ ).catch(() => null);
57
+
58
+ const inAppEnabled = pref ? (pref as any).inApp : true;
59
+ const emailEnabled = pref ? (pref as any).email : true;
60
+
61
+ // 2. Create in-app notification
62
+ if (inAppEnabled) {
63
+ await withTenant(opts.tenantId, (tx) =>
64
+ tx.notification.create({
65
+ data: {
66
+ id: `notif_${crypto.randomUUID()}`,
67
+ tenantId: opts.tenantId,
68
+ userId: opts.userId,
69
+ type: opts.type,
70
+ title: opts.title,
71
+ body: opts.body,
72
+ metadata: opts.metadata as any,
73
+ },
74
+ })
75
+ ).catch(console.error);
76
+ }
77
+
78
+ // 3. Send email if enabled
79
+ if (emailEnabled) {
80
+ let recipientEmail = opts.email?.recipientEmail;
81
+ let recipientName = opts.email?.recipientName;
82
+
83
+ // Resolve user's registered email if not explicitly provided
84
+ if (!recipientEmail && opts.userId) {
85
+ const u = await withTenant(opts.tenantId, (tx) =>
86
+ tx.user.findUnique({
87
+ where: { id: opts.userId },
88
+ select: { email: true, name: true },
89
+ })
90
+ ).catch(() => null);
91
+ if (u?.email) {
92
+ recipientEmail = u.email;
93
+ recipientName = u.name ?? undefined;
94
+ }
95
+ }
96
+
97
+ if (recipientEmail) {
98
+ try {
99
+ const result = await sendEmail({
100
+ to: recipientEmail,
101
+ name: recipientName,
102
+ type: opts.type,
103
+ title: opts.title,
104
+ body: opts.body,
105
+ metadata: opts.metadata,
106
+ tenantId: opts.tenantId,
107
+ });
108
+ return { emailSent: result.sent, emailReason: result.reason };
109
+ } catch (err: any) {
110
+ console.error('[notifications] sendEmail error:', err);
111
+ return { emailSent: false, emailReason: err?.message ?? String(err) };
112
+ }
113
+ }
114
+ }
115
+
116
+ return { emailSent: false, emailReason: emailEnabled ? 'No recipient email available.' : 'Recipient disabled email for this event.' };
117
+ }
118
+
119
+ /** Dispatch to all active members of a tenant (respecting each user's in-app and email preferences) */
120
+ export async function dispatchToTenantMembers(
121
+ tenantId: string,
122
+ opts: Omit<DispatchOptions, 'tenantId' | 'userId' | 'email'>
123
+ ): Promise<void> {
124
+ const members = await withTenant(tenantId, (tx) =>
125
+ tx.membership.findMany({
126
+ where: { tenantId, status: 'ACTIVE' },
127
+ include: { user: { select: { email: true, name: true } } },
128
+ })
129
+ ).catch(() => []);
130
+
131
+ await Promise.all((members as any[]).map((m: any) =>
132
+ dispatchNotification({
133
+ ...opts,
134
+ tenantId,
135
+ userId: m.userId,
136
+ email: m.user?.email ? { recipientEmail: m.user.email, recipientName: m.user.name ?? undefined } : undefined,
137
+ })
138
+ ));
139
+ }
140
+
141
+ /** Dispatch to all members of a tenant (alias to dispatchToTenantMembers) */
142
+ export async function dispatchToTenantAdmins(
143
+ tenantId: string,
144
+ opts: Omit<DispatchOptions, 'tenantId' | 'userId' | 'email'>
145
+ ): Promise<void> {
146
+ return dispatchToTenantMembers(tenantId, opts);
147
+ }
148
+
149
+ /** Dispatch to all project members */
150
+ export async function dispatchToProjectMembers(
151
+ tenantId: string,
152
+ _projectId: string,
153
+ opts: Omit<DispatchOptions, 'tenantId' | 'userId' | 'email'>
154
+ ): Promise<void> {
155
+ return dispatchToTenantMembers(tenantId, opts);
156
+ }
@@ -0,0 +1,61 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { classifyScreen, buildOrphanReport } from './orphan-classification.js';
4
+
5
+ test('classifyScreen: entry point is always REACHABLE, even with zero inbound edges', () => {
6
+ assert.equal(classifyScreen('ENTRY_POINT', false), 'REACHABLE');
7
+ });
8
+
9
+ test('classifyScreen: any discovery method with an inbound edge is REACHABLE', () => {
10
+ assert.equal(classifyScreen('LINK_FOLLOW', true), 'REACHABLE');
11
+ assert.equal(classifyScreen('SEED_URL', true), 'REACHABLE');
12
+ assert.equal(classifyScreen('SKYVERN_AI', true), 'REACHABLE');
13
+ });
14
+
15
+ test('classifyScreen: link-followed with no inbound edge is a GRAPH_ORPHAN', () => {
16
+ assert.equal(classifyScreen('LINK_FOLLOW', false), 'GRAPH_ORPHAN');
17
+ });
18
+
19
+ test('classifyScreen: seed URL with no inbound edge is a SITEMAP_ORPHAN', () => {
20
+ assert.equal(classifyScreen('SEED_URL', false), 'SITEMAP_ORPHAN');
21
+ });
22
+
23
+ test('classifyScreen: Skyvern-discovered with no inbound edge is UNVERIFIED_ORPHAN', () => {
24
+ assert.equal(classifyScreen('SKYVERN_AI', false), 'UNVERIFIED_ORPHAN');
25
+ });
26
+
27
+ test('buildOrphanReport: aggregates counts and percent correctly', () => {
28
+ const screens = [
29
+ { id: 's1', name: 'Home', url: '/', discoveryMethod: 'ENTRY_POINT' },
30
+ { id: 's2', name: 'Dashboard', url: '/dashboard', discoveryMethod: 'LINK_FOLLOW' },
31
+ { id: 's3', name: 'Old Promo', url: '/promo-2023', discoveryMethod: 'LINK_FOLLOW' },
32
+ { id: 's4', name: 'Legal', url: '/legal', discoveryMethod: 'SEED_URL' },
33
+ ];
34
+ // s2 has an inbound edge (from Home); s1's entry-point status makes it reachable
35
+ // regardless; s3 and s4 have none.
36
+ const report = buildOrphanReport(screens, new Set(['s2']));
37
+
38
+ assert.equal(report.totalScreens, 4);
39
+ assert.equal(report.reachableCount, 2);
40
+ assert.equal(report.orphanCount, 2);
41
+ assert.equal(report.orphanPercent, 50);
42
+
43
+ const byId = Object.fromEntries(report.screens.map((s) => [s.id, s.classification]));
44
+ assert.equal(byId.s1, 'REACHABLE');
45
+ assert.equal(byId.s2, 'REACHABLE');
46
+ assert.equal(byId.s3, 'GRAPH_ORPHAN');
47
+ assert.equal(byId.s4, 'SITEMAP_ORPHAN');
48
+ });
49
+
50
+ test('buildOrphanReport: zero screens produces a zero-percent report, not NaN/division-by-zero', () => {
51
+ const report = buildOrphanReport([], new Set());
52
+ assert.equal(report.totalScreens, 0);
53
+ assert.equal(report.orphanPercent, 0);
54
+ });
55
+
56
+ test('buildOrphanReport: all screens reachable gives 0% orphan rate', () => {
57
+ const screens = [{ id: 's1', name: 'Home', url: '/', discoveryMethod: 'ENTRY_POINT' }];
58
+ const report = buildOrphanReport(screens, new Set());
59
+ assert.equal(report.orphanPercent, 0);
60
+ assert.equal(report.orphanCount, 0);
61
+ });
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Orphan-page classification — pure logic, no I/O, so it's unit-testable in
3
+ * isolation. See docs/research/25-orphan-page-detection-industry-comparison.md
4
+ * for the industry comparison and design rationale this implements.
5
+ */
6
+
7
+ export type DiscoveryMethod = 'ENTRY_POINT' | 'LINK_FOLLOW' | 'SEED_URL' | 'SKYVERN_AI';
8
+
9
+ export type OrphanClassification =
10
+ | 'REACHABLE' // has an inbound KAG edge, or is the crawl's own entry point
11
+ | 'GRAPH_ORPHAN' // discovered via a followed link, but nothing currently links to it
12
+ // (usually a stale/removed nav item, or a canonical-URL match miss)
13
+ | 'SITEMAP_ORPHAN' // discovered only because it was a configured seed URL — a true
14
+ // SEO-style orphan: known to exist, unreachable by navigation
15
+ | 'UNVERIFIED_ORPHAN'; // discovered via Skyvern AI/vision navigation, not a DOM link —
16
+ // less trustworthy until KAG link-wiring covers that path too
17
+
18
+ /** Classifies a single screen given its discovery method and whether the KAG graph
19
+ * has at least one inbound Element.transitionsToScreenId edge pointing to it. */
20
+ export function classifyScreen(discoveryMethod: string, hasInboundEdge: boolean): OrphanClassification {
21
+ if (discoveryMethod === 'ENTRY_POINT' || hasInboundEdge) return 'REACHABLE';
22
+ if (discoveryMethod === 'SEED_URL') return 'SITEMAP_ORPHAN';
23
+ if (discoveryMethod === 'SKYVERN_AI') return 'UNVERIFIED_ORPHAN';
24
+ return 'GRAPH_ORPHAN';
25
+ }
26
+
27
+ export interface OrphanScreenResult {
28
+ id: string;
29
+ name: string;
30
+ url: string | null;
31
+ discoveryMethod: string;
32
+ classification: OrphanClassification;
33
+ }
34
+
35
+ export interface OrphanReport {
36
+ screens: OrphanScreenResult[];
37
+ totalScreens: number;
38
+ reachableCount: number;
39
+ orphanCount: number;
40
+ /** orphanCount / totalScreens as a percentage, 0 when there are no screens at all. */
41
+ orphanPercent: number;
42
+ }
43
+
44
+ /** Builds the full per-project orphan report from raw screen + inbound-edge data. */
45
+ export function buildOrphanReport(
46
+ screens: Array<{ id: string; name: string; url: string | null; discoveryMethod: string }>,
47
+ screenIdsWithInboundEdge: Set<string>,
48
+ ): OrphanReport {
49
+ const classified = screens.map((s) => ({
50
+ id: s.id,
51
+ name: s.name,
52
+ url: s.url,
53
+ discoveryMethod: s.discoveryMethod,
54
+ classification: classifyScreen(s.discoveryMethod, screenIdsWithInboundEdge.has(s.id)),
55
+ }));
56
+ const reachableCount = classified.filter((s) => s.classification === 'REACHABLE').length;
57
+ const orphanCount = classified.length - reachableCount;
58
+ return {
59
+ screens: classified,
60
+ totalScreens: classified.length,
61
+ reachableCount,
62
+ orphanCount,
63
+ orphanPercent: classified.length > 0 ? Math.round((orphanCount / classified.length) * 1000) / 10 : 0,
64
+ };
65
+ }
@@ -0,0 +1,88 @@
1
+ import crypto from 'node:crypto';
2
+ import test from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import { prisma, withTenant } from '@taodigital/database';
5
+ import { AppBrain } from './repository.js';
6
+
7
+ function id(prefix: string) {
8
+ return `${prefix}_${crypto.randomUUID()}`;
9
+ }
10
+
11
+ async function makeFixtureProject() {
12
+ const tenantId = id('tenant');
13
+ const projectId = id('project');
14
+ await prisma.$transaction(async (tx) => {
15
+ await tx.$executeRawUnsafe(`SELECT set_config('app.current_tenant', $1, true)`, tenantId);
16
+ await tx.tenant.create({ data: { id: tenantId, name: 'Orphan Screens Test Tenant', slug: tenantId, isolation: 'SHARED', region: 'US' } });
17
+ await tx.project.create({ data: { id: projectId, tenantId, name: 'Orphan Screens Test Project' } });
18
+ });
19
+ return { tenantId, projectId };
20
+ }
21
+
22
+ async function makeScreen(tenantId: string, projectId: string, name: string, discoveryMethod: string) {
23
+ return withTenant(tenantId, (tx) =>
24
+ (tx as any).screen.create({
25
+ data: { id: id('screen'), tenantId, projectId, name, url: `https://example.com/${name}`, discoveryMethod },
26
+ })
27
+ );
28
+ }
29
+
30
+ async function makeLinkElement(tenantId: string, projectId: string, screenId: string, meaning: string, transitionsToScreenId: string) {
31
+ return withTenant(tenantId, (tx) =>
32
+ (tx as any).element.create({
33
+ data: { id: id('el'), tenantId, projectId, screenId, meaning, role: 'LINK', isInteractive: true, transitionsToScreenId },
34
+ })
35
+ );
36
+ }
37
+
38
+ test('getOrphanScreens: end-to-end classification against real data', async () => {
39
+ const { tenantId, projectId } = await makeFixtureProject();
40
+
41
+ const home = await makeScreen(tenantId, projectId, 'home', 'ENTRY_POINT');
42
+ const dashboard = await makeScreen(tenantId, projectId, 'dashboard', 'LINK_FOLLOW');
43
+ const oldPromo = await makeScreen(tenantId, projectId, 'old-promo', 'LINK_FOLLOW'); // no inbound link -> GRAPH_ORPHAN
44
+ const legalSeed = await makeScreen(tenantId, projectId, 'legal', 'SEED_URL'); // no inbound link -> SITEMAP_ORPHAN
45
+ const skyvernPage = await makeScreen(tenantId, projectId, 'skyvern-found', 'SKYVERN_AI'); // no inbound -> UNVERIFIED_ORPHAN
46
+
47
+ // Home links to Dashboard — the only real inbound edge in this fixture.
48
+ await makeLinkElement(tenantId, projectId, home.id, 'Go to dashboard', dashboard.id);
49
+
50
+ const report = await AppBrain.getOrphanScreens(tenantId, projectId);
51
+
52
+ assert.equal(report.totalScreens, 5);
53
+ assert.equal(report.reachableCount, 2); // home (entry) + dashboard (inbound edge)
54
+ assert.equal(report.orphanCount, 3);
55
+
56
+ const byId = Object.fromEntries(report.screens.map((s: any) => [s.id, s.classification]));
57
+ assert.equal(byId[home.id], 'REACHABLE');
58
+ assert.equal(byId[dashboard.id], 'REACHABLE');
59
+ assert.equal(byId[oldPromo.id], 'GRAPH_ORPHAN');
60
+ assert.equal(byId[legalSeed.id], 'SITEMAP_ORPHAN');
61
+ assert.equal(byId[skyvernPage.id], 'UNVERIFIED_ORPHAN');
62
+ });
63
+
64
+ test('getOrphanScreens: PENDING_REMOVAL screens are excluded entirely (stale, not orphaned)', async () => {
65
+ const { tenantId, projectId } = await makeFixtureProject();
66
+ const active = await makeScreen(tenantId, projectId, 'active-page', 'LINK_FOLLOW');
67
+ const removed = await makeScreen(tenantId, projectId, 'removed-page', 'LINK_FOLLOW');
68
+ await withTenant(tenantId, (tx) => (tx as any).screen.update({ where: { id: removed.id }, data: { status: 'PENDING_REMOVAL' } }));
69
+
70
+ const report = await AppBrain.getOrphanScreens(tenantId, projectId);
71
+ const ids = report.screens.map((s: any) => s.id);
72
+ assert.ok(ids.includes(active.id));
73
+ assert.ok(!ids.includes(removed.id));
74
+ });
75
+
76
+ test('saveScreen: discoveryMethod is set on create and preserved (not overwritten) on re-crawl update', async () => {
77
+ const { tenantId, projectId } = await makeFixtureProject();
78
+ const url = 'https://example.com/preserved';
79
+ const created = await AppBrain.saveScreen(tenantId, projectId, 'Preserved', url, undefined, 'SEED_URL');
80
+ assert.equal((created as any).discoveryMethod, 'SEED_URL');
81
+
82
+ // Re-crawl the same URL without passing a discoveryMethod (matches how the crawler's
83
+ // update path never re-specifies it) — must NOT reset to the LINK_FOLLOW default.
84
+ const updated = await AppBrain.saveScreen(tenantId, projectId, 'Preserved', url);
85
+ assert.equal((updated as any).version, 2);
86
+ const refetched = await withTenant(tenantId, (tx) => (tx as any).screen.findUnique({ where: { id: (created as any).id } }));
87
+ assert.equal(refetched.discoveryMethod, 'SEED_URL');
88
+ });