@yasserkhanorg/e2e-agents 1.9.5 → 1.10.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 (36) hide show
  1. package/dist/esm/knowledge/api_surface.js +265 -34
  2. package/dist/esm/knowledge/failure_history.js +121 -0
  3. package/dist/esm/knowledge/route_families.js +31 -1
  4. package/dist/esm/pipeline/stage1_impact.js +19 -3
  5. package/dist/esm/pipeline/stage2_coverage.js +28 -7
  6. package/dist/esm/pipeline/stage3_generation.js +20 -1
  7. package/dist/esm/prompts/coverage.js +10 -0
  8. package/dist/esm/prompts/generation.js +41 -7
  9. package/dist/esm/validation/guardrails.js +5 -0
  10. package/dist/knowledge/api_surface.d.ts +12 -0
  11. package/dist/knowledge/api_surface.d.ts.map +1 -1
  12. package/dist/knowledge/api_surface.js +268 -34
  13. package/dist/knowledge/failure_history.d.ts +39 -0
  14. package/dist/knowledge/failure_history.d.ts.map +1 -0
  15. package/dist/knowledge/failure_history.js +128 -0
  16. package/dist/knowledge/route_families.d.ts +11 -0
  17. package/dist/knowledge/route_families.d.ts.map +1 -1
  18. package/dist/knowledge/route_families.js +32 -1
  19. package/dist/pipeline/stage1_impact.d.ts +1 -1
  20. package/dist/pipeline/stage1_impact.d.ts.map +1 -1
  21. package/dist/pipeline/stage1_impact.js +18 -2
  22. package/dist/pipeline/stage2_coverage.d.ts.map +1 -1
  23. package/dist/pipeline/stage2_coverage.js +28 -7
  24. package/dist/pipeline/stage3_generation.d.ts.map +1 -1
  25. package/dist/pipeline/stage3_generation.js +20 -1
  26. package/dist/prompts/coverage.d.ts.map +1 -1
  27. package/dist/prompts/coverage.js +10 -0
  28. package/dist/prompts/generation.d.ts +1 -1
  29. package/dist/prompts/generation.d.ts.map +1 -1
  30. package/dist/prompts/generation.js +41 -7
  31. package/dist/validation/guardrails.d.ts +2 -0
  32. package/dist/validation/guardrails.d.ts.map +1 -1
  33. package/dist/validation/guardrails.js +5 -0
  34. package/dist/validation/output_schema.d.ts +3 -0
  35. package/dist/validation/output_schema.d.ts.map +1 -1
  36. package/package.json +1 -1
@@ -2,39 +2,233 @@
2
2
  // See LICENSE.txt for license information.
3
3
  import { existsSync, readFileSync, readdirSync, writeFileSync } from 'fs';
4
4
  import { join, extname } from 'path';
5
+ import ts from 'typescript';
6
+ // ── TypeScript AST-based extraction ────────────────────────
7
+ function extractMethodsFromAST(sourceFile, checker) {
8
+ const surfaces = [];
9
+ ts.forEachChild(sourceFile, (node) => {
10
+ if (!ts.isClassDeclaration(node) || !node.name) {
11
+ return;
12
+ }
13
+ const className = node.name.text;
14
+ const methods = [];
15
+ const seen = new Set();
16
+ // Get base class name if extends
17
+ let extendsName;
18
+ if (node.heritageClauses) {
19
+ for (const clause of node.heritageClauses) {
20
+ if (clause.token === ts.SyntaxKind.ExtendsKeyword && clause.types.length > 0) {
21
+ const expr = clause.types[0].expression;
22
+ if (ts.isIdentifier(expr)) {
23
+ extendsName = expr.text;
24
+ }
25
+ }
26
+ }
27
+ }
28
+ for (const member of node.members) {
29
+ // Skip constructor
30
+ if (ts.isConstructorDeclaration(member)) {
31
+ continue;
32
+ }
33
+ // Skip private/protected members
34
+ const modifiers = ts.canHaveModifiers(member) ? ts.getModifiers(member) : undefined;
35
+ if (modifiers?.some((m) => m.kind === ts.SyntaxKind.PrivateKeyword || m.kind === ts.SyntaxKind.ProtectedKeyword)) {
36
+ continue;
37
+ }
38
+ const name = member.name && ts.isIdentifier(member.name) ? member.name.text : null;
39
+ if (!name || name.startsWith('_') || seen.has(name)) {
40
+ continue;
41
+ }
42
+ seen.add(name);
43
+ if (ts.isMethodDeclaration(member)) {
44
+ const isAsync = modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword) ?? false;
45
+ const params = extractParams(member.parameters, checker);
46
+ const returnType = extractReturnType(member, checker);
47
+ methods.push({
48
+ name,
49
+ kind: 'method',
50
+ async: isAsync ? true : undefined,
51
+ params: params.length > 0 ? params : undefined,
52
+ returnType: returnType || undefined,
53
+ });
54
+ }
55
+ else if (ts.isGetAccessorDeclaration(member)) {
56
+ methods.push({ name, kind: 'getter' });
57
+ }
58
+ else if (ts.isPropertyDeclaration(member)) {
59
+ // Check if it's an arrow function property (e.g., name = async () => {})
60
+ if (member.initializer && (ts.isArrowFunction(member.initializer) || ts.isFunctionExpression(member.initializer))) {
61
+ const fn = member.initializer;
62
+ const fnModifiers = ts.canHaveModifiers(fn) ? ts.getModifiers(fn) : undefined;
63
+ const isAsync = fnModifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword) ?? false;
64
+ const params = extractParams(fn.parameters, checker);
65
+ methods.push({
66
+ name,
67
+ kind: 'method',
68
+ async: isAsync ? true : undefined,
69
+ params: params.length > 0 ? params : undefined,
70
+ });
71
+ }
72
+ else {
73
+ methods.push({ name, kind: 'property' });
74
+ }
75
+ }
76
+ }
77
+ if (methods.length > 0) {
78
+ surfaces.push({
79
+ className,
80
+ file: sourceFile.fileName,
81
+ methods,
82
+ extends: extendsName,
83
+ });
84
+ }
85
+ });
86
+ return surfaces;
87
+ }
88
+ function extractParams(params, checker) {
89
+ return params.map((p) => {
90
+ const name = ts.isIdentifier(p.name) ? p.name.text : p.name.getText();
91
+ const optional = p.questionToken !== undefined || p.initializer !== undefined;
92
+ let type;
93
+ if (p.type) {
94
+ type = p.type.getText();
95
+ }
96
+ else if (checker) {
97
+ try {
98
+ const symbol = checker.getSymbolAtLocation(p.name);
99
+ if (symbol) {
100
+ const t = checker.getTypeOfSymbolAtLocation(symbol, p);
101
+ type = checker.typeToString(t);
102
+ }
103
+ }
104
+ catch {
105
+ // Type inference failure is non-fatal
106
+ }
107
+ }
108
+ return { name, type, optional: optional || undefined };
109
+ });
110
+ }
111
+ function extractReturnType(method, checker) {
112
+ if (method.type) {
113
+ return method.type.getText();
114
+ }
115
+ if (checker) {
116
+ try {
117
+ const signature = checker.getSignatureFromDeclaration(method);
118
+ if (signature) {
119
+ const returnType = checker.getReturnTypeOfSignature(signature);
120
+ const typeStr = checker.typeToString(returnType);
121
+ // Skip overly verbose inferred types
122
+ if (typeStr.length < 100) {
123
+ return typeStr;
124
+ }
125
+ }
126
+ }
127
+ catch {
128
+ // Type inference failure is non-fatal
129
+ }
130
+ }
131
+ return null;
132
+ }
133
+ /**
134
+ * Extract page objects using the TypeScript Compiler API.
135
+ * Falls back to regex if compilation fails.
136
+ */
137
+ function extractWithAST(files) {
138
+ if (files.length === 0) {
139
+ return [];
140
+ }
141
+ // Find the nearest tsconfig.json
142
+ const firstDir = join(files[0], '..');
143
+ const tsconfigPath = ts.findConfigFile(firstDir, ts.sys.fileExists, 'tsconfig.json');
144
+ let program;
145
+ if (tsconfigPath) {
146
+ const configFile = ts.readConfigFile(tsconfigPath, ts.sys.readFile);
147
+ const parsedConfig = ts.parseJsonConfigFileContent(configFile.config, ts.sys, join(tsconfigPath, '..'));
148
+ // Only compile the files we care about, using the config's compiler options
149
+ program = ts.createProgram(files, parsedConfig.options);
150
+ }
151
+ else {
152
+ program = ts.createProgram(files, {
153
+ target: ts.ScriptTarget.ES2022,
154
+ module: ts.ModuleKind.ESNext,
155
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
156
+ strict: true,
157
+ allowJs: false,
158
+ noEmit: true,
159
+ });
160
+ }
161
+ const checker = program.getTypeChecker();
162
+ const surfaces = [];
163
+ for (const filePath of files) {
164
+ const sourceFile = program.getSourceFile(filePath);
165
+ if (!sourceFile) {
166
+ continue;
167
+ }
168
+ surfaces.push(...extractMethodsFromAST(sourceFile, checker));
169
+ }
170
+ // Resolve inherited methods: if class extends another class in the catalog,
171
+ // merge parent methods that the child doesn't override
172
+ resolveInheritance(surfaces);
173
+ return surfaces;
174
+ }
175
+ /**
176
+ * Merge parent class methods into child classes that extend them.
177
+ */
178
+ function resolveInheritance(surfaces) {
179
+ const byName = new Map(surfaces.map((s) => [s.className, s]));
180
+ for (const surface of surfaces) {
181
+ if (!surface.extends) {
182
+ continue;
183
+ }
184
+ const parent = byName.get(surface.extends);
185
+ if (!parent) {
186
+ continue;
187
+ }
188
+ const childMethodNames = new Set(surface.methods.map((m) => m.name));
189
+ for (const parentMethod of parent.methods) {
190
+ if (!childMethodNames.has(parentMethod.name)) {
191
+ surface.methods.push({ ...parentMethod });
192
+ }
193
+ }
194
+ }
195
+ }
196
+ // ── Regex-based extraction (fallback) ──────────────────────
5
197
  const RESERVED_WORDS = new Set([
6
198
  'private', 'protected', 'static', 'abstract', 'override',
7
199
  'if', 'for', 'while', 'switch', 'return',
8
200
  'const', 'let', 'var', 'import', 'export',
9
201
  'class', 'type', 'interface', 'constructor',
10
202
  ]);
11
- function extractMethodsFromSource(content) {
203
+ function extractMethodsFromRegex(content) {
12
204
  const methods = [];
13
205
  const seen = new Set();
14
- // Match async method declarations: async methodName(
15
206
  const asyncMethodRe = /(?:async\s+)([a-zA-Z_]\w*)\s*\(/g;
16
207
  let match;
17
208
  while ((match = asyncMethodRe.exec(content)) !== null) {
18
209
  const name = match[1];
19
- if (RESERVED_WORDS.has(name)) {
20
- continue;
21
- }
22
- if (!seen.has(name)) {
210
+ if (!RESERVED_WORDS.has(name) && !seen.has(name)) {
23
211
  seen.add(name);
24
- methods.push({ name, kind: 'method' });
212
+ methods.push({ name, kind: 'method', async: true });
25
213
  }
26
214
  }
27
- // Match non-async public method patterns
28
215
  const methodRe = /^\s+(?:readonly\s+)?([a-zA-Z_]\w*)\s*(?:\(|=\s*(?:async\s*)?\()/gm;
29
216
  while ((match = methodRe.exec(content)) !== null) {
30
217
  const name = match[1];
31
- if (RESERVED_WORDS.has(name) || seen.has(name)) {
32
- continue;
218
+ if (!RESERVED_WORDS.has(name) && !seen.has(name)) {
219
+ seen.add(name);
220
+ const isAsync = match[0].includes('async');
221
+ methods.push({ name, kind: 'method', async: isAsync ? true : undefined });
222
+ }
223
+ }
224
+ const arrowRe = /^\s+([a-zA-Z_]\w*)\s*=\s*(async\s+)?(?:\([^)]*\)|[a-zA-Z_]\w*)\s*=>/gm;
225
+ while ((match = arrowRe.exec(content)) !== null) {
226
+ const name = match[1];
227
+ if (!RESERVED_WORDS.has(name) && !seen.has(name)) {
228
+ seen.add(name);
229
+ methods.push({ name, kind: 'method', async: match[2] ? true : undefined });
33
230
  }
34
- seen.add(name);
35
- methods.push({ name, kind: 'method' });
36
231
  }
37
- // Match getter patterns: get propertyName()
38
232
  const getterRe = /\bget\s+([a-zA-Z_]\w*)\s*\(\)/g;
39
233
  while ((match = getterRe.exec(content)) !== null) {
40
234
  const name = match[1];
@@ -43,15 +237,13 @@ function extractMethodsFromSource(content) {
43
237
  methods.push({ name, kind: 'getter' });
44
238
  }
45
239
  }
46
- // Match readonly property declarations
47
240
  const propRe = /^\s+(?:readonly\s+)?([a-zA-Z_]\w*)\s*[:=]/gm;
48
241
  while ((match = propRe.exec(content)) !== null) {
49
242
  const name = match[1];
50
- if (RESERVED_WORDS.has(name) || seen.has(name)) {
51
- continue;
243
+ if (!RESERVED_WORDS.has(name) && !seen.has(name)) {
244
+ seen.add(name);
245
+ methods.push({ name, kind: 'property' });
52
246
  }
53
- seen.add(name);
54
- methods.push({ name, kind: 'property' });
55
247
  }
56
248
  return methods;
57
249
  }
@@ -59,7 +251,27 @@ function extractClassName(content) {
59
251
  const match = content.match(/(?:export\s+)?class\s+(\w+)/);
60
252
  return match ? match[1] : null;
61
253
  }
62
- function scanDirectory(dir) {
254
+ // ── Directory scanning ─────────────────────────────────────
255
+ function collectTypeScriptFiles(dir) {
256
+ const files = [];
257
+ if (!existsSync(dir)) {
258
+ return files;
259
+ }
260
+ const entries = readdirSync(dir, { withFileTypes: true });
261
+ for (const entry of entries) {
262
+ const fullPath = join(dir, entry.name);
263
+ if (entry.isDirectory()) {
264
+ files.push(...collectTypeScriptFiles(fullPath));
265
+ continue;
266
+ }
267
+ const ext = extname(entry.name);
268
+ if (ext === '.ts' || ext === '.tsx') {
269
+ files.push(fullPath);
270
+ }
271
+ }
272
+ return files;
273
+ }
274
+ function scanDirectoryWithRegex(dir) {
63
275
  const surfaces = [];
64
276
  if (!existsSync(dir)) {
65
277
  return surfaces;
@@ -68,29 +280,22 @@ function scanDirectory(dir) {
68
280
  for (const entry of entries) {
69
281
  const fullPath = join(dir, entry.name);
70
282
  if (entry.isDirectory()) {
71
- surfaces.push(...scanDirectory(fullPath));
283
+ surfaces.push(...scanDirectoryWithRegex(fullPath));
72
284
  continue;
73
285
  }
74
286
  const ext = extname(entry.name);
75
287
  if (ext !== '.ts' && ext !== '.tsx') {
76
288
  continue;
77
289
  }
78
- if (entry.name === 'index.ts' || entry.name === 'index.tsx') {
79
- continue;
80
- }
81
290
  try {
82
291
  const content = readFileSync(fullPath, 'utf-8');
83
292
  const className = extractClassName(content);
84
293
  if (!className) {
85
294
  continue;
86
295
  }
87
- const extractedMethods = extractMethodsFromSource(content);
296
+ const extractedMethods = extractMethodsFromRegex(content);
88
297
  if (extractedMethods.length > 0) {
89
- surfaces.push({
90
- className,
91
- file: fullPath,
92
- methods: extractedMethods,
93
- });
298
+ surfaces.push({ className, file: fullPath, methods: extractedMethods });
94
299
  }
95
300
  }
96
301
  catch {
@@ -99,6 +304,7 @@ function scanDirectory(dir) {
99
304
  }
100
305
  return surfaces;
101
306
  }
307
+ // ── Public API ──────────────────────────────────────────────
102
308
  export function buildApiSurface(testsRoot, config) {
103
309
  const pageObjectsDir = config?.pageObjectsDir
104
310
  ? join(testsRoot, config.pageObjectsDir)
@@ -106,10 +312,30 @@ export function buildApiSurface(testsRoot, config) {
106
312
  const componentsDir = config?.componentsDir
107
313
  ? join(testsRoot, config.componentsDir)
108
314
  : join(testsRoot, 'lib', 'src', 'ui', 'components');
109
- const pageObjects = [
110
- ...scanDirectory(pageObjectsDir),
111
- ...scanDirectory(componentsDir),
112
- ];
315
+ let pageObjects;
316
+ if (config?.useRegexFallback) {
317
+ pageObjects = [
318
+ ...scanDirectoryWithRegex(pageObjectsDir),
319
+ ...scanDirectoryWithRegex(componentsDir),
320
+ ];
321
+ }
322
+ else {
323
+ // Use TypeScript AST — full type info, inheritance, params
324
+ const allFiles = [
325
+ ...collectTypeScriptFiles(pageObjectsDir),
326
+ ...collectTypeScriptFiles(componentsDir),
327
+ ];
328
+ try {
329
+ pageObjects = extractWithAST(allFiles);
330
+ }
331
+ catch {
332
+ // Fall back to regex if AST extraction fails
333
+ pageObjects = [
334
+ ...scanDirectoryWithRegex(pageObjectsDir),
335
+ ...scanDirectoryWithRegex(componentsDir),
336
+ ];
337
+ }
338
+ }
113
339
  return {
114
340
  pageObjects,
115
341
  generatedAt: new Date().toISOString(),
@@ -168,7 +394,12 @@ export function formatApiSurfaceForPrompt(catalog, classNames) {
168
394
  if (m.kind === 'getter') {
169
395
  return ` get ${m.name}()`;
170
396
  }
171
- return ` ${m.name}()`;
397
+ const prefix = m.async ? 'async ' : '';
398
+ const paramStr = m.params
399
+ ? m.params.map((p) => `${p.name}${p.optional ? '?' : ''}${p.type ? `: ${p.type}` : ''}`).join(', ')
400
+ : '';
401
+ const retStr = m.returnType ? `: ${m.returnType}` : '';
402
+ return ` ${prefix}${m.name}(${paramStr})${retStr}`;
172
403
  })
173
404
  .join('\n');
174
405
  sections.push(`${name}:\n${methodList}`);
@@ -0,0 +1,121 @@
1
+ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
2
+ // See LICENSE.txt for license information.
3
+ /**
4
+ * Tracks historical test failure correlations: which tests fail when certain files change.
5
+ * Used to boost confidence in impact analysis — if a file change historically breaks a test,
6
+ * future changes to that file should prioritize that test.
7
+ *
8
+ * Data is stored as a JSON file at .e2e-ai-agents/failure-history.json.
9
+ */
10
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
11
+ import { join, dirname } from 'path';
12
+ const DEFAULT_HISTORY = {
13
+ correlations: [],
14
+ totalRuns: 0,
15
+ updatedAt: new Date().toISOString(),
16
+ };
17
+ export function loadFailureHistory(testsRoot) {
18
+ const historyPath = join(testsRoot, '.e2e-ai-agents', 'failure-history.json');
19
+ if (!existsSync(historyPath)) {
20
+ return { ...DEFAULT_HISTORY };
21
+ }
22
+ try {
23
+ const raw = JSON.parse(readFileSync(historyPath, 'utf-8'));
24
+ if (!Array.isArray(raw.correlations)) {
25
+ return { ...DEFAULT_HISTORY };
26
+ }
27
+ return raw;
28
+ }
29
+ catch {
30
+ return { ...DEFAULT_HISTORY };
31
+ }
32
+ }
33
+ export function saveFailureHistory(testsRoot, history) {
34
+ const historyPath = join(testsRoot, '.e2e-ai-agents', 'failure-history.json');
35
+ try {
36
+ const dir = dirname(historyPath);
37
+ if (!existsSync(dir)) {
38
+ mkdirSync(dir, { recursive: true });
39
+ }
40
+ history.updatedAt = new Date().toISOString();
41
+ writeFileSync(historyPath, JSON.stringify(history, null, 2), 'utf-8');
42
+ }
43
+ catch {
44
+ // Non-fatal — history is advisory, not required
45
+ }
46
+ }
47
+ /**
48
+ * Record that a set of changed files caused a set of spec failures.
49
+ * Call this after a test run where failures were observed.
50
+ */
51
+ export function recordFailures(history, changedFiles, failedSpecs) {
52
+ const now = new Date().toISOString();
53
+ const updated = { ...history, totalRuns: history.totalRuns + 1, correlations: [...history.correlations] };
54
+ for (const changedFile of changedFiles) {
55
+ for (const specFile of failedSpecs) {
56
+ const existing = updated.correlations.find((c) => c.changedFile === changedFile && c.specFile === specFile);
57
+ if (existing) {
58
+ existing.count++;
59
+ existing.lastSeen = now;
60
+ }
61
+ else {
62
+ updated.correlations.push({
63
+ changedFile,
64
+ specFile,
65
+ count: 1,
66
+ lastSeen: now,
67
+ });
68
+ }
69
+ }
70
+ }
71
+ // Prune stale correlations (not seen in 90 days)
72
+ const cutoff = new Date();
73
+ cutoff.setDate(cutoff.getDate() - 90);
74
+ const cutoffStr = cutoff.toISOString();
75
+ updated.correlations = updated.correlations.filter((c) => c.lastSeen >= cutoffStr);
76
+ return updated;
77
+ }
78
+ /**
79
+ * Get a confidence boost (0-20) for a file based on historical failure patterns.
80
+ * A file that historically causes test failures gets a higher confidence boost
81
+ * when detected as impacted, meaning the system is more confident it needs testing.
82
+ */
83
+ export function getConfidenceBoost(history, changedFile) {
84
+ const correlations = history.correlations.filter((c) => c.changedFile === changedFile);
85
+ if (correlations.length === 0) {
86
+ return 0;
87
+ }
88
+ // More correlations and higher counts = more confidence
89
+ const totalCount = correlations.reduce((sum, c) => sum + c.count, 0);
90
+ const uniqueSpecs = correlations.length;
91
+ // Scale: 1 correlation = +5, 3+ = +10, 5+ with high counts = +15, max +20
92
+ if (totalCount >= 10 && uniqueSpecs >= 5)
93
+ return 20;
94
+ if (totalCount >= 5 && uniqueSpecs >= 3)
95
+ return 15;
96
+ if (totalCount >= 3)
97
+ return 10;
98
+ return 5;
99
+ }
100
+ /**
101
+ * Get the most likely failing specs for a set of changed files, based on history.
102
+ * Returns specs sorted by correlation strength (count * recency).
103
+ */
104
+ export function getPredictedFailures(history, changedFiles, limit = 10) {
105
+ const specScores = new Map();
106
+ for (const changedFile of changedFiles) {
107
+ for (const c of history.correlations) {
108
+ if (c.changedFile !== changedFile)
109
+ continue;
110
+ // Score: count weighted by recency (days since last seen)
111
+ const daysSince = (Date.now() - new Date(c.lastSeen).getTime()) / (1000 * 60 * 60 * 24);
112
+ const recencyWeight = Math.max(0.1, 1 - daysSince / 90);
113
+ const score = c.count * recencyWeight;
114
+ specScores.set(c.specFile, (specScores.get(c.specFile) || 0) + score);
115
+ }
116
+ }
117
+ return Array.from(specScores.entries())
118
+ .map(([specFile, score]) => ({ specFile, score }))
119
+ .sort((a, b) => b.score - a.score)
120
+ .slice(0, limit);
121
+ }
@@ -106,8 +106,22 @@ function validateFamily(family) {
106
106
  if (testType) {
107
107
  result.testType = testType;
108
108
  }
109
+ if (Array.isArray(obj.assertionPatterns)) {
110
+ result.assertionPatterns = validateAssertionPatterns(obj.assertionPatterns);
111
+ }
109
112
  return result;
110
113
  }
114
+ const VALID_ASSERTION_TYPES = [
115
+ 'state-change', 'cross-user', 'persistence', 'negative',
116
+ 'permission', 'data-integrity', 'error-handling',
117
+ ];
118
+ function validateAssertionPatterns(patterns) {
119
+ return patterns
120
+ .filter((p) => p != null && typeof p === 'object')
121
+ .filter((p) => typeof p.type === 'string' && typeof p.pattern === 'string')
122
+ .filter((p) => VALID_ASSERTION_TYPES.includes(p.type))
123
+ .map((p) => ({ type: p.type, pattern: p.pattern }));
124
+ }
111
125
  function validateFeature(feature) {
112
126
  if (!feature || typeof feature !== 'object') {
113
127
  return null;
@@ -141,6 +155,9 @@ function validateFeature(feature) {
141
155
  if (Array.isArray(obj.userFlows)) {
142
156
  result.userFlows = obj.userFlows.filter((v) => typeof v === 'string');
143
157
  }
158
+ if (Array.isArray(obj.assertionPatterns)) {
159
+ result.assertionPatterns = validateAssertionPatterns(obj.assertionPatterns);
160
+ }
144
161
  return result;
145
162
  }
146
163
  export function loadRouteFamilyManifest(testsRoot, config) {
@@ -294,6 +311,19 @@ export function getRoutesForBinding(manifest, binding) {
294
311
  }
295
312
  return family.routes;
296
313
  }
314
+ export function getAssertionPatternsForBinding(manifest, binding) {
315
+ const family = getFamilyById(manifest, binding.family);
316
+ if (!family) {
317
+ return [];
318
+ }
319
+ if (binding.feature) {
320
+ const feature = getFeatureById(family, binding.feature);
321
+ if (feature?.assertionPatterns && feature.assertionPatterns.length > 0) {
322
+ return feature.assertionPatterns;
323
+ }
324
+ }
325
+ return family.assertionPatterns || [];
326
+ }
297
327
  export function clearManifestCache() {
298
328
  manifestCache.clear();
299
329
  }
@@ -345,7 +375,7 @@ export function serializeManifest(manifest) {
345
375
  const cleaned = { ...f };
346
376
  const optionalArrays = [
347
377
  'pageObjects', 'components', 'webappPaths', 'serverPaths',
348
- 'specDirs', 'cypressSpecDirs', 'tags', 'userFlows', 'features', 'apiEndpoints',
378
+ 'specDirs', 'cypressSpecDirs', 'tags', 'userFlows', 'features', 'apiEndpoints', 'assertionPatterns',
349
379
  ];
350
380
  for (const key of optionalArrays) {
351
381
  if (!cleaned[key] || (Array.isArray(cleaned[key]) && cleaned[key].length === 0)) {
@@ -3,7 +3,8 @@
3
3
  import { LLMProviderFactory } from '../provider_factory.js';
4
4
  import { buildImpactPrompt, parseImpactResponse } from '../prompts/impact.js';
5
5
  import { formatContextForPrompt } from '../knowledge/context_loader.js';
6
- import { getFamilyById } from '../knowledge/route_families.js';
6
+ import { getFamilyById, getAssertionPatternsForBinding } from '../knowledge/route_families.js';
7
+ import { loadFailureHistory, getConfidenceBoost } from '../knowledge/failure_history.js';
7
8
  import { getSpecsForFamily } from '../knowledge/spec_index.js';
8
9
  import { computeConfidence, shouldForceCannotDetermine } from '../validation/guardrails.js';
9
10
  function normalizePriority(value) {
@@ -18,7 +19,7 @@ async function getProvider(config) {
18
19
  }
19
20
  return LLMProviderFactory.createFromEnv();
20
21
  }
21
- export async function runImpactStage(familyGroups, manifest, specIndex, apiSurface, context, config) {
22
+ export async function runImpactStage(familyGroups, manifest, specIndex, apiSurface, context, config, testsRoot) {
22
23
  const warnings = [];
23
24
  const allDecisions = [];
24
25
  if (familyGroups.length === 0) {
@@ -35,6 +36,8 @@ export async function runImpactStage(familyGroups, manifest, specIndex, apiSurfa
35
36
  return { decisions: [], warnings, providerName: 'none' };
36
37
  }
37
38
  const contextBlock = formatContextForPrompt(context);
39
+ // Load historical failure correlations for confidence boosting
40
+ const failureHistory = testsRoot ? loadFailureHistory(testsRoot) : null;
38
41
  for (const group of familyGroups) {
39
42
  const family = manifest ? getFamilyById(manifest, group.familyId) : null;
40
43
  if (!family) {
@@ -83,15 +86,27 @@ export async function runImpactStage(familyGroups, manifest, specIndex, apiSurfa
83
86
  if (!flow.id || !flow.changedFiles || !Array.isArray(flow.changedFiles)) {
84
87
  continue;
85
88
  }
89
+ // Compute confidence with optional historical failure boost
90
+ const changedFilesList = Array.isArray(flow.changedFiles)
91
+ ? flow.changedFiles.filter((f) => typeof f === 'string')
92
+ : [];
93
+ const historyBoost = failureHistory
94
+ ? Math.max(...changedFilesList.map((f) => getConfidenceBoost(failureHistory, f)), 0)
95
+ : 0;
86
96
  const confidence = typeof flow.confidence === 'number'
87
- ? Math.max(0, Math.min(100, flow.confidence))
97
+ ? Math.min(100, Math.max(0, flow.confidence) + historyBoost)
88
98
  : computeConfidence({
89
99
  hasRouteFamily: true,
90
100
  hasSpecificRoute: Boolean(flow.route),
91
101
  hasPageObject: Boolean(flow.pageObjects && flow.pageObjects.length > 0),
92
102
  hasUserAction: Boolean(flow.userActions && flow.userActions.length > 0),
93
103
  hasExistingSpecCited: false,
104
+ historyBoost,
94
105
  });
106
+ // Resolve assertion patterns from manifest for this flow's family/feature
107
+ const assertionPatterns = manifest
108
+ ? getAssertionPatternsForBinding(manifest, { family: group.familyId, feature: group.featureId })
109
+ : [];
95
110
  const decision = {
96
111
  flowId: flow.id,
97
112
  flowName: flow.name || flow.id,
@@ -107,6 +122,7 @@ export async function runImpactStage(familyGroups, manifest, specIndex, apiSurfa
107
122
  blockingReason: shouldForceCannotDetermine(confidence) ? 'Confidence too low to determine action.' : undefined,
108
123
  priority: normalizePriority(flow.priority),
109
124
  userActions: Array.isArray(flow.userActions) ? flow.userActions.filter((a) => typeof a === 'string') : [],
125
+ assertionPatterns: assertionPatterns.length > 0 ? assertionPatterns : undefined,
110
126
  };
111
127
  allDecisions.push(decision);
112
128
  }
@@ -43,13 +43,26 @@ export async function runCoverageStage(decisions, specIndex, context, testsRoot,
43
43
  for (const [familyId, familyDecisions] of byFamily) {
44
44
  // Gather relevant specs
45
45
  const specs = getSpecsForFamily(specIndex, familyId);
46
- const specsWithContent = specs
47
- .map((s) => {
46
+ // Two-tier approach: send all spec titles (compact), full content for top matches only
47
+ const allSpecSummaries = specs.map((s) => ({
48
+ relativePath: s.relativePath,
49
+ testTitles: s.testTitles,
50
+ }));
51
+ // Load full content with a total budget of 200K chars (~50K tokens) to avoid blowing context windows
52
+ const MAX_TOTAL_SPEC_CHARS = 200000;
53
+ let totalSpecChars = 0;
54
+ const specsWithContent = [];
55
+ for (const s of specs) {
56
+ if (specsWithContent.length >= 30)
57
+ break;
48
58
  const content = loadSpecFileContent(testsRoot, s.relativePath, maxSpecChars);
49
- return content ? { relativePath: s.relativePath, content, testTitles: s.testTitles } : null;
50
- })
51
- .filter((s) => s !== null)
52
- .slice(0, 15); // Limit to 15 specs per family to stay within token budget
59
+ if (!content)
60
+ continue;
61
+ if (totalSpecChars + content.length > MAX_TOTAL_SPEC_CHARS)
62
+ break;
63
+ totalSpecChars += content.length;
64
+ specsWithContent.push({ relativePath: s.relativePath, content, testTitles: s.testTitles });
65
+ }
53
66
  if (specsWithContent.length === 0) {
54
67
  // No specs to evaluate — mark all as create_spec
55
68
  for (const d of familyDecisions) {
@@ -70,10 +83,18 @@ export async function runCoverageStage(decisions, specIndex, context, testsRoot,
70
83
  evidence: d.evidence,
71
84
  priority: d.priority,
72
85
  }));
86
+ // Include titles-only summaries for specs beyond the content limit
87
+ const extraSummaries = allSpecSummaries
88
+ .slice(specsWithContent.length)
89
+ .map((s) => ` - ${s.relativePath}: ${s.testTitles.join(', ')}`)
90
+ .join('\n');
91
+ const extraContext = extraSummaries
92
+ ? `\nADDITIONAL SPECS (titles only, no content loaded):\n${extraSummaries}\n`
93
+ : '';
73
94
  const prompt = buildCoveragePrompt({
74
95
  flows,
75
96
  specs: specsWithContent,
76
- contextBlock,
97
+ contextBlock: contextBlock + extraContext,
77
98
  profile: config.profile,
78
99
  });
79
100
  try {