@yasserkhanorg/e2e-agents 1.5.0 → 1.7.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.
package/dist/logger.js CHANGED
@@ -51,6 +51,7 @@ function logLevelToString(level) {
51
51
  class Logger {
52
52
  constructor(minLevel) {
53
53
  this.level = minLevel ?? getLogLevelFromEnv();
54
+ this.jsonMode = process.env.LOG_FORMAT?.toLowerCase() === 'json';
54
55
  }
55
56
  error(message, context) {
56
57
  if (this.level >= LogLevel.ERROR) {
@@ -75,11 +76,37 @@ class Logger {
75
76
  setLevel(level) {
76
77
  this.level = level;
77
78
  }
79
+ setJsonMode(enabled) {
80
+ this.jsonMode = enabled;
81
+ }
82
+ /**
83
+ * Start a timer for measuring duration of an operation.
84
+ * Returns an object with `end()` that logs at DEBUG level and returns elapsed ms.
85
+ */
86
+ timer(label) {
87
+ const start = performance.now();
88
+ return {
89
+ end: () => {
90
+ const elapsed = Math.round(performance.now() - start);
91
+ this.debug(`${label} completed`, { durationMs: elapsed });
92
+ return elapsed;
93
+ },
94
+ };
95
+ }
78
96
  log(level, message, context) {
79
97
  const timestamp = new Date().toISOString();
80
98
  const levelStr = logLevelToString(level);
81
- const contextStr = context ? ` ${JSON.stringify(context)}` : '';
82
- const output = `[${timestamp}] [${levelStr}] ${message}${contextStr}`;
99
+ let output;
100
+ if (this.jsonMode) {
101
+ const entry = { ts: timestamp, level: levelStr, msg: message };
102
+ if (context)
103
+ entry.ctx = context;
104
+ output = JSON.stringify(entry);
105
+ }
106
+ else {
107
+ const contextStr = context ? ` ${JSON.stringify(context)}` : '';
108
+ output = `[${timestamp}] [${levelStr}] ${message}${contextStr}`;
109
+ }
83
110
  if (level <= LogLevel.WARN) {
84
111
  console.error(output);
85
112
  }
@@ -1 +1 @@
1
- {"version":3,"file":"orchestrator.d.ts","sourceRoot":"","sources":["../../src/pipeline/orchestrator.ts"],"names":[],"mappings":"AAQA,OAAO,EAAiB,KAAK,YAAY,EAAC,MAAM,oBAAoB,CAAC;AACrE,OAAO,EAAmB,KAAK,cAAc,EAAC,MAAM,sBAAsB,CAAC;AAC3E,OAAO,EAAqB,KAAK,gBAAgB,EAAE,KAAK,aAAa,EAAC,MAAM,wBAAwB,CAAC;AACrG,OAAO,EAAuD,KAAK,UAAU,EAAE,KAAK,UAAU,EAAC,MAAM,kBAAkB,CAAC;AACxH,OAAO,EAAe,KAAK,kBAAkB,EAAoB,MAAM,gCAAgC,CAAC;AAExG,OAAO,KAAK,EAAC,iBAAiB,EAAC,MAAM,gCAAgC,CAAC;AACtE,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,6BAA6B,CAAC;AAElE,MAAM,WAAW,cAAc;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,qBAAqB,CAAC,EAAE,OAAO,CAAC;IAChC,aAAa,CAAC,EAAE,iBAAiB,CAAC;IAClC,UAAU,CAAC,EAAE,gBAAgB,CAAC;IAC9B,MAAM,CAAC,EAAE,YAAY,CAAC;IACtB,QAAQ,CAAC,EAAE,cAAc,CAAC;IAC1B,UAAU,CAAC,EAAE,gBAAgB,CAAC;IAC9B,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB,iEAAiE;IACjE,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,MAAM,CAAC,EAAE,KAAK,CAAC,YAAY,GAAG,QAAQ,GAAG,UAAU,GAAG,YAAY,GAAG,MAAM,CAAC,CAAC;CAChF;AAED,MAAM,WAAW,cAAc;IAC3B,MAAM,EAAE,kBAAkB,CAAC;IAC3B,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,SAAS,CAAC,EAAE,aAAa,EAAE,CAAC;IAC5B,UAAU,CAAC,EAAE,UAAU,CAAC;CAC3B;AAqBD,wBAAsB,WAAW,CAAC,MAAM,EAAE,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC,CAgIjF"}
1
+ {"version":3,"file":"orchestrator.d.ts","sourceRoot":"","sources":["../../src/pipeline/orchestrator.ts"],"names":[],"mappings":"AAQA,OAAO,EAAiB,KAAK,YAAY,EAAC,MAAM,oBAAoB,CAAC;AACrE,OAAO,EAAmB,KAAK,cAAc,EAAC,MAAM,sBAAsB,CAAC;AAC3E,OAAO,EAAqB,KAAK,gBAAgB,EAAE,KAAK,aAAa,EAAC,MAAM,wBAAwB,CAAC;AACrG,OAAO,EAAuD,KAAK,UAAU,EAAE,KAAK,UAAU,EAAC,MAAM,kBAAkB,CAAC;AACxH,OAAO,EAAe,KAAK,kBAAkB,EAAoB,MAAM,gCAAgC,CAAC;AAExG,OAAO,KAAK,EAAC,iBAAiB,EAAC,MAAM,gCAAgC,CAAC;AACtE,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,6BAA6B,CAAC;AAElE,MAAM,WAAW,cAAc;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,qBAAqB,CAAC,EAAE,OAAO,CAAC;IAChC,aAAa,CAAC,EAAE,iBAAiB,CAAC;IAClC,UAAU,CAAC,EAAE,gBAAgB,CAAC;IAC9B,MAAM,CAAC,EAAE,YAAY,CAAC;IACtB,QAAQ,CAAC,EAAE,cAAc,CAAC;IAC1B,UAAU,CAAC,EAAE,gBAAgB,CAAC;IAC9B,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB,iEAAiE;IACjE,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,MAAM,CAAC,EAAE,KAAK,CAAC,YAAY,GAAG,QAAQ,GAAG,UAAU,GAAG,YAAY,GAAG,MAAM,CAAC,CAAC;CAChF;AAED,MAAM,WAAW,cAAc;IAC3B,MAAM,EAAE,kBAAkB,CAAC;IAC3B,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,SAAS,CAAC,EAAE,aAAa,EAAE,CAAC;IAC5B,UAAU,CAAC,EAAE,UAAU,CAAC;CAC3B;AAqBD,wBAAsB,WAAW,CAAC,MAAM,EAAE,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC,CA6IjF"}
@@ -6,6 +6,7 @@ exports.runPipeline = runPipeline;
6
6
  const fs_1 = require("fs");
7
7
  const path_1 = require("path");
8
8
  const git_js_1 = require("../agent/git.js");
9
+ const logger_js_1 = require("../logger.js");
9
10
  const stage0_preprocess_js_1 = require("./stage0_preprocess.js");
10
11
  const stage1_impact_js_1 = require("./stage1_impact.js");
11
12
  const stage2_coverage_js_1 = require("./stage2_coverage.js");
@@ -61,20 +62,25 @@ async function runPipeline(config) {
61
62
  const reportPath = writeReport(config.testsRoot, emptyReport);
62
63
  return { report: emptyReport, reportPath, warnings: allWarnings };
63
64
  }
65
+ const timings = {};
64
66
  // Step 2: Preprocess — deterministic file classification + route family binding
67
+ const preprocessTimer = logger_js_1.logger.timer('preprocess');
65
68
  const preprocessResult = (0, stage0_preprocess_js_1.preprocess)(changedFiles, {
66
69
  appPath: config.appPath,
67
70
  testsRoot: config.testsRoot,
68
71
  routeFamilies: config.routeFamilies,
69
72
  apiSurface: config.apiSurface,
70
73
  });
74
+ timings.preprocess = preprocessTimer.end();
71
75
  allWarnings.push(...preprocessResult.warnings);
72
76
  let decisions = [];
73
77
  // Step 3: Impact stage — AI-powered flow identification per family
74
78
  if (stages.includes('impact')) {
79
+ const impactTimer = logger_js_1.logger.timer('impact');
75
80
  const impactResult = await (0, stage1_impact_js_1.runImpactStage)(preprocessResult.familyGroups, preprocessResult.manifest, preprocessResult.specIndex, preprocessResult.apiSurface, preprocessResult.context, config.impact || {});
76
81
  decisions = impactResult.decisions;
77
82
  allWarnings.push(...impactResult.warnings);
83
+ timings.impact = impactTimer.end();
78
84
  // Check cannot_determine ratio
79
85
  const cannotDetermineRatio = (0, guardrails_js_1.computeCannotDetermineRatio)(decisions);
80
86
  if (cannotDetermineRatio > 0.3) {
@@ -83,18 +89,23 @@ async function runPipeline(config) {
83
89
  }
84
90
  // Step 4: Coverage stage — AI-powered spec coverage evaluation
85
91
  if (stages.includes('coverage') && decisions.length > 0) {
92
+ const coverageTimer = logger_js_1.logger.timer('coverage');
86
93
  const coverageResult = await (0, stage2_coverage_js_1.runCoverageStage)(decisions, preprocessResult.specIndex, preprocessResult.context, config.testsRoot, config.coverage || {});
87
94
  decisions = coverageResult.decisions;
95
+ timings.coverage = coverageTimer.end();
88
96
  allWarnings.push(...coverageResult.warnings);
89
97
  }
90
98
  // Step 5: Generation stage — AI-powered spec generation for create_spec / add_scenarios
91
99
  if (stages.includes('generation') && decisions.length > 0) {
100
+ const generationTimer = logger_js_1.logger.timer('generation');
92
101
  const generationResult = await (0, stage3_generation_js_1.runGenerationStage)(decisions, preprocessResult.apiSurface, config.testsRoot, config.generation || {});
93
102
  generatedSpecs = generationResult.generated;
103
+ timings.generation = generationTimer.end();
94
104
  allWarnings.push(...generationResult.warnings);
95
105
  }
96
106
  // Step 6: Heal stage — MCP-backed playwright-test-healer for failing/flaky specs
97
107
  if (stages.includes('heal')) {
108
+ const healTimer = logger_js_1.logger.timer('heal');
98
109
  const healTargets = (0, stage4_heal_js_1.resolveHealTargets)(config.testsRoot, {
99
110
  playwrightReportPath: config.playwrightReportPath,
100
111
  generatedSpecs,
@@ -106,6 +117,7 @@ async function runPipeline(config) {
106
117
  else {
107
118
  allWarnings.push('Heal stage: no targets found (no failing specs in report, no generated specs).');
108
119
  }
120
+ timings.heal = healTimer.end();
109
121
  }
110
122
  // Build report
111
123
  const report = {
@@ -121,16 +133,18 @@ async function runPipeline(config) {
121
133
  generationAgent: stages.includes('generation') ? (config.generation?.provider || 'auto') : undefined,
122
134
  },
123
135
  };
124
- const reportPath = writeReport(config.testsRoot, report, healResult);
136
+ const reportPath = writeReport(config.testsRoot, report, healResult, timings);
125
137
  return { report, reportPath, warnings: allWarnings, generated: generatedSpecs, healResult };
126
138
  }
127
- function writeReport(testsRoot, report, healResult) {
139
+ function writeReport(testsRoot, report, healResult, timings) {
128
140
  const outputDir = (0, path_1.join)(testsRoot, '.e2e-ai-agents');
129
141
  if (!(0, fs_1.existsSync)(outputDir)) {
130
142
  (0, fs_1.mkdirSync)(outputDir, { recursive: true });
131
143
  }
144
+ // Include timings in the JSON report if available
145
+ const reportWithTimings = timings ? { ...report, timings } : report;
132
146
  const jsonPath = (0, path_1.join)(outputDir, 'pipeline-report.json');
133
- (0, fs_1.writeFileSync)(jsonPath, JSON.stringify(report, null, 2), 'utf-8');
147
+ (0, fs_1.writeFileSync)(jsonPath, JSON.stringify(reportWithTimings, null, 2), 'utf-8');
134
148
  const mdPath = (0, path_1.join)(outputDir, 'pipeline-report.md');
135
149
  (0, fs_1.writeFileSync)(mdPath, renderMarkdown(report, healResult), 'utf-8');
136
150
  return jsonPath;
@@ -8,8 +8,10 @@ export interface EnrichedEntry {
8
8
  routes?: string[];
9
9
  pageObjects?: string[];
10
10
  components?: string[];
11
+ webappPaths?: string[];
12
+ serverPaths?: string[];
11
13
  }
12
14
  export declare function validateEntries(parsed: unknown[]): EnrichedEntry[];
13
15
  export declare function parseEnrichResponse(response: string): EnrichedEntry[];
14
- export declare function enrichFamilies(families: RouteFamily[], scanned: ScannedFamily[], projectRoot: string, provider: LLMProvider, budgetUSD: number): Promise<EnrichmentResult>;
16
+ export declare function enrichFamilies(families: RouteFamily[], scanned: ScannedFamily[], projectRoot: string, provider: LLMProvider, budgetUSD: number, testsRoot?: string): Promise<EnrichmentResult>;
15
17
  //# sourceMappingURL=enricher.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"enricher.d.ts","sourceRoot":"","sources":["../../src/training/enricher.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,0BAA0B,CAAC;AAC1D,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,gCAAgC,CAAC;AAGhE,OAAO,KAAK,EAAC,gBAAgB,EAAE,aAAa,EAAC,MAAM,YAAY,CAAC;AAmIhE,MAAM,WAAW,aAAa;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,CAAC,EAAE,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;IAC9B,SAAS,CAAC,EAAE,MAAM,EAAE,CAAC;IACrB,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;CACzB;AAED,wBAAgB,eAAe,CAAC,MAAM,EAAE,OAAO,EAAE,GAAG,aAAa,EAAE,CAiBlE;AAED,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,GAAG,aAAa,EAAE,CAwBrE;AA2BD,wBAAsB,cAAc,CAChC,QAAQ,EAAE,WAAW,EAAE,EACvB,OAAO,EAAE,aAAa,EAAE,EACxB,WAAW,EAAE,MAAM,EACnB,QAAQ,EAAE,WAAW,EACrB,SAAS,EAAE,MAAM,GAClB,OAAO,CAAC,gBAAgB,CAAC,CAgF3B"}
1
+ {"version":3,"file":"enricher.d.ts","sourceRoot":"","sources":["../../src/training/enricher.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,0BAA0B,CAAC;AAC1D,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,gCAAgC,CAAC;AAGhE,OAAO,KAAK,EAAC,gBAAgB,EAAE,aAAa,EAAC,MAAM,YAAY,CAAC;AAkLhE,MAAM,WAAW,aAAa;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,CAAC,EAAE,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;IAC9B,SAAS,CAAC,EAAE,MAAM,EAAE,CAAC;IACrB,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;CAC1B;AAED,wBAAgB,eAAe,CAAC,MAAM,EAAE,OAAO,EAAE,GAAG,aAAa,EAAE,CAmBlE;AAED,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,GAAG,aAAa,EAAE,CAwBrE;AAkCD,wBAAsB,cAAc,CAChC,QAAQ,EAAE,WAAW,EAAE,EACvB,OAAO,EAAE,aAAa,EAAE,EACxB,WAAW,EAAE,MAAM,EACnB,QAAQ,EAAE,WAAW,EACrB,SAAS,EAAE,MAAM,EACjB,SAAS,CAAC,EAAE,MAAM,GACnB,OAAO,CAAC,gBAAgB,CAAC,CAuF3B"}
@@ -64,9 +64,47 @@ function sampleFiles(dir, maxFiles) {
64
64
  walk(dir);
65
65
  return files;
66
66
  }
67
- function buildEnrichPrompt(families, projectRoot) {
67
+ /**
68
+ * Build a shallow directory listing of the source tree (depth 2-3) so the LLM
69
+ * can suggest accurate webappPaths / serverPaths for test-derived families.
70
+ */
71
+ function getSourceTreeListing(projectRoot, maxDepth = 3) {
72
+ const lines = [];
73
+ function walk(dir, depth, prefix) {
74
+ if (depth > maxDepth || lines.length > 200)
75
+ return;
76
+ let entries;
77
+ try {
78
+ entries = (0, fs_1.readdirSync)(dir).sort();
79
+ }
80
+ catch {
81
+ return;
82
+ }
83
+ const dirs = entries.filter((e) => {
84
+ if (e.startsWith('.') || SKIP_DIRS.has(e))
85
+ return false;
86
+ try {
87
+ const stat = (0, fs_1.lstatSync)((0, path_1.join)(dir, e));
88
+ return !stat.isSymbolicLink() && stat.isDirectory();
89
+ }
90
+ catch {
91
+ return false;
92
+ }
93
+ });
94
+ for (const d of dirs) {
95
+ lines.push(`${prefix}${d}/`);
96
+ walk((0, path_1.join)(dir, d), depth + 1, prefix + ' ');
97
+ }
98
+ }
99
+ walk((0, path_1.resolve)(projectRoot), 0, '');
100
+ return lines.join('\n');
101
+ }
102
+ function buildEnrichPrompt(families, projectRoot, testsRoot) {
68
103
  const sections = [];
104
+ const hasTestOnlyFamilies = families.some((f) => f.webappPaths.length === 0 && f.serverPaths.length === 0);
105
+ const resolvedTestsRoot = testsRoot ? (0, path_1.resolve)(testsRoot) : (0, path_1.resolve)(projectRoot);
69
106
  for (const family of families) {
107
+ const isTestOnly = family.webappPaths.length === 0 && family.serverPaths.length === 0;
70
108
  const allDirs = [
71
109
  ...family.webappPaths.map((p) => p.replace(/\/?\*.*$/, '')),
72
110
  ...family.serverPaths.map((p) => p.replace(/\/?\*.*$/, '')),
@@ -80,10 +118,19 @@ function buildEnrichPrompt(families, projectRoot) {
80
118
  if (samples.length >= MAX_FILES_PER_FAMILY)
81
119
  break;
82
120
  }
121
+ // For test-only families, sample the test files themselves for richer context
122
+ if (isTestOnly) {
123
+ for (const specDir of family.specDirs) {
124
+ if (samples.length >= MAX_FILES_PER_FAMILY)
125
+ break;
126
+ const fullDir = (0, path_1.join)(resolvedTestsRoot, specDir);
127
+ samples.push(...sampleFiles(fullDir, MAX_FILES_PER_FAMILY - samples.length));
128
+ }
129
+ }
83
130
  // Sample spec descriptions
84
131
  const specSamples = [];
85
132
  for (const specDir of family.specDirs) {
86
- const fullDir = (0, path_1.join)((0, path_1.resolve)(projectRoot), specDir);
133
+ const fullDir = (0, path_1.join)(resolvedTestsRoot, specDir);
87
134
  const specFiles = sampleFiles(fullDir, 5);
88
135
  for (const sf of specFiles) {
89
136
  const matches = sf.content.match(/(?:test|it|describe)\s*\(\s*['"`]([^'"`]+)/g);
@@ -92,7 +139,7 @@ function buildEnrichPrompt(families, projectRoot) {
92
139
  }
93
140
  }
94
141
  }
95
- sections.push(`## Family: ${family.id}
142
+ sections.push(`## Family: ${family.id}${isTestOnly ? ' [TEST-ONLY — needs webappPaths/serverPaths]' : ''}
96
143
  Routes (guessed): ${JSON.stringify(family.routes)}
97
144
  Webapp paths: ${JSON.stringify(family.webappPaths)}
98
145
  Server paths: ${JSON.stringify(family.serverPaths)}
@@ -107,6 +154,10 @@ Test descriptions:
107
154
  ${specSamples.length > 0 ? specSamples.map((d) => `- ${d}`).join('\n') : '(none found)'}
108
155
  `);
109
156
  }
157
+ // Include source tree listing when we have test-only families
158
+ const sourceTreeSection = hasTestOnlyFamilies
159
+ ? `\n## Source Directory Structure\nUse this to suggest accurate webappPaths and serverPaths for test-only families:\n\`\`\`\n${getSourceTreeListing(projectRoot)}\n\`\`\`\n`
160
+ : '';
110
161
  return `You are analyzing a codebase to enrich route-family definitions for an E2E test impact analysis tool.
111
162
 
112
163
  For each family below, provide:
@@ -115,6 +166,8 @@ For each family below, provide:
115
166
  3. **routes**: Improved URL patterns (e.g., "/{team}/channels/{channel}" instead of "/channels")
116
167
  4. **pageObjects**: Array of page object class names found in the code
117
168
  5. **components**: Array of UI component names relevant to this family
169
+ 6. **webappPaths**: Array of glob patterns for frontend source directories (e.g., "src/components/drafts/**"). REQUIRED for families marked [TEST-ONLY].
170
+ 7. **serverPaths**: Array of glob patterns for backend source directories. REQUIRED for families marked [TEST-ONLY].
118
171
 
119
172
  Respond in JSON format:
120
173
  \`\`\`json
@@ -125,11 +178,13 @@ Respond in JSON format:
125
178
  "userFlows": ["Flow name 1", "Flow name 2"],
126
179
  "routes": ["/improved/route/{param}"],
127
180
  "pageObjects": ["PageName"],
128
- "components": ["ComponentName"]
181
+ "components": ["ComponentName"],
182
+ "webappPaths": ["src/components/feature_name/**"],
183
+ "serverPaths": ["server/channels/api4/feature.go"]
129
184
  }
130
185
  ]
131
186
  \`\`\`
132
-
187
+ ${sourceTreeSection}
133
188
  ${sections.join('\n---\n')}`;
134
189
  }
135
190
  function validateEntries(parsed) {
@@ -148,6 +203,8 @@ function validateEntries(parsed) {
148
203
  userFlows: filterStrings(entry.userFlows, 500),
149
204
  pageObjects: filterStrings(entry.pageObjects, 200),
150
205
  components: filterStrings(entry.components, 200),
206
+ webappPaths: filterStrings(entry.webappPaths, 300),
207
+ serverPaths: filterStrings(entry.serverPaths, 300),
151
208
  }));
152
209
  }
153
210
  function parseEnrichResponse(response) {
@@ -197,13 +254,22 @@ function applyEnrichment(family, enriched) {
197
254
  if (enriched.components && (!family.components || family.components.length === 0)) {
198
255
  result.components = enriched.components;
199
256
  }
257
+ // Only fill source paths when the family has none (test-derived families)
258
+ if (enriched.webappPaths && (!family.webappPaths || family.webappPaths.length === 0)) {
259
+ result.webappPaths = enriched.webappPaths;
260
+ }
261
+ if (enriched.serverPaths && (!family.serverPaths || family.serverPaths.length === 0)) {
262
+ result.serverPaths = enriched.serverPaths;
263
+ }
200
264
  return result;
201
265
  }
202
- async function enrichFamilies(families, scanned, projectRoot, provider, budgetUSD) {
266
+ async function enrichFamilies(families, scanned, projectRoot, provider, budgetUSD, testsRoot) {
203
267
  const scannedMap = new Map(scanned.map((s) => [s.id, s]));
204
268
  const enriched = [];
205
269
  let totalTokens = 0;
206
270
  let totalCost = 0;
271
+ let requestCount = 0;
272
+ let totalResponseMs = 0;
207
273
  const skipped = [];
208
274
  // Process in chunks of 4 families
209
275
  const chunkSize = 4;
@@ -223,7 +289,7 @@ async function enrichFamilies(families, scanned, projectRoot, provider, budgetUS
223
289
  enriched.push(...chunk);
224
290
  continue;
225
291
  }
226
- let prompt = buildEnrichPrompt(scannedChunk, projectRoot);
292
+ let prompt = buildEnrichPrompt(scannedChunk, projectRoot, testsRoot);
227
293
  if (prompt.length > MAX_PROMPT_CHARS) {
228
294
  // Truncate at the last complete section boundary to avoid malformed input
229
295
  const lastSectionEnd = prompt.lastIndexOf('\n---\n', MAX_PROMPT_CHARS);
@@ -236,15 +302,18 @@ async function enrichFamilies(families, scanned, projectRoot, provider, budgetUS
236
302
  prompt = prompt.slice(0, MAX_PROMPT_CHARS);
237
303
  }
238
304
  }
239
- let timer;
305
+ let timeoutTimer;
240
306
  try {
241
307
  const timeoutPromise = new Promise((_, reject) => {
242
- timer = setTimeout(() => reject(new Error('LLM request timed out')), LLM_TIMEOUT_MS);
308
+ timeoutTimer = setTimeout(() => reject(new Error('LLM request timed out')), LLM_TIMEOUT_MS);
243
309
  });
310
+ const reqStart = performance.now();
244
311
  const response = await Promise.race([
245
312
  provider.generateText(prompt, { maxTokens: 4096, temperature: 0.3 }),
246
313
  timeoutPromise,
247
314
  ]);
315
+ totalResponseMs += performance.now() - reqStart;
316
+ requestCount++;
248
317
  totalTokens += (response.usage?.inputTokens ?? 0) + (response.usage?.outputTokens ?? 0);
249
318
  totalCost += response.cost ?? 0;
250
319
  const entries = parseEnrichResponse(response.text);
@@ -265,8 +334,8 @@ async function enrichFamilies(families, scanned, projectRoot, provider, budgetUS
265
334
  enriched.push(...chunk);
266
335
  }
267
336
  finally {
268
- if (timer)
269
- clearTimeout(timer);
337
+ if (timeoutTimer)
338
+ clearTimeout(timeoutTimer);
270
339
  }
271
340
  }
272
341
  return {
@@ -274,5 +343,7 @@ async function enrichFamilies(families, scanned, projectRoot, provider, budgetUS
274
343
  tokensUsed: totalTokens,
275
344
  costUSD: Math.round(totalCost * 100) / 100,
276
345
  skippedFamilies: skipped,
346
+ requestCount,
347
+ avgResponseMs: requestCount > 0 ? Math.round(totalResponseMs / requestCount) : 0,
277
348
  };
278
349
  }
@@ -1,5 +1,15 @@
1
1
  import type { RouteFamilyManifest } from '../knowledge/route_families.js';
2
2
  import type { MergeResult, ScannedFamily } from './types.js';
3
3
  export declare function mergeFamilies(existing: RouteFamilyManifest | null, scanned: ScannedFamily[]): MergeResult;
4
- export declare function detectStaleFamilies(manifest: RouteFamilyManifest, projectRoot: string): string[];
4
+ /**
5
+ * Detect families whose paths no longer exist on disk.
6
+ *
7
+ * Paths in the manifest may be relative to different roots:
8
+ * - webappPaths / serverPaths are typically relative to the repo root
9
+ * - specDirs may be relative to the tests root
10
+ *
11
+ * We try each pattern against all provided roots (and the git repo root
12
+ * if discoverable) to avoid false positives from path-prefix mismatches.
13
+ */
14
+ export declare function detectStaleFamilies(manifest: RouteFamilyManifest, projectRoot: string, testsRoot?: string): string[];
5
15
  //# sourceMappingURL=merger.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"merger.d.ts","sourceRoot":"","sources":["../../src/training/merger.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAc,mBAAmB,EAAC,MAAM,gCAAgC,CAAC;AAGrF,OAAO,KAAK,EAAC,WAAW,EAAE,aAAa,EAAC,MAAM,YAAY,CAAC;AAqE3D,wBAAgB,aAAa,CACzB,QAAQ,EAAE,mBAAmB,GAAG,IAAI,EACpC,OAAO,EAAE,aAAa,EAAE,GACzB,WAAW,CAyCb;AAED,wBAAgB,mBAAmB,CAC/B,QAAQ,EAAE,mBAAmB,EAC7B,WAAW,EAAE,MAAM,GACpB,MAAM,EAAE,CA8BV"}
1
+ {"version":3,"file":"merger.d.ts","sourceRoot":"","sources":["../../src/training/merger.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAc,mBAAmB,EAAC,MAAM,gCAAgC,CAAC;AAGrF,OAAO,KAAK,EAAC,WAAW,EAAE,aAAa,EAAC,MAAM,YAAY,CAAC;AAkF3D,wBAAgB,aAAa,CACzB,QAAQ,EAAE,mBAAmB,GAAG,IAAI,EACpC,OAAO,EAAE,aAAa,EAAE,GACzB,WAAW,CA+Cb;AAED;;;;;;;;;GASG;AACH,wBAAgB,mBAAmB,CAC/B,QAAQ,EAAE,mBAAmB,EAC7B,WAAW,EAAE,MAAM,EACnB,SAAS,CAAC,EAAE,MAAM,GACnB,MAAM,EAAE,CA6DV"}
@@ -4,6 +4,7 @@
4
4
  Object.defineProperty(exports, "__esModule", { value: true });
5
5
  exports.mergeFamilies = mergeFamilies;
6
6
  exports.detectStaleFamilies = detectStaleFamilies;
7
+ const child_process_1 = require("child_process");
7
8
  const fs_1 = require("fs");
8
9
  const path_1 = require("path");
9
10
  const types_js_1 = require("./types.js");
@@ -71,6 +72,21 @@ function scannedToRouteFamily(scanned) {
71
72
  }
72
73
  return family;
73
74
  }
75
+ /**
76
+ * Try to find a matching family ID with singular/plural normalization.
77
+ * "team" matches "teams", "emoji" matches "emoji", etc.
78
+ */
79
+ function findFuzzyMatch(id, idMap) {
80
+ if (idMap.has(id))
81
+ return id;
82
+ // Try adding 's'
83
+ if (!id.endsWith('s') && idMap.has(id + 's'))
84
+ return id + 's';
85
+ // Try removing 's'
86
+ if (id.endsWith('s') && idMap.has(id.slice(0, -1)))
87
+ return id.slice(0, -1);
88
+ return undefined;
89
+ }
74
90
  function mergeFamilies(existing, scanned) {
75
91
  const existingFamilies = existing?.families || [];
76
92
  const existingMap = new Map(existingFamilies.map((f) => [f.id, f]));
@@ -78,9 +94,15 @@ function mergeFamilies(existing, scanned) {
78
94
  const newFamilies = [];
79
95
  const updatedFamilies = [];
80
96
  const mergedFamilies = [];
81
- // Process existing families
97
+ // Process existing families — match scanned by exact or fuzzy ID
82
98
  for (const ef of existingFamilies) {
83
- const sf = scannedMap.get(ef.id);
99
+ let sf = scannedMap.get(ef.id);
100
+ // Try singular/plural match if exact match failed
101
+ if (!sf) {
102
+ const fuzzyId = findFuzzyMatch(ef.id, scannedMap);
103
+ if (fuzzyId)
104
+ sf = scannedMap.get(fuzzyId);
105
+ }
84
106
  if (sf) {
85
107
  mergedFamilies.push(mergeFamily(ef, sf));
86
108
  updatedFamilies.push(ef.id);
@@ -90,9 +112,10 @@ function mergeFamilies(existing, scanned) {
90
112
  mergedFamilies.push({ ...ef });
91
113
  }
92
114
  }
93
- // Add new families from scanner
115
+ // Add new families from scanner (if no existing family matched)
94
116
  for (const sf of scanned) {
95
- if (!existingMap.has(sf.id)) {
117
+ const matchedExisting = findFuzzyMatch(sf.id, existingMap);
118
+ if (!matchedExisting) {
96
119
  mergedFamilies.push(scannedToRouteFamily(sf));
97
120
  newFamilies.push(sf.id);
98
121
  }
@@ -112,8 +135,33 @@ function mergeFamilies(existing, scanned) {
112
135
  summary: parts.join(', '),
113
136
  };
114
137
  }
115
- function detectStaleFamilies(manifest, projectRoot) {
116
- const resolved = (0, path_1.resolve)(projectRoot);
138
+ /**
139
+ * Detect families whose paths no longer exist on disk.
140
+ *
141
+ * Paths in the manifest may be relative to different roots:
142
+ * - webappPaths / serverPaths are typically relative to the repo root
143
+ * - specDirs may be relative to the tests root
144
+ *
145
+ * We try each pattern against all provided roots (and the git repo root
146
+ * if discoverable) to avoid false positives from path-prefix mismatches.
147
+ */
148
+ function detectStaleFamilies(manifest, projectRoot, testsRoot) {
149
+ const roots = new Set([(0, path_1.resolve)(projectRoot)]);
150
+ if (testsRoot)
151
+ roots.add((0, path_1.resolve)(testsRoot));
152
+ // Also try to discover the git repo root — manifest paths may be repo-relative
153
+ try {
154
+ const gitRoot = (0, child_process_1.execFileSync)('git', ['rev-parse', '--show-toplevel'], {
155
+ cwd: projectRoot,
156
+ encoding: 'utf-8',
157
+ stdio: ['pipe', 'pipe', 'pipe'],
158
+ }).trim();
159
+ if (gitRoot)
160
+ roots.add((0, path_1.resolve)(gitRoot));
161
+ }
162
+ catch {
163
+ // Not a git repo or git not available — that's fine
164
+ }
117
165
  const stale = [];
118
166
  for (const family of manifest.families) {
119
167
  const allPatterns = [
@@ -123,15 +171,34 @@ function detectStaleFamilies(manifest, projectRoot) {
123
171
  ];
124
172
  if (allPatterns.length === 0)
125
173
  continue;
126
- // Check if any pattern resolves to existing files/dirs
174
+ // Check if any pattern resolves to existing files/dirs in any root
127
175
  let hasAny = false;
128
176
  for (const pattern of allPatterns) {
129
177
  // Strip trailing glob (* or **) to get the directory
130
178
  const dirPart = pattern.replace(/\/?\*.*$/, '');
131
- if (dirPart && (0, fs_1.existsSync)((0, path_1.join)(resolved, dirPart))) {
132
- hasAny = true;
133
- break;
179
+ if (!dirPart)
180
+ continue;
181
+ // For file-level patterns like "server/channels/api4/draft*.go",
182
+ // dirPart is "server/channels/api4/draft" — check the parent dir instead
183
+ const isFileGlob = /\.\w+$/.test(pattern);
184
+ const pathsToCheck = [dirPart];
185
+ if (isFileGlob) {
186
+ const parentDir = dirPart.split('/').slice(0, -1).join('/');
187
+ if (parentDir)
188
+ pathsToCheck.push(parentDir);
189
+ }
190
+ for (const checkPath of pathsToCheck) {
191
+ for (const root of roots) {
192
+ if ((0, fs_1.existsSync)((0, path_1.join)(root, checkPath))) {
193
+ hasAny = true;
194
+ break;
195
+ }
196
+ }
197
+ if (hasAny)
198
+ break;
134
199
  }
200
+ if (hasAny)
201
+ break;
135
202
  }
136
203
  if (!hasAny) {
137
204
  stale.push(family.id);
@@ -1,5 +1,31 @@
1
- import type { DiscoveredDir, ScanResult } from './types.js';
1
+ import type { DiscoveredDir, ScannedFamily, ScanResult } from './types.js';
2
2
  export declare function discoverSourceDirs(projectRoot: string): DiscoveredDir[];
3
3
  export declare function discoverTestDirs(projectRoot: string): DiscoveredDir[];
4
- export declare function scanProject(projectRoot: string): ScanResult;
4
+ /**
5
+ * Discover families by scanning server Go source files.
6
+ *
7
+ * The backend follows a three-tier pattern:
8
+ * api4/draft.go + app/draft.go + store/sqlstore/draft_store.go
9
+ *
10
+ * Related files are grouped under parent domains:
11
+ * channel.go, channel_bookmark.go, channel_category.go → "channel" family
12
+ *
13
+ * Each domain becomes a candidate family with precise serverPaths.
14
+ */
15
+ export declare function discoverServerDerivedFamilies(serverRoot: string): {
16
+ multiTierFamilies: ScannedFamily[];
17
+ singleTierFamilies: ScannedFamily[];
18
+ };
19
+ export declare function discoverTestDerivedFamilies(testsRoot: string): ScannedFamily[];
20
+ /**
21
+ * Discover test library paths (page objects, helpers) organized by feature.
22
+ * Walks well-known test lib directories and maps subdirectories to family IDs.
23
+ */
24
+ export declare function discoverTestLibPaths(testsRoot: string): Map<string, string[]>;
25
+ /**
26
+ * Discover files in well-known directories (types, utils) whose basename
27
+ * maps directly to a family ID.
28
+ */
29
+ export declare function discoverNameMatchedPaths(appPath: string, gitRepoRoot?: string): Map<string, string[]>;
30
+ export declare function scanProject(projectRoot: string, testsRoot?: string, serverRoot?: string, gitRepoRoot?: string): ScanResult;
5
31
  //# sourceMappingURL=scanner.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"scanner.d.ts","sourceRoot":"","sources":["../../src/training/scanner.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAC,aAAa,EAAiC,UAAU,EAAC,MAAM,YAAY,CAAC;AAgGzF,wBAAgB,kBAAkB,CAAC,WAAW,EAAE,MAAM,GAAG,aAAa,EAAE,CA+BvE;AAED,wBAAgB,gBAAgB,CAAC,WAAW,EAAE,MAAM,GAAG,aAAa,EAAE,CA6DrE;AA6ID,wBAAgB,WAAW,CAAC,WAAW,EAAE,MAAM,GAAG,UAAU,CA+E3D"}
1
+ {"version":3,"file":"scanner.d.ts","sourceRoot":"","sources":["../../src/training/scanner.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAC,aAAa,EAAE,aAAa,EAAkB,UAAU,EAAC,MAAM,YAAY,CAAC;AAgJzF,wBAAgB,kBAAkB,CAAC,WAAW,EAAE,MAAM,GAAG,aAAa,EAAE,CA+BvE;AAED,wBAAgB,gBAAgB,CAAC,WAAW,EAAE,MAAM,GAAG,aAAa,EAAE,CA6DrE;AAuLD;;;;;;;;;;GAUG;AACH,wBAAgB,6BAA6B,CAAC,UAAU,EAAE,MAAM,GAAG;IAAC,iBAAiB,EAAE,aAAa,EAAE,CAAC;IAAC,kBAAkB,EAAE,aAAa,EAAE,CAAA;CAAC,CAgI3I;AAED,wBAAgB,2BAA2B,CAAC,SAAS,EAAE,MAAM,GAAG,aAAa,EAAE,CAiG9E;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,SAAS,EAAE,MAAM,GAAG,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAmC7E;AAED;;;GAGG;AACH,wBAAgB,wBAAwB,CACpC,OAAO,EAAE,MAAM,EACf,WAAW,CAAC,EAAE,MAAM,GACrB,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAmDvB;AAED,wBAAgB,WAAW,CAAC,WAAW,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,EAAE,WAAW,CAAC,EAAE,MAAM,GAAG,UAAU,CA0L1H"}