@yasserkhanorg/e2e-agents 1.4.0 → 1.6.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 (76) hide show
  1. package/dist/agent/feedback.d.ts +16 -0
  2. package/dist/agent/feedback.d.ts.map +1 -1
  3. package/dist/agent/feedback.js +62 -0
  4. package/dist/agent/process_runner.d.ts +1 -1
  5. package/dist/agent/process_runner.d.ts.map +1 -1
  6. package/dist/agent/process_runner.js +3 -3
  7. package/dist/api.d.ts.map +1 -1
  8. package/dist/api.js +5 -2
  9. package/dist/cli/commands/train.d.ts.map +1 -1
  10. package/dist/cli/commands/train.js +31 -4
  11. package/dist/cli/parse_args.d.ts.map +1 -1
  12. package/dist/cli/parse_args.js +1 -0
  13. package/dist/cli/types.d.ts +1 -0
  14. package/dist/cli/types.d.ts.map +1 -1
  15. package/dist/engine/plan_builder.d.ts +2 -1
  16. package/dist/engine/plan_builder.d.ts.map +1 -1
  17. package/dist/engine/plan_builder.js +22 -9
  18. package/dist/esm/agent/feedback.js +61 -0
  19. package/dist/esm/agent/process_runner.js +3 -3
  20. package/dist/esm/api.js +5 -2
  21. package/dist/esm/cli/commands/train.js +31 -4
  22. package/dist/esm/cli/parse_args.js +1 -0
  23. package/dist/esm/engine/plan_builder.js +22 -9
  24. package/dist/esm/index.js +1 -1
  25. package/dist/esm/pipeline/spec_verifier.js +75 -0
  26. package/dist/esm/pipeline/stage3_generation.js +122 -4
  27. package/dist/esm/pipeline/stage4_heal.js +146 -3
  28. package/dist/esm/prompts/heal.js +4 -0
  29. package/dist/esm/qa-agent/phase2/agent_loop.js +60 -24
  30. package/dist/esm/qa-agent/phase2/exploration_state.js +21 -0
  31. package/dist/esm/qa-agent/phase2/tools.js +99 -1
  32. package/dist/esm/qa-agent/phase3/reporter.js +31 -4
  33. package/dist/esm/training/enricher.js +71 -7
  34. package/dist/esm/training/merger.js +77 -10
  35. package/dist/esm/training/scanner.js +368 -2
  36. package/dist/esm/validation/guardrails.js +1 -0
  37. package/dist/index.d.ts +2 -2
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +3 -2
  40. package/dist/pipeline/orchestrator.d.ts.map +1 -1
  41. package/dist/pipeline/spec_verifier.d.ts +20 -0
  42. package/dist/pipeline/spec_verifier.d.ts.map +1 -0
  43. package/dist/pipeline/spec_verifier.js +79 -0
  44. package/dist/pipeline/stage3_generation.d.ts +10 -0
  45. package/dist/pipeline/stage3_generation.d.ts.map +1 -1
  46. package/dist/pipeline/stage3_generation.js +120 -2
  47. package/dist/pipeline/stage4_heal.d.ts +4 -0
  48. package/dist/pipeline/stage4_heal.d.ts.map +1 -1
  49. package/dist/pipeline/stage4_heal.js +145 -2
  50. package/dist/prompts/heal.d.ts +2 -0
  51. package/dist/prompts/heal.d.ts.map +1 -1
  52. package/dist/prompts/heal.js +4 -0
  53. package/dist/qa-agent/phase2/agent_loop.d.ts.map +1 -1
  54. package/dist/qa-agent/phase2/agent_loop.js +60 -24
  55. package/dist/qa-agent/phase2/exploration_state.d.ts.map +1 -1
  56. package/dist/qa-agent/phase2/exploration_state.js +21 -0
  57. package/dist/qa-agent/phase2/tools.d.ts.map +1 -1
  58. package/dist/qa-agent/phase2/tools.js +99 -1
  59. package/dist/qa-agent/phase3/reporter.js +31 -4
  60. package/dist/qa-agent/types.d.ts +9 -1
  61. package/dist/qa-agent/types.d.ts.map +1 -1
  62. package/dist/training/enricher.d.ts +3 -1
  63. package/dist/training/enricher.d.ts.map +1 -1
  64. package/dist/training/enricher.js +71 -7
  65. package/dist/training/merger.d.ts +11 -1
  66. package/dist/training/merger.d.ts.map +1 -1
  67. package/dist/training/merger.js +77 -10
  68. package/dist/training/scanner.d.ts +15 -2
  69. package/dist/training/scanner.d.ts.map +1 -1
  70. package/dist/training/scanner.js +370 -2
  71. package/dist/training/types.d.ts +4 -0
  72. package/dist/training/types.d.ts.map +1 -1
  73. package/dist/validation/guardrails.d.ts +2 -0
  74. package/dist/validation/guardrails.d.ts.map +1 -1
  75. package/dist/validation/guardrails.js +4 -1
  76. package/package.json +1 -1
@@ -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
  }
@@ -59,9 +59,47 @@ function sampleFiles(dir, maxFiles) {
59
59
  walk(dir);
60
60
  return files;
61
61
  }
62
- function buildEnrichPrompt(families, projectRoot) {
62
+ /**
63
+ * Build a shallow directory listing of the source tree (depth 2-3) so the LLM
64
+ * can suggest accurate webappPaths / serverPaths for test-derived families.
65
+ */
66
+ function getSourceTreeListing(projectRoot, maxDepth = 3) {
67
+ const lines = [];
68
+ function walk(dir, depth, prefix) {
69
+ if (depth > maxDepth || lines.length > 200)
70
+ return;
71
+ let entries;
72
+ try {
73
+ entries = readdirSync(dir).sort();
74
+ }
75
+ catch {
76
+ return;
77
+ }
78
+ const dirs = entries.filter((e) => {
79
+ if (e.startsWith('.') || SKIP_DIRS.has(e))
80
+ return false;
81
+ try {
82
+ const stat = lstatSync(join(dir, e));
83
+ return !stat.isSymbolicLink() && stat.isDirectory();
84
+ }
85
+ catch {
86
+ return false;
87
+ }
88
+ });
89
+ for (const d of dirs) {
90
+ lines.push(`${prefix}${d}/`);
91
+ walk(join(dir, d), depth + 1, prefix + ' ');
92
+ }
93
+ }
94
+ walk(resolve(projectRoot), 0, '');
95
+ return lines.join('\n');
96
+ }
97
+ function buildEnrichPrompt(families, projectRoot, testsRoot) {
63
98
  const sections = [];
99
+ const hasTestOnlyFamilies = families.some((f) => f.webappPaths.length === 0 && f.serverPaths.length === 0);
100
+ const resolvedTestsRoot = testsRoot ? resolve(testsRoot) : resolve(projectRoot);
64
101
  for (const family of families) {
102
+ const isTestOnly = family.webappPaths.length === 0 && family.serverPaths.length === 0;
65
103
  const allDirs = [
66
104
  ...family.webappPaths.map((p) => p.replace(/\/?\*.*$/, '')),
67
105
  ...family.serverPaths.map((p) => p.replace(/\/?\*.*$/, '')),
@@ -75,10 +113,19 @@ function buildEnrichPrompt(families, projectRoot) {
75
113
  if (samples.length >= MAX_FILES_PER_FAMILY)
76
114
  break;
77
115
  }
116
+ // For test-only families, sample the test files themselves for richer context
117
+ if (isTestOnly) {
118
+ for (const specDir of family.specDirs) {
119
+ if (samples.length >= MAX_FILES_PER_FAMILY)
120
+ break;
121
+ const fullDir = join(resolvedTestsRoot, specDir);
122
+ samples.push(...sampleFiles(fullDir, MAX_FILES_PER_FAMILY - samples.length));
123
+ }
124
+ }
78
125
  // Sample spec descriptions
79
126
  const specSamples = [];
80
127
  for (const specDir of family.specDirs) {
81
- const fullDir = join(resolve(projectRoot), specDir);
128
+ const fullDir = join(resolvedTestsRoot, specDir);
82
129
  const specFiles = sampleFiles(fullDir, 5);
83
130
  for (const sf of specFiles) {
84
131
  const matches = sf.content.match(/(?:test|it|describe)\s*\(\s*['"`]([^'"`]+)/g);
@@ -87,7 +134,7 @@ function buildEnrichPrompt(families, projectRoot) {
87
134
  }
88
135
  }
89
136
  }
90
- sections.push(`## Family: ${family.id}
137
+ sections.push(`## Family: ${family.id}${isTestOnly ? ' [TEST-ONLY — needs webappPaths/serverPaths]' : ''}
91
138
  Routes (guessed): ${JSON.stringify(family.routes)}
92
139
  Webapp paths: ${JSON.stringify(family.webappPaths)}
93
140
  Server paths: ${JSON.stringify(family.serverPaths)}
@@ -102,6 +149,10 @@ Test descriptions:
102
149
  ${specSamples.length > 0 ? specSamples.map((d) => `- ${d}`).join('\n') : '(none found)'}
103
150
  `);
104
151
  }
152
+ // Include source tree listing when we have test-only families
153
+ const sourceTreeSection = hasTestOnlyFamilies
154
+ ? `\n## Source Directory Structure\nUse this to suggest accurate webappPaths and serverPaths for test-only families:\n\`\`\`\n${getSourceTreeListing(projectRoot)}\n\`\`\`\n`
155
+ : '';
105
156
  return `You are analyzing a codebase to enrich route-family definitions for an E2E test impact analysis tool.
106
157
 
107
158
  For each family below, provide:
@@ -110,6 +161,8 @@ For each family below, provide:
110
161
  3. **routes**: Improved URL patterns (e.g., "/{team}/channels/{channel}" instead of "/channels")
111
162
  4. **pageObjects**: Array of page object class names found in the code
112
163
  5. **components**: Array of UI component names relevant to this family
164
+ 6. **webappPaths**: Array of glob patterns for frontend source directories (e.g., "src/components/drafts/**"). REQUIRED for families marked [TEST-ONLY].
165
+ 7. **serverPaths**: Array of glob patterns for backend source directories. REQUIRED for families marked [TEST-ONLY].
113
166
 
114
167
  Respond in JSON format:
115
168
  \`\`\`json
@@ -120,11 +173,13 @@ Respond in JSON format:
120
173
  "userFlows": ["Flow name 1", "Flow name 2"],
121
174
  "routes": ["/improved/route/{param}"],
122
175
  "pageObjects": ["PageName"],
123
- "components": ["ComponentName"]
176
+ "components": ["ComponentName"],
177
+ "webappPaths": ["src/components/feature_name/**"],
178
+ "serverPaths": ["server/channels/api4/feature.go"]
124
179
  }
125
180
  ]
126
181
  \`\`\`
127
-
182
+ ${sourceTreeSection}
128
183
  ${sections.join('\n---\n')}`;
129
184
  }
130
185
  export function validateEntries(parsed) {
@@ -143,6 +198,8 @@ export function validateEntries(parsed) {
143
198
  userFlows: filterStrings(entry.userFlows, 500),
144
199
  pageObjects: filterStrings(entry.pageObjects, 200),
145
200
  components: filterStrings(entry.components, 200),
201
+ webappPaths: filterStrings(entry.webappPaths, 300),
202
+ serverPaths: filterStrings(entry.serverPaths, 300),
146
203
  }));
147
204
  }
148
205
  export function parseEnrichResponse(response) {
@@ -192,9 +249,16 @@ function applyEnrichment(family, enriched) {
192
249
  if (enriched.components && (!family.components || family.components.length === 0)) {
193
250
  result.components = enriched.components;
194
251
  }
252
+ // Only fill source paths when the family has none (test-derived families)
253
+ if (enriched.webappPaths && (!family.webappPaths || family.webappPaths.length === 0)) {
254
+ result.webappPaths = enriched.webappPaths;
255
+ }
256
+ if (enriched.serverPaths && (!family.serverPaths || family.serverPaths.length === 0)) {
257
+ result.serverPaths = enriched.serverPaths;
258
+ }
195
259
  return result;
196
260
  }
197
- export async function enrichFamilies(families, scanned, projectRoot, provider, budgetUSD) {
261
+ export async function enrichFamilies(families, scanned, projectRoot, provider, budgetUSD, testsRoot) {
198
262
  const scannedMap = new Map(scanned.map((s) => [s.id, s]));
199
263
  const enriched = [];
200
264
  let totalTokens = 0;
@@ -218,7 +282,7 @@ export async function enrichFamilies(families, scanned, projectRoot, provider, b
218
282
  enriched.push(...chunk);
219
283
  continue;
220
284
  }
221
- let prompt = buildEnrichPrompt(scannedChunk, projectRoot);
285
+ let prompt = buildEnrichPrompt(scannedChunk, projectRoot, testsRoot);
222
286
  if (prompt.length > MAX_PROMPT_CHARS) {
223
287
  // Truncate at the last complete section boundary to avoid malformed input
224
288
  const lastSectionEnd = prompt.lastIndexOf('\n---\n', MAX_PROMPT_CHARS);
@@ -1,5 +1,6 @@
1
1
  // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
2
2
  // See LICENSE.txt for license information.
3
+ import { execFileSync } from 'child_process';
3
4
  import { existsSync } from 'fs';
4
5
  import { join, resolve } from 'path';
5
6
  import { isGuessedRoute } from './types.js';
@@ -67,6 +68,21 @@ function scannedToRouteFamily(scanned) {
67
68
  }
68
69
  return family;
69
70
  }
71
+ /**
72
+ * Try to find a matching family ID with singular/plural normalization.
73
+ * "team" matches "teams", "emoji" matches "emoji", etc.
74
+ */
75
+ function findFuzzyMatch(id, idMap) {
76
+ if (idMap.has(id))
77
+ return id;
78
+ // Try adding 's'
79
+ if (!id.endsWith('s') && idMap.has(id + 's'))
80
+ return id + 's';
81
+ // Try removing 's'
82
+ if (id.endsWith('s') && idMap.has(id.slice(0, -1)))
83
+ return id.slice(0, -1);
84
+ return undefined;
85
+ }
70
86
  export function mergeFamilies(existing, scanned) {
71
87
  const existingFamilies = existing?.families || [];
72
88
  const existingMap = new Map(existingFamilies.map((f) => [f.id, f]));
@@ -74,9 +90,15 @@ export function mergeFamilies(existing, scanned) {
74
90
  const newFamilies = [];
75
91
  const updatedFamilies = [];
76
92
  const mergedFamilies = [];
77
- // Process existing families
93
+ // Process existing families — match scanned by exact or fuzzy ID
78
94
  for (const ef of existingFamilies) {
79
- const sf = scannedMap.get(ef.id);
95
+ let sf = scannedMap.get(ef.id);
96
+ // Try singular/plural match if exact match failed
97
+ if (!sf) {
98
+ const fuzzyId = findFuzzyMatch(ef.id, scannedMap);
99
+ if (fuzzyId)
100
+ sf = scannedMap.get(fuzzyId);
101
+ }
80
102
  if (sf) {
81
103
  mergedFamilies.push(mergeFamily(ef, sf));
82
104
  updatedFamilies.push(ef.id);
@@ -86,9 +108,10 @@ export function mergeFamilies(existing, scanned) {
86
108
  mergedFamilies.push({ ...ef });
87
109
  }
88
110
  }
89
- // Add new families from scanner
111
+ // Add new families from scanner (if no existing family matched)
90
112
  for (const sf of scanned) {
91
- if (!existingMap.has(sf.id)) {
113
+ const matchedExisting = findFuzzyMatch(sf.id, existingMap);
114
+ if (!matchedExisting) {
92
115
  mergedFamilies.push(scannedToRouteFamily(sf));
93
116
  newFamilies.push(sf.id);
94
117
  }
@@ -108,8 +131,33 @@ export function mergeFamilies(existing, scanned) {
108
131
  summary: parts.join(', '),
109
132
  };
110
133
  }
111
- export function detectStaleFamilies(manifest, projectRoot) {
112
- const resolved = resolve(projectRoot);
134
+ /**
135
+ * Detect families whose paths no longer exist on disk.
136
+ *
137
+ * Paths in the manifest may be relative to different roots:
138
+ * - webappPaths / serverPaths are typically relative to the repo root
139
+ * - specDirs may be relative to the tests root
140
+ *
141
+ * We try each pattern against all provided roots (and the git repo root
142
+ * if discoverable) to avoid false positives from path-prefix mismatches.
143
+ */
144
+ export function detectStaleFamilies(manifest, projectRoot, testsRoot) {
145
+ const roots = new Set([resolve(projectRoot)]);
146
+ if (testsRoot)
147
+ roots.add(resolve(testsRoot));
148
+ // Also try to discover the git repo root — manifest paths may be repo-relative
149
+ try {
150
+ const gitRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], {
151
+ cwd: projectRoot,
152
+ encoding: 'utf-8',
153
+ stdio: ['pipe', 'pipe', 'pipe'],
154
+ }).trim();
155
+ if (gitRoot)
156
+ roots.add(resolve(gitRoot));
157
+ }
158
+ catch {
159
+ // Not a git repo or git not available — that's fine
160
+ }
113
161
  const stale = [];
114
162
  for (const family of manifest.families) {
115
163
  const allPatterns = [
@@ -119,15 +167,34 @@ export function detectStaleFamilies(manifest, projectRoot) {
119
167
  ];
120
168
  if (allPatterns.length === 0)
121
169
  continue;
122
- // Check if any pattern resolves to existing files/dirs
170
+ // Check if any pattern resolves to existing files/dirs in any root
123
171
  let hasAny = false;
124
172
  for (const pattern of allPatterns) {
125
173
  // Strip trailing glob (* or **) to get the directory
126
174
  const dirPart = pattern.replace(/\/?\*.*$/, '');
127
- if (dirPart && existsSync(join(resolved, dirPart))) {
128
- hasAny = true;
129
- break;
175
+ if (!dirPart)
176
+ continue;
177
+ // For file-level patterns like "server/channels/api4/draft*.go",
178
+ // dirPart is "server/channels/api4/draft" — check the parent dir instead
179
+ const isFileGlob = /\.\w+$/.test(pattern);
180
+ const pathsToCheck = [dirPart];
181
+ if (isFileGlob) {
182
+ const parentDir = dirPart.split('/').slice(0, -1).join('/');
183
+ if (parentDir)
184
+ pathsToCheck.push(parentDir);
185
+ }
186
+ for (const checkPath of pathsToCheck) {
187
+ for (const root of roots) {
188
+ if (existsSync(join(root, checkPath))) {
189
+ hasAny = true;
190
+ break;
191
+ }
192
+ }
193
+ if (hasAny)
194
+ break;
130
195
  }
196
+ if (hasAny)
197
+ break;
131
198
  }
132
199
  if (!hasAny) {
133
200
  stale.push(family.id);