@yasserkhanorg/e2e-agents 1.3.2 → 1.5.0

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 (93) hide show
  1. package/README.md +40 -9
  2. package/dist/agent/feedback.d.ts +16 -0
  3. package/dist/agent/feedback.d.ts.map +1 -1
  4. package/dist/agent/feedback.js +62 -0
  5. package/dist/agent/process_runner.d.ts +1 -1
  6. package/dist/agent/process_runner.d.ts.map +1 -1
  7. package/dist/agent/process_runner.js +3 -3
  8. package/dist/api.d.ts.map +1 -1
  9. package/dist/api.js +5 -2
  10. package/dist/cli/commands/train.d.ts +3 -0
  11. package/dist/cli/commands/train.d.ts.map +1 -0
  12. package/dist/cli/commands/train.js +307 -0
  13. package/dist/cli/parse_args.d.ts.map +1 -1
  14. package/dist/cli/parse_args.js +7 -1
  15. package/dist/cli/types.d.ts +6 -1
  16. package/dist/cli/types.d.ts.map +1 -1
  17. package/dist/cli/usage.d.ts.map +1 -1
  18. package/dist/cli/usage.js +7 -1
  19. package/dist/cli.js +5 -0
  20. package/dist/engine/plan_builder.d.ts +2 -1
  21. package/dist/engine/plan_builder.d.ts.map +1 -1
  22. package/dist/engine/plan_builder.js +22 -9
  23. package/dist/esm/agent/feedback.js +61 -0
  24. package/dist/esm/agent/process_runner.js +3 -3
  25. package/dist/esm/api.js +5 -2
  26. package/dist/esm/cli/commands/train.js +271 -0
  27. package/dist/esm/cli/parse_args.js +7 -1
  28. package/dist/esm/cli/usage.js +7 -1
  29. package/dist/esm/cli.js +5 -0
  30. package/dist/esm/engine/plan_builder.js +22 -9
  31. package/dist/esm/index.js +6 -1
  32. package/dist/esm/knowledge/route_families.js +2 -2
  33. package/dist/esm/pipeline/spec_verifier.js +75 -0
  34. package/dist/esm/pipeline/stage3_generation.js +122 -4
  35. package/dist/esm/pipeline/stage4_heal.js +146 -3
  36. package/dist/esm/prompts/heal.js +4 -0
  37. package/dist/esm/qa-agent/phase2/agent_loop.js +60 -24
  38. package/dist/esm/qa-agent/phase2/exploration_state.js +21 -0
  39. package/dist/esm/qa-agent/phase2/tools.js +99 -1
  40. package/dist/esm/qa-agent/phase3/reporter.js +31 -4
  41. package/dist/esm/training/enricher.js +273 -0
  42. package/dist/esm/training/merger.js +137 -0
  43. package/dist/esm/training/scanner.js +386 -0
  44. package/dist/esm/training/types.js +6 -0
  45. package/dist/esm/training/validator.js +153 -0
  46. package/dist/esm/validation/guardrails.js +1 -0
  47. package/dist/index.d.ts +7 -2
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +16 -1
  50. package/dist/knowledge/route_families.d.ts +2 -0
  51. package/dist/knowledge/route_families.d.ts.map +1 -1
  52. package/dist/knowledge/route_families.js +2 -0
  53. package/dist/pipeline/orchestrator.d.ts.map +1 -1
  54. package/dist/pipeline/spec_verifier.d.ts +20 -0
  55. package/dist/pipeline/spec_verifier.d.ts.map +1 -0
  56. package/dist/pipeline/spec_verifier.js +79 -0
  57. package/dist/pipeline/stage3_generation.d.ts +10 -0
  58. package/dist/pipeline/stage3_generation.d.ts.map +1 -1
  59. package/dist/pipeline/stage3_generation.js +120 -2
  60. package/dist/pipeline/stage4_heal.d.ts +4 -0
  61. package/dist/pipeline/stage4_heal.d.ts.map +1 -1
  62. package/dist/pipeline/stage4_heal.js +145 -2
  63. package/dist/prompts/heal.d.ts +2 -0
  64. package/dist/prompts/heal.d.ts.map +1 -1
  65. package/dist/prompts/heal.js +4 -0
  66. package/dist/qa-agent/phase2/agent_loop.d.ts.map +1 -1
  67. package/dist/qa-agent/phase2/agent_loop.js +60 -24
  68. package/dist/qa-agent/phase2/exploration_state.d.ts.map +1 -1
  69. package/dist/qa-agent/phase2/exploration_state.js +21 -0
  70. package/dist/qa-agent/phase2/tools.d.ts.map +1 -1
  71. package/dist/qa-agent/phase2/tools.js +99 -1
  72. package/dist/qa-agent/phase3/reporter.js +31 -4
  73. package/dist/qa-agent/types.d.ts +9 -1
  74. package/dist/qa-agent/types.d.ts.map +1 -1
  75. package/dist/training/enricher.d.ts +15 -0
  76. package/dist/training/enricher.d.ts.map +1 -0
  77. package/dist/training/enricher.js +278 -0
  78. package/dist/training/merger.d.ts +5 -0
  79. package/dist/training/merger.d.ts.map +1 -0
  80. package/dist/training/merger.js +141 -0
  81. package/dist/training/scanner.d.ts +5 -0
  82. package/dist/training/scanner.d.ts.map +1 -0
  83. package/dist/training/scanner.js +391 -0
  84. package/dist/training/types.d.ts +109 -0
  85. package/dist/training/types.d.ts.map +1 -0
  86. package/dist/training/types.js +9 -0
  87. package/dist/training/validator.d.ts +16 -0
  88. package/dist/training/validator.d.ts.map +1 -0
  89. package/dist/training/validator.js +160 -0
  90. package/dist/validation/guardrails.d.ts +2 -0
  91. package/dist/validation/guardrails.d.ts.map +1 -1
  92. package/dist/validation/guardrails.js +4 -1
  93. package/package.json +1 -1
@@ -8,6 +8,7 @@ export function createExplorationState(flows, timeLimitMs, budgetUSD) {
8
8
  flowsExplored: [],
9
9
  currentFlow: null,
10
10
  findings: [],
11
+ findingDedupIndex: {},
11
12
  actionsLog: [],
12
13
  recentActions: [],
13
14
  tokensUsed: 0,
@@ -24,7 +25,27 @@ export function recordAction(state, action) {
24
25
  state.recentActions.shift();
25
26
  }
26
27
  }
28
+ /**
29
+ * Hash a finding on (type + severity + normalizedSummary + urlPattern) for dedup.
30
+ */
31
+ function findingDedupKey(finding) {
32
+ // Normalize: lowercase, collapse whitespace, strip trailing punctuation
33
+ const normalizedSummary = finding.summary.toLowerCase().replace(/\s+/g, ' ').replace(/[.!?]+$/, '').trim();
34
+ // Extract URL pattern: strip query params and hash, replace path segments that look like IDs
35
+ const urlPattern = finding.evidence.url
36
+ .replace(/[?#].*$/, '')
37
+ .replace(/\/[a-z0-9]{20,}/gi, '/{id}')
38
+ .replace(/\/\d{2,}/g, '/{id}');
39
+ return `${finding.type}|${finding.severity}|${normalizedSummary}|${urlPattern}`;
40
+ }
27
41
  export function recordFinding(state, finding) {
42
+ const key = findingDedupKey(finding);
43
+ const existingIdx = state.findingDedupIndex[key];
44
+ if (existingIdx !== undefined && existingIdx < state.findings.length) {
45
+ state.findings[existingIdx].duplicateCount = (state.findings[existingIdx].duplicateCount || 1) + 1;
46
+ return;
47
+ }
48
+ state.findingDedupIndex[key] = state.findings.length;
28
49
  state.findings.push(finding);
29
50
  }
30
51
  export function markFlowExplored(state, flowId) {
@@ -94,7 +94,7 @@ export const TOOL_DEFINITIONS = [
94
94
  },
95
95
  {
96
96
  name: 'report_finding',
97
- description: 'Report a bug, visual issue, UX problem, or gap you discovered. Always include current URL and repro steps.',
97
+ description: 'Report a bug, visual issue, UX problem, or gap you discovered. Include expected/actual behavior and repro steps. Take before/after screenshots before calling this.',
98
98
  input_schema: {
99
99
  type: 'object',
100
100
  properties: {
@@ -106,6 +106,13 @@ export const TOOL_DEFINITIONS = [
106
106
  items: { type: 'string' },
107
107
  description: 'Steps to reproduce',
108
108
  },
109
+ screenshot_refs: {
110
+ type: 'array',
111
+ items: { type: 'string' },
112
+ description: 'Paths to before/after screenshots (from take_screenshot)',
113
+ },
114
+ expected_behavior: { type: 'string', description: 'What should have happened' },
115
+ actual_behavior: { type: 'string', description: 'What actually happened' },
109
116
  },
110
117
  required: ['type', 'severity', 'summary', 'repro_steps'],
111
118
  },
@@ -133,6 +140,23 @@ export const TOOL_DEFINITIONS = [
133
140
  required: ['role'],
134
141
  },
135
142
  },
143
+ {
144
+ name: 'wait_for',
145
+ description: 'Wait for an element condition or page state. Use after actions that trigger async changes (navigation, API calls, animations).',
146
+ input_schema: {
147
+ type: 'object',
148
+ properties: {
149
+ condition: {
150
+ type: 'string',
151
+ enum: ['visible', 'hidden', 'stable', 'networkidle'],
152
+ description: 'What to wait for: visible/hidden (element state), stable (no DOM changes for 1s), networkidle (no pending requests)',
153
+ },
154
+ ref: { type: 'string', description: 'Accessibility ref for element conditions (visible/hidden). Not needed for stable/networkidle.' },
155
+ timeout_ms: { type: 'number', description: 'Max wait time in ms (default 5000, max 15000)' },
156
+ },
157
+ required: ['condition'],
158
+ },
159
+ },
136
160
  ];
137
161
  export function executeTool(ctx, name, input) {
138
162
  switch (name) {
@@ -204,6 +228,36 @@ export function executeTool(ctx, name, input) {
204
228
  if (!Array.isArray(input.repro_steps)) {
205
229
  return { output: `Invalid repro_steps: expected an array of strings.` };
206
230
  }
231
+ // Auto-capture console errors at time of finding
232
+ let autoConsoleErrors;
233
+ try {
234
+ const raw = ctx.browser.evaluateInternal('JSON.stringify(window.__consoleErrors || [])');
235
+ const parsed = JSON.parse(raw);
236
+ if (Array.isArray(parsed) && parsed.length > 0) {
237
+ autoConsoleErrors = parsed.map(String).slice(-10);
238
+ }
239
+ }
240
+ catch {
241
+ // Console error capture not available
242
+ }
243
+ // Auto-take screenshot if none provided
244
+ let autoScreenshot;
245
+ const screenshotRefs = Array.isArray(input.screenshot_refs)
246
+ ? input.screenshot_refs.map(String)
247
+ : undefined;
248
+ if (!screenshotRefs || screenshotRefs.length === 0) {
249
+ try {
250
+ const nextCount = ctx.screenshotCounter + 1;
251
+ const filename = `${String(nextCount).padStart(3, '0')}-finding-auto.png`;
252
+ const screenshotPath = `${ctx.screenshotDir}/${filename}`;
253
+ ctx.browser.screenshot(screenshotPath);
254
+ ctx.screenshotCounter = nextCount;
255
+ autoScreenshot = screenshotPath;
256
+ }
257
+ catch {
258
+ autoScreenshot = undefined;
259
+ }
260
+ }
207
261
  const finding = {
208
262
  id: `f-${crypto.randomUUID()}`,
209
263
  type: rawType,
@@ -213,6 +267,11 @@ export function executeTool(ctx, name, input) {
213
267
  evidence: {
214
268
  url: ctx.currentUrl,
215
269
  reproSteps: input.repro_steps.map(String),
270
+ screenshotRefs: screenshotRefs || (autoScreenshot ? [autoScreenshot] : undefined),
271
+ screenshotPath: autoScreenshot || (screenshotRefs ? screenshotRefs[0] : undefined),
272
+ consoleErrors: autoConsoleErrors,
273
+ expectedBehavior: input.expected_behavior ? String(input.expected_behavior) : undefined,
274
+ actualBehavior: input.actual_behavior ? String(input.actual_behavior) : undefined,
216
275
  },
217
276
  timestamp: Date.now(),
218
277
  };
@@ -229,6 +288,45 @@ export function executeTool(ctx, name, input) {
229
288
  flowDone: { flowId, status: rawStatus },
230
289
  };
231
290
  }
291
+ case 'wait_for': {
292
+ const condition = String(input.condition || '');
293
+ const VALID_CONDITIONS = new Set(['visible', 'hidden', 'stable', 'networkidle']);
294
+ if (!VALID_CONDITIONS.has(condition)) {
295
+ return { output: `Invalid condition "${condition}". Must be one of: ${[...VALID_CONDITIONS].join(', ')}.` };
296
+ }
297
+ const timeoutMs = Math.min(Math.max(Number(input.timeout_ms) || 5000, 500), 15000);
298
+ try {
299
+ if (condition === 'stable' || condition === 'networkidle') {
300
+ const waitMs = condition === 'networkidle' ? Math.min(timeoutMs, 3000) : 1000;
301
+ ctx.browser.evaluateInternal(`new Promise(r => setTimeout(r, ${waitMs}))`);
302
+ return { output: `Waited ${waitMs}ms for ${condition} (heuristic delay)` };
303
+ }
304
+ // Element-level wait: poll snapshot for ref presence/absence
305
+ const ref = input.ref ? String(input.ref) : undefined;
306
+ if (!ref) {
307
+ return { output: `Element condition "${condition}" requires a ref parameter.` };
308
+ }
309
+ const start = Date.now();
310
+ const wantVisible = condition === 'visible';
311
+ // Use word-boundary regex to avoid false positives (@e1 matching @e10)
312
+ const refPattern = new RegExp(`(?<![\\w@])${ref.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w])`);
313
+ const pollIntervalMs = 300;
314
+ while (Date.now() - start < timeoutMs) {
315
+ const snap = ctx.browser.snapshot();
316
+ const found = refPattern.test(snap);
317
+ if ((wantVisible && found) || (!wantVisible && !found)) {
318
+ return { output: `Element ${ref} is now ${condition} (took ${Date.now() - start}ms)` };
319
+ }
320
+ // Synchronous in-process sleep via Atomics.wait (available in Node.js 8.10+)
321
+ const buf = new SharedArrayBuffer(4);
322
+ Atomics.wait(new Int32Array(buf), 0, 0, pollIntervalMs);
323
+ }
324
+ return { output: `Timeout: element ${ref} did not become ${condition} within ${timeoutMs}ms` };
325
+ }
326
+ catch (err) {
327
+ return { output: `wait_for error: ${String(err)}` };
328
+ }
329
+ }
232
330
  case 'switch_user': {
233
331
  const role = String(input.role);
234
332
  const user = ctx.users?.find((u) => u.role === role);
@@ -80,16 +80,43 @@ function renderMarkdown(report) {
80
80
  if (report.phase2.findings.length > 0) {
81
81
  lines.push(`## Findings`, '');
82
82
  for (const f of report.phase2.findings) {
83
- lines.push(`### [${f.severity.toUpperCase()}] ${f.summary}`);
83
+ const dupNote = f.duplicateCount && f.duplicateCount > 1
84
+ ? ` (seen ${f.duplicateCount} times)`
85
+ : '';
86
+ lines.push(`### [${f.severity.toUpperCase()}] ${f.summary}${dupNote}`);
84
87
  lines.push('');
85
88
  lines.push(`- **Type:** ${f.type}`);
86
89
  lines.push(`- **Flow:** ${f.flow}`);
87
90
  lines.push(`- **URL:** ${f.evidence.url}`);
88
- if (f.evidence.screenshotPath) {
89
- lines.push(`- **Screenshot:** ${f.evidence.screenshotPath}`);
91
+ // Expected vs actual behavior
92
+ if (f.evidence.expectedBehavior || f.evidence.actualBehavior) {
93
+ const escapePipe = (s) => s.replace(/\|/g, '\\|');
94
+ lines.push('');
95
+ lines.push(`| Expected | Actual |`);
96
+ lines.push(`|----------|--------|`);
97
+ lines.push(`| ${escapePipe(f.evidence.expectedBehavior || '—')} | ${escapePipe(f.evidence.actualBehavior || '—')} |`);
98
+ lines.push('');
99
+ }
100
+ // Screenshot evidence (inline images)
101
+ if (f.evidence.screenshotRefs && f.evidence.screenshotRefs.length > 0) {
102
+ for (const ref of f.evidence.screenshotRefs) {
103
+ lines.push(`![Evidence](${ref})`);
104
+ }
105
+ }
106
+ else if (f.evidence.screenshotPath) {
107
+ lines.push(`![Evidence](${f.evidence.screenshotPath})`);
108
+ }
109
+ // Console errors
110
+ if (f.evidence.consoleErrors && f.evidence.consoleErrors.length > 0) {
111
+ lines.push('');
112
+ lines.push('**Console errors:**');
113
+ for (const err of f.evidence.consoleErrors.slice(0, 5)) {
114
+ lines.push(`- \`${err.replace(/`/g, '\\`')}\``);
115
+ }
90
116
  }
91
117
  if (f.evidence.reproSteps.length > 0) {
92
- lines.push('- **Repro steps:**');
118
+ lines.push('');
119
+ lines.push('**Repro steps:**');
93
120
  for (const step of f.evidence.reproSteps) {
94
121
  lines.push(` 1. ${step}`);
95
122
  }
@@ -0,0 +1,273 @@
1
+ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
2
+ // See LICENSE.txt for license information.
3
+ import { lstatSync, readdirSync, readFileSync } from 'fs';
4
+ import { join, relative, resolve } from 'path';
5
+ import { isGuessedRoute } from './types.js';
6
+ const MAX_FILES_PER_FAMILY = 20;
7
+ const MAX_LINES_PER_FILE = 50;
8
+ const LLM_TIMEOUT_MS = 60000;
9
+ const MAX_PROMPT_CHARS = 100000;
10
+ const SENSITIVE_PATTERNS = [
11
+ /[._]env/, /secret/i, /credential/i, /\.pem$/, /\.key$/, /password/i,
12
+ /config\/secrets/, /fixtures\/.*auth/i, /\.npmrc/, /\.netrc/,
13
+ /id_rsa/, /id_ed25519/, /\.p12$/, /\.pfx$/, /tokens?\.json/i,
14
+ ];
15
+ const SKIP_DIRS = new Set([
16
+ 'node_modules', '.git', 'dist', 'build', '.next', '.nuxt', 'coverage',
17
+ ]);
18
+ function sampleFiles(dir, maxFiles) {
19
+ const files = [];
20
+ function walk(d, depth = 0, maxDepth = 10) {
21
+ if (files.length >= maxFiles)
22
+ return;
23
+ if (depth > maxDepth)
24
+ return;
25
+ try {
26
+ for (const entry of readdirSync(d)) {
27
+ if (files.length >= maxFiles)
28
+ return;
29
+ // Skip dot-dirs and known heavy directories
30
+ if (entry.startsWith('.') || SKIP_DIRS.has(entry))
31
+ continue;
32
+ const full = join(d, entry);
33
+ try {
34
+ // Skip symlinks
35
+ const lstat = lstatSync(full);
36
+ if (lstat.isSymbolicLink())
37
+ continue;
38
+ // Skip sensitive files (test against relative path from scan root)
39
+ const relPath = relative(dir, full);
40
+ if (SENSITIVE_PATTERNS.some((p) => p.test(relPath) || p.test(entry)))
41
+ continue;
42
+ if (lstat.isDirectory()) {
43
+ walk(full, depth + 1, maxDepth);
44
+ }
45
+ else if (lstat.isFile() && lstat.size < 50000) {
46
+ const ext = entry.slice(entry.lastIndexOf('.'));
47
+ if (['.ts', '.tsx', '.js', '.jsx', '.go', '.py', '.rs'].includes(ext)) {
48
+ const content = readFileSync(full, 'utf-8');
49
+ const lines = content.split('\n').slice(0, MAX_LINES_PER_FILE).join('\n');
50
+ files.push({ path: full, content: lines });
51
+ }
52
+ }
53
+ }
54
+ catch { /* skip */ }
55
+ }
56
+ }
57
+ catch { /* skip */ }
58
+ }
59
+ walk(dir);
60
+ return files;
61
+ }
62
+ function buildEnrichPrompt(families, projectRoot) {
63
+ const sections = [];
64
+ for (const family of families) {
65
+ const allDirs = [
66
+ ...family.webappPaths.map((p) => p.replace(/\/?\*.*$/, '')),
67
+ ...family.serverPaths.map((p) => p.replace(/\/?\*.*$/, '')),
68
+ ];
69
+ const samples = [];
70
+ for (const dir of allDirs) {
71
+ if (!dir)
72
+ continue;
73
+ const fullDir = join(resolve(projectRoot), dir);
74
+ samples.push(...sampleFiles(fullDir, MAX_FILES_PER_FAMILY - samples.length));
75
+ if (samples.length >= MAX_FILES_PER_FAMILY)
76
+ break;
77
+ }
78
+ // Sample spec descriptions
79
+ const specSamples = [];
80
+ for (const specDir of family.specDirs) {
81
+ const fullDir = join(resolve(projectRoot), specDir);
82
+ const specFiles = sampleFiles(fullDir, 5);
83
+ for (const sf of specFiles) {
84
+ const matches = sf.content.match(/(?:test|it|describe)\s*\(\s*['"`]([^'"`]+)/g);
85
+ if (matches) {
86
+ specSamples.push(...matches.map((m) => m.replace(/(?:test|it|describe)\s*\(\s*['"`]/, '')));
87
+ }
88
+ }
89
+ }
90
+ sections.push(`## Family: ${family.id}
91
+ Routes (guessed): ${JSON.stringify(family.routes)}
92
+ Webapp paths: ${JSON.stringify(family.webappPaths)}
93
+ Server paths: ${JSON.stringify(family.serverPaths)}
94
+ Spec dirs: ${JSON.stringify(family.specDirs)}
95
+ Tags: ${JSON.stringify(family.tags)}
96
+ Features: ${family.features.map((f) => f.id).join(', ') || 'none'}
97
+
98
+ Sample files (${samples.length}):
99
+ ${samples.map((s) => `### ${relative(projectRoot, s.path)}\n\`\`\`\n${s.content}\n\`\`\``).join('\n')}
100
+
101
+ Test descriptions:
102
+ ${specSamples.length > 0 ? specSamples.map((d) => `- ${d}`).join('\n') : '(none found)'}
103
+ `);
104
+ }
105
+ return `You are analyzing a codebase to enrich route-family definitions for an E2E test impact analysis tool.
106
+
107
+ For each family below, provide:
108
+ 1. **priority**: P0 (critical user flow), P1 (important), or P2 (nice-to-have)
109
+ 2. **userFlows**: Array of human-readable flow names (e.g., "Create channel", "Search messages")
110
+ 3. **routes**: Improved URL patterns (e.g., "/{team}/channels/{channel}" instead of "/channels")
111
+ 4. **pageObjects**: Array of page object class names found in the code
112
+ 5. **components**: Array of UI component names relevant to this family
113
+
114
+ Respond in JSON format:
115
+ \`\`\`json
116
+ [
117
+ {
118
+ "id": "family_id",
119
+ "priority": "P0",
120
+ "userFlows": ["Flow name 1", "Flow name 2"],
121
+ "routes": ["/improved/route/{param}"],
122
+ "pageObjects": ["PageName"],
123
+ "components": ["ComponentName"]
124
+ }
125
+ ]
126
+ \`\`\`
127
+
128
+ ${sections.join('\n---\n')}`;
129
+ }
130
+ export function validateEntries(parsed) {
131
+ const filterStrings = (arr, maxLen) => {
132
+ if (!Array.isArray(arr))
133
+ return undefined;
134
+ const filtered = arr.filter((v) => typeof v === 'string' && v.length < maxLen);
135
+ return filtered.length > 0 ? filtered : undefined;
136
+ };
137
+ return parsed
138
+ .filter((e) => !!e && typeof e.id === 'string')
139
+ .map((entry) => ({
140
+ id: entry.id,
141
+ priority: ['P0', 'P1', 'P2'].includes(entry.priority) ? entry.priority : undefined,
142
+ routes: filterStrings(entry.routes, 200),
143
+ userFlows: filterStrings(entry.userFlows, 500),
144
+ pageObjects: filterStrings(entry.pageObjects, 200),
145
+ components: filterStrings(entry.components, 200),
146
+ }));
147
+ }
148
+ export function parseEnrichResponse(response) {
149
+ // Extract JSON from response (may be wrapped in markdown code block)
150
+ const jsonMatch = response.match(/```(?:json)?\s*([\s\S]*?)```/) || [null, response];
151
+ const jsonStr = jsonMatch[1]?.trim() || response.trim();
152
+ try {
153
+ const parsed = JSON.parse(jsonStr);
154
+ if (Array.isArray(parsed)) {
155
+ return validateEntries(parsed);
156
+ }
157
+ }
158
+ catch {
159
+ // Try to find any JSON array in the response
160
+ const arrayMatch = response.match(/\[[\s\S]*\]/);
161
+ if (arrayMatch) {
162
+ try {
163
+ const parsed = JSON.parse(arrayMatch[0]);
164
+ if (Array.isArray(parsed)) {
165
+ return validateEntries(parsed);
166
+ }
167
+ }
168
+ catch {
169
+ // give up
170
+ }
171
+ }
172
+ }
173
+ return [];
174
+ }
175
+ function applyEnrichment(family, enriched) {
176
+ const result = { ...family };
177
+ if (enriched.priority && !family.priority) {
178
+ result.priority = enriched.priority;
179
+ }
180
+ if (enriched.userFlows && (!family.userFlows || family.userFlows.length === 0)) {
181
+ result.userFlows = enriched.userFlows;
182
+ }
183
+ if (enriched.routes && enriched.routes.length > 0) {
184
+ // Only replace if current routes look like guesses
185
+ if (isGuessedRoute(family.routes)) {
186
+ result.routes = enriched.routes;
187
+ }
188
+ }
189
+ if (enriched.pageObjects && (!family.pageObjects || family.pageObjects.length === 0)) {
190
+ result.pageObjects = enriched.pageObjects;
191
+ }
192
+ if (enriched.components && (!family.components || family.components.length === 0)) {
193
+ result.components = enriched.components;
194
+ }
195
+ return result;
196
+ }
197
+ export async function enrichFamilies(families, scanned, projectRoot, provider, budgetUSD) {
198
+ const scannedMap = new Map(scanned.map((s) => [s.id, s]));
199
+ const enriched = [];
200
+ let totalTokens = 0;
201
+ let totalCost = 0;
202
+ const skipped = [];
203
+ // Process in chunks of 4 families
204
+ const chunkSize = 4;
205
+ for (let i = 0; i < families.length; i += chunkSize) {
206
+ if (totalCost >= budgetUSD) {
207
+ for (let j = i; j < families.length; j++) {
208
+ skipped.push(families[j].id);
209
+ enriched.push(families[j]);
210
+ }
211
+ break;
212
+ }
213
+ const chunk = families.slice(i, i + chunkSize);
214
+ const scannedChunk = chunk
215
+ .map((f) => scannedMap.get(f.id))
216
+ .filter((s) => s !== undefined);
217
+ if (scannedChunk.length === 0) {
218
+ enriched.push(...chunk);
219
+ continue;
220
+ }
221
+ let prompt = buildEnrichPrompt(scannedChunk, projectRoot);
222
+ if (prompt.length > MAX_PROMPT_CHARS) {
223
+ // Truncate at the last complete section boundary to avoid malformed input
224
+ const lastSectionEnd = prompt.lastIndexOf('\n---\n', MAX_PROMPT_CHARS);
225
+ if (lastSectionEnd > 0) {
226
+ console.warn(`[train] Prompt truncated from ${prompt.length} chars at section boundary`);
227
+ prompt = prompt.slice(0, lastSectionEnd);
228
+ }
229
+ else {
230
+ console.warn(`[train] Prompt truncated from ${prompt.length} to ${MAX_PROMPT_CHARS} chars`);
231
+ prompt = prompt.slice(0, MAX_PROMPT_CHARS);
232
+ }
233
+ }
234
+ let timer;
235
+ try {
236
+ const timeoutPromise = new Promise((_, reject) => {
237
+ timer = setTimeout(() => reject(new Error('LLM request timed out')), LLM_TIMEOUT_MS);
238
+ });
239
+ const response = await Promise.race([
240
+ provider.generateText(prompt, { maxTokens: 4096, temperature: 0.3 }),
241
+ timeoutPromise,
242
+ ]);
243
+ totalTokens += (response.usage?.inputTokens ?? 0) + (response.usage?.outputTokens ?? 0);
244
+ totalCost += response.cost ?? 0;
245
+ const entries = parseEnrichResponse(response.text);
246
+ const entryMap = new Map(entries.map((e) => [e.id, e]));
247
+ for (const family of chunk) {
248
+ const entry = entryMap.get(family.id);
249
+ if (entry) {
250
+ enriched.push(applyEnrichment(family, entry));
251
+ }
252
+ else {
253
+ enriched.push(family);
254
+ }
255
+ }
256
+ }
257
+ catch (error) {
258
+ // On LLM failure, keep families unchanged
259
+ console.warn(`[train] LLM enrichment failed for chunk: ${error instanceof Error ? error.message : String(error)}`);
260
+ enriched.push(...chunk);
261
+ }
262
+ finally {
263
+ if (timer)
264
+ clearTimeout(timer);
265
+ }
266
+ }
267
+ return {
268
+ enrichedFamilies: enriched,
269
+ tokensUsed: totalTokens,
270
+ costUSD: Math.round(totalCost * 100) / 100,
271
+ skippedFamilies: skipped,
272
+ };
273
+ }
@@ -0,0 +1,137 @@
1
+ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
2
+ // See LICENSE.txt for license information.
3
+ import { existsSync } from 'fs';
4
+ import { join, resolve } from 'path';
5
+ import { isGuessedRoute } from './types.js';
6
+ function unionArrays(existing, incoming) {
7
+ const set = new Set(existing || []);
8
+ for (const item of incoming) {
9
+ set.add(item);
10
+ }
11
+ return Array.from(set);
12
+ }
13
+ function mergeFamily(existing, scanned) {
14
+ const merged = { ...existing };
15
+ // Structural fields: union arrays
16
+ merged.webappPaths = unionArrays(existing.webappPaths, scanned.webappPaths);
17
+ merged.serverPaths = unionArrays(existing.serverPaths, scanned.serverPaths);
18
+ merged.specDirs = unionArrays(existing.specDirs, scanned.specDirs);
19
+ merged.cypressSpecDirs = unionArrays(existing.cypressSpecDirs, scanned.cypressSpecDirs);
20
+ merged.tags = unionArrays(existing.tags, scanned.tags);
21
+ // Routes: only update if existing looks like a guess
22
+ if (isGuessedRoute(existing.routes) && !isGuessedRoute(scanned.routes)) {
23
+ merged.routes = scanned.routes;
24
+ }
25
+ // Human-curated fields: never overwrite (priority, userFlows, pageObjects, components)
26
+ // Merge features
27
+ if (scanned.features.length > 0) {
28
+ const existingFeatures = existing.features || [];
29
+ const existingIds = new Set(existingFeatures.map((f) => f.id));
30
+ const newFeatures = scanned.features.filter((f) => !existingIds.has(f.id));
31
+ if (newFeatures.length > 0) {
32
+ merged.features = [
33
+ ...existingFeatures,
34
+ ...newFeatures.map((f) => ({
35
+ id: f.id,
36
+ webappPaths: f.webappPaths,
37
+ serverPaths: f.serverPaths,
38
+ specDirs: f.specDirs,
39
+ })),
40
+ ];
41
+ }
42
+ }
43
+ return merged;
44
+ }
45
+ function scannedToRouteFamily(scanned) {
46
+ const family = {
47
+ id: scanned.id,
48
+ routes: scanned.routes,
49
+ };
50
+ if (scanned.webappPaths.length > 0)
51
+ family.webappPaths = scanned.webappPaths;
52
+ if (scanned.serverPaths.length > 0)
53
+ family.serverPaths = scanned.serverPaths;
54
+ if (scanned.specDirs.length > 0)
55
+ family.specDirs = scanned.specDirs;
56
+ if (scanned.cypressSpecDirs.length > 0)
57
+ family.cypressSpecDirs = scanned.cypressSpecDirs;
58
+ if (scanned.tags.length > 0)
59
+ family.tags = scanned.tags;
60
+ if (scanned.features.length > 0) {
61
+ family.features = scanned.features.map((f) => ({
62
+ id: f.id,
63
+ webappPaths: f.webappPaths.length > 0 ? f.webappPaths : undefined,
64
+ serverPaths: f.serverPaths.length > 0 ? f.serverPaths : undefined,
65
+ specDirs: f.specDirs.length > 0 ? f.specDirs : undefined,
66
+ }));
67
+ }
68
+ return family;
69
+ }
70
+ export function mergeFamilies(existing, scanned) {
71
+ const existingFamilies = existing?.families || [];
72
+ const existingMap = new Map(existingFamilies.map((f) => [f.id, f]));
73
+ const scannedMap = new Map(scanned.map((f) => [f.id, f]));
74
+ const newFamilies = [];
75
+ const updatedFamilies = [];
76
+ const mergedFamilies = [];
77
+ // Process existing families
78
+ for (const ef of existingFamilies) {
79
+ const sf = scannedMap.get(ef.id);
80
+ if (sf) {
81
+ mergedFamilies.push(mergeFamily(ef, sf));
82
+ updatedFamilies.push(ef.id);
83
+ }
84
+ else {
85
+ // Keep untouched
86
+ mergedFamilies.push({ ...ef });
87
+ }
88
+ }
89
+ // Add new families from scanner
90
+ for (const sf of scanned) {
91
+ if (!existingMap.has(sf.id)) {
92
+ mergedFamilies.push(scannedToRouteFamily(sf));
93
+ newFamilies.push(sf.id);
94
+ }
95
+ }
96
+ const parts = [];
97
+ if (updatedFamilies.length > 0)
98
+ parts.push(`${updatedFamilies.length} families updated`);
99
+ if (newFamilies.length > 0)
100
+ parts.push(`${newFamilies.length} new families added`);
101
+ if (parts.length === 0)
102
+ parts.push('no changes');
103
+ return {
104
+ manifest: { families: mergedFamilies, source: existing?.source || 'train-scan' },
105
+ newFamilies,
106
+ updatedFamilies,
107
+ staleFamilies: [],
108
+ summary: parts.join(', '),
109
+ };
110
+ }
111
+ export function detectStaleFamilies(manifest, projectRoot) {
112
+ const resolved = resolve(projectRoot);
113
+ const stale = [];
114
+ for (const family of manifest.families) {
115
+ const allPatterns = [
116
+ ...(family.webappPaths || []),
117
+ ...(family.serverPaths || []),
118
+ ...(family.specDirs || []),
119
+ ];
120
+ if (allPatterns.length === 0)
121
+ continue;
122
+ // Check if any pattern resolves to existing files/dirs
123
+ let hasAny = false;
124
+ for (const pattern of allPatterns) {
125
+ // Strip trailing glob (* or **) to get the directory
126
+ const dirPart = pattern.replace(/\/?\*.*$/, '');
127
+ if (dirPart && existsSync(join(resolved, dirPart))) {
128
+ hasAny = true;
129
+ break;
130
+ }
131
+ }
132
+ if (!hasAny) {
133
+ stale.push(family.id);
134
+ }
135
+ }
136
+ return stale;
137
+ }