@yasserkhanorg/e2e-agents 0.3.3 → 0.3.5

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 (48) hide show
  1. package/README.md +66 -3
  2. package/dist/agent/ai_flow_analysis.d.ts +12 -0
  3. package/dist/agent/ai_flow_analysis.d.ts.map +1 -0
  4. package/dist/agent/ai_flow_analysis.js +326 -0
  5. package/dist/agent/ai_mapping.d.ts +14 -0
  6. package/dist/agent/ai_mapping.d.ts.map +1 -0
  7. package/dist/agent/ai_mapping.js +374 -0
  8. package/dist/agent/config.d.ts +32 -0
  9. package/dist/agent/config.d.ts.map +1 -1
  10. package/dist/agent/config.js +187 -1
  11. package/dist/agent/flow_catalog.d.ts.map +1 -1
  12. package/dist/agent/flow_catalog.js +10 -1
  13. package/dist/agent/operational_insights.d.ts +1 -1
  14. package/dist/agent/operational_insights.d.ts.map +1 -1
  15. package/dist/agent/operational_insights.js +2 -1
  16. package/dist/agent/pipeline.d.ts +2 -0
  17. package/dist/agent/pipeline.d.ts.map +1 -1
  18. package/dist/agent/pipeline.js +409 -68
  19. package/dist/agent/plan.d.ts +40 -0
  20. package/dist/agent/plan.d.ts.map +1 -1
  21. package/dist/agent/plan.js +159 -4
  22. package/dist/agent/report.d.ts +13 -2
  23. package/dist/agent/report.d.ts.map +1 -1
  24. package/dist/agent/report.js +9 -0
  25. package/dist/agent/runner.d.ts.map +1 -1
  26. package/dist/agent/runner.js +246 -19
  27. package/dist/agent/tests.d.ts +1 -1
  28. package/dist/agent/tests.d.ts.map +1 -1
  29. package/dist/api.d.ts.map +1 -1
  30. package/dist/api.js +1 -0
  31. package/dist/cli.js +97 -4
  32. package/dist/esm/agent/ai_flow_analysis.js +323 -0
  33. package/dist/esm/agent/ai_mapping.js +371 -0
  34. package/dist/esm/agent/config.js +187 -1
  35. package/dist/esm/agent/flow_catalog.js +10 -1
  36. package/dist/esm/agent/operational_insights.js +2 -1
  37. package/dist/esm/agent/pipeline.js +409 -68
  38. package/dist/esm/agent/plan.js +158 -5
  39. package/dist/esm/agent/report.js +9 -0
  40. package/dist/esm/agent/runner.js +246 -19
  41. package/dist/esm/api.js +2 -1
  42. package/dist/esm/cli.js +98 -5
  43. package/dist/esm/provider_factory.js +7 -3
  44. package/dist/provider_factory.d.ts.map +1 -1
  45. package/dist/provider_factory.js +7 -3
  46. package/package.json +4 -1
  47. package/schemas/impact.schema.json +40 -3
  48. package/schemas/plan.schema.json +48 -0
package/dist/cli.js CHANGED
@@ -76,6 +76,8 @@ function printUsage() {
76
76
  'Options:',
77
77
  ' --config <path> Path to e2e-ai-agents.config.json (auto-discovered if present)',
78
78
  ' --path <app-root> Path to the web app (required)',
79
+ ' --profile <name> default | mattermost',
80
+ ' --mattermost Shortcut for --profile mattermost',
79
81
  ' --tests-root <path> Path to tests root (optional)',
80
82
  ' --framework <name> auto | playwright | cypress | selenium',
81
83
  ' --patterns <globs> Comma-separated test patterns',
@@ -89,20 +91,27 @@ function printUsage() {
89
91
  ' --pipeline-base-url Base URL for Playwright runs',
90
92
  ' --pipeline-browser Browser: chrome|chromium|firefox|webkit',
91
93
  ' --pipeline-headless Run in headless mode',
94
+ ' --pipeline-headed Run in headed mode',
92
95
  ' --pipeline-project Playwright project name',
93
96
  ' --pipeline-parallel Enable parallel mode in generator',
94
97
  ' --pipeline-dry-run Do not execute pipeline (report only)',
95
98
  ' --pipeline-mcp Use Playwright MCP server for exploration/healing',
96
99
  ' --pipeline-mcp-allow-fallback Allow non-MCP fallback if official MCP setup fails',
100
+ ' --pipeline-mcp-only Require MCP for UI exploration (fail if unavailable)',
101
+ ' --pipeline-mcp-timeout-ms <n> Timeout per MCP CLI invocation in milliseconds',
102
+ ' --pipeline-mcp-retries <n> Retry count for retryable MCP CLI failures',
97
103
  ' --spec <path> Optional spec PDF for context',
98
104
  ' --since <git-ref> Git ref for impact analysis (default HEAD~1)',
99
105
  ' --time <minutes> Time limit in minutes',
100
106
  ' --budget-usd <amount> Max LLM budget in USD',
101
107
  ' --budget-tokens <n> Max LLM tokens',
108
+ ' --llm-provider <name> LLM provider: auto | anthropic | openai | ollama',
102
109
  ' --policy-min-confidence <n> Minimum confidence for targeted suite',
103
110
  ' --policy-safe-merge-confidence <n> Confidence needed for safe-to-merge',
104
111
  ' --policy-force-full-on-warnings <n> Escalate to full at warning count',
105
112
  ' --policy-risky-patterns <globs> Comma-separated risky file globs',
113
+ ' --policy-enforcement-mode <mode> advisory | warn | block',
114
+ ' --policy-block-actions <actions> Comma-separated CI actions to block/warn',
106
115
  ' --ci-comment-path <path> Write CI markdown summary',
107
116
  ' --github-output <path> Write GitHub Actions outputs',
108
117
  ' --fail-on-must-add-tests Exit non-zero on must-add-tests decision',
@@ -170,6 +179,17 @@ function parseArgs(argv) {
170
179
  i += 1;
171
180
  continue;
172
181
  }
182
+ if (arg === '--profile' && next) {
183
+ if (next === 'default' || next === 'mattermost') {
184
+ parsed.profile = next;
185
+ }
186
+ i += 1;
187
+ continue;
188
+ }
189
+ if (arg === '--mattermost') {
190
+ parsed.profile = 'mattermost';
191
+ continue;
192
+ }
173
193
  if (arg === '--tests-root' && next) {
174
194
  parsed.testsRoot = next;
175
195
  i += 1;
@@ -216,6 +236,20 @@ function parseArgs(argv) {
216
236
  parsed.pipelineMcpAllowFallback = true;
217
237
  continue;
218
238
  }
239
+ if (arg === '--pipeline-mcp-only') {
240
+ parsed.pipelineMcpOnly = true;
241
+ continue;
242
+ }
243
+ if (arg === '--pipeline-mcp-timeout-ms' && next) {
244
+ parsed.pipelineMcpTimeoutMs = Number(next);
245
+ i += 1;
246
+ continue;
247
+ }
248
+ if (arg === '--pipeline-mcp-retries' && next) {
249
+ parsed.pipelineMcpRetries = Number(next);
250
+ i += 1;
251
+ continue;
252
+ }
219
253
  if (arg === '--pipeline-scenarios' && next) {
220
254
  const value = Number(next);
221
255
  if (Number.isFinite(value)) {
@@ -246,6 +280,10 @@ function parseArgs(argv) {
246
280
  parsed.pipelineHeadless = true;
247
281
  continue;
248
282
  }
283
+ if (arg === '--pipeline-headed') {
284
+ parsed.pipelineHeadless = false;
285
+ continue;
286
+ }
249
287
  if (arg === '--pipeline-project' && next) {
250
288
  parsed.pipelineProject = next;
251
289
  i += 1;
@@ -293,6 +331,11 @@ function parseArgs(argv) {
293
331
  i += 1;
294
332
  continue;
295
333
  }
334
+ if (arg === '--llm-provider' && next) {
335
+ parsed.llmProvider = next;
336
+ i += 1;
337
+ continue;
338
+ }
296
339
  if (arg === '--policy-min-confidence' && next) {
297
340
  const value = Number(next);
298
341
  if (Number.isFinite(value)) {
@@ -322,6 +365,21 @@ function parseArgs(argv) {
322
365
  i += 1;
323
366
  continue;
324
367
  }
368
+ if (arg === '--policy-enforcement-mode' && next) {
369
+ if (next === 'advisory' || next === 'warn' || next === 'block') {
370
+ parsed.policyEnforcementMode = next;
371
+ }
372
+ i += 1;
373
+ continue;
374
+ }
375
+ if (arg === '--policy-block-actions' && next) {
376
+ parsed.policyBlockActions = next
377
+ .split(',')
378
+ .map((value) => value.trim())
379
+ .filter((value) => (value === 'run-now' || value === 'must-add-tests' || value === 'safe-to-merge'));
380
+ i += 1;
381
+ continue;
382
+ }
325
383
  if (arg === '--ci-comment-path' && next) {
326
384
  parsed.ciCommentPath = next;
327
385
  i += 1;
@@ -450,8 +508,10 @@ async function main() {
450
508
  }
451
509
  const { config } = (0, config_js_1.resolveConfig)(process.cwd(), autoConfig, {
452
510
  path: args.path,
511
+ profile: args.profile,
453
512
  testsRoot: args.testsRoot,
454
513
  mode: 'impact',
514
+ llmProvider: args.llmProvider,
455
515
  });
456
516
  const reportRoot = config.testsRoot || config.path;
457
517
  const raw = JSON.parse((0, fs_1.readFileSync)(args.feedbackInputPath, 'utf-8'));
@@ -485,9 +545,11 @@ async function main() {
485
545
  }
486
546
  const { config } = (0, config_js_1.resolveConfig)(process.cwd(), autoConfig, {
487
547
  path: args.path,
548
+ profile: args.profile,
488
549
  testsRoot: args.testsRoot,
489
550
  mode: 'impact',
490
551
  gitSince: args.gitSince,
552
+ llmProvider: args.llmProvider,
491
553
  });
492
554
  const reportRoot = config.testsRoot || config.path;
493
555
  const output = (0, traceability_capture_js_1.captureTraceabilityInput)({
@@ -526,8 +588,10 @@ async function main() {
526
588
  }
527
589
  const { config } = (0, config_js_1.resolveConfig)(process.cwd(), autoConfig, {
528
590
  path: args.path,
591
+ profile: args.profile,
529
592
  testsRoot: args.testsRoot,
530
593
  mode: 'impact',
594
+ llmProvider: args.llmProvider,
531
595
  });
532
596
  const reportRoot = config.testsRoot || config.path;
533
597
  const raw = JSON.parse((0, fs_1.readFileSync)(args.traceabilityInputPath, 'utf-8'));
@@ -560,8 +624,10 @@ async function main() {
560
624
  }
561
625
  const { config } = (0, config_js_1.resolveConfig)(process.cwd(), autoConfig, {
562
626
  path: args.path,
627
+ profile: args.profile,
563
628
  testsRoot: args.testsRoot,
564
629
  mode: 'gap',
630
+ llmProvider: args.llmProvider,
565
631
  });
566
632
  const result = (0, handoff_js_1.finalizeGeneratedTests)({
567
633
  appPath: config.path,
@@ -600,6 +666,7 @@ async function main() {
600
666
  }
601
667
  const { config } = (0, config_js_1.resolveConfig)(process.cwd(), autoConfig, {
602
668
  path: args.path,
669
+ profile: args.profile,
603
670
  testsRoot: args.testsRoot,
604
671
  mode: 'gap',
605
672
  framework: args.framework,
@@ -626,7 +693,9 @@ async function main() {
626
693
  dryRun: args.pipelineDryRun,
627
694
  mcp: args.pipelineMcp,
628
695
  mcpAllowFallback: args.pipelineMcpAllowFallback,
696
+ mcpOnly: args.pipelineMcpOnly,
629
697
  },
698
+ llmProvider: args.llmProvider,
630
699
  });
631
700
  if (args.allowFallback) {
632
701
  config.impact.allowFallback = true;
@@ -713,6 +782,7 @@ async function main() {
713
782
  }
714
783
  const { config } = (0, config_js_1.resolveConfig)(process.cwd(), autoConfig, {
715
784
  path: args.path,
785
+ profile: args.profile,
716
786
  testsRoot: args.testsRoot,
717
787
  mode: 'gap',
718
788
  framework: args.framework,
@@ -728,7 +798,9 @@ async function main() {
728
798
  dryRun: args.pipelineDryRun,
729
799
  mcp: args.pipelineMcp,
730
800
  mcpAllowFallback: args.pipelineMcpAllowFallback,
801
+ mcpOnly: args.pipelineMcpOnly,
731
802
  },
803
+ llmProvider: args.llmProvider,
732
804
  });
733
805
  const reportRoot = config.testsRoot || config.path;
734
806
  const unstableSpecs = (0, playwright_report_js_1.extractPlaywrightUnstableSpecs)(args.traceabilityReportPath, [reportRoot, config.path]);
@@ -762,8 +834,10 @@ async function main() {
762
834
  process.exit(1);
763
835
  }
764
836
  const forcePipelineFromApproval = args.command === 'approve-and-generate' || args.command === 'generate';
765
- const { config } = (0, config_js_1.resolveConfig)(process.cwd(), autoConfig, {
837
+ const forceAIPipelineFromApproval = args.command === 'approve-and-generate' || args.command === 'generate';
838
+ const { config, configPath } = (0, config_js_1.resolveConfig)(process.cwd(), autoConfig, {
766
839
  path: args.path,
840
+ profile: args.profile,
767
841
  testsRoot: args.testsRoot,
768
842
  mode: (args.command === 'gap' || args.command === 'approve-and-generate' || args.command === 'generate') ? 'gap' : 'impact',
769
843
  framework: args.framework,
@@ -778,6 +852,7 @@ async function main() {
778
852
  flowCatalogPath: args.flowCatalogPath,
779
853
  specPDF: args.specPDF,
780
854
  gitSince: args.gitSince,
855
+ llmProvider: args.llmProvider,
781
856
  pipeline: (args.pipeline || forcePipelineFromApproval)
782
857
  ? {
783
858
  enabled: true,
@@ -789,19 +864,26 @@ async function main() {
789
864
  project: args.pipelineProject,
790
865
  parallel: args.pipelineParallel,
791
866
  dryRun: args.pipelineDryRun,
792
- mcp: args.pipelineMcp !== undefined ? args.pipelineMcp : forcePipelineFromApproval,
867
+ mcp: args.pipelineMcp !== undefined ? args.pipelineMcp : forceAIPipelineFromApproval,
793
868
  mcpAllowFallback: args.pipelineMcpAllowFallback,
869
+ mcpOnly: args.pipelineMcpOnly !== undefined ? args.pipelineMcpOnly : forceAIPipelineFromApproval,
870
+ mcpCommandTimeoutMs: args.pipelineMcpTimeoutMs,
871
+ mcpRetries: args.pipelineMcpRetries,
794
872
  }
795
873
  : undefined,
796
874
  policy: args.policyMinConfidence !== undefined ||
797
875
  args.policySafeMergeConfidence !== undefined ||
798
876
  args.policyWarningsThreshold !== undefined ||
799
- (args.policyRiskyPatterns && args.policyRiskyPatterns.length > 0)
877
+ (args.policyRiskyPatterns && args.policyRiskyPatterns.length > 0) ||
878
+ args.policyEnforcementMode !== undefined ||
879
+ (args.policyBlockActions && args.policyBlockActions.length > 0)
800
880
  ? {
801
881
  minConfidenceForTargeted: args.policyMinConfidence,
802
882
  safeMergeMinConfidence: args.policySafeMergeConfidence,
803
883
  forceFullOnWarningsAtOrAbove: args.policyWarningsThreshold,
804
884
  riskyFilePatterns: args.policyRiskyPatterns,
885
+ enforcementMode: args.policyEnforcementMode,
886
+ blockOnActions: args.policyBlockActions,
805
887
  }
806
888
  : undefined,
807
889
  });
@@ -825,29 +907,39 @@ async function main() {
825
907
  appPath: config.path,
826
908
  testsRoot: reportRoot,
827
909
  sinceRef: config.git.since,
910
+ configPath,
828
911
  });
829
912
  const plan = (0, operational_insights_js_1.applyOperationalInsights)(withActions, reportRoot);
830
913
  const planPath = (0, plan_js_1.writePlanReport)(reportRoot, plan);
831
914
  const summaryMarkdown = (0, plan_js_1.renderCiSummaryMarkdown)(plan);
832
915
  const summaryPath = (0, plan_js_1.writeCiSummary)(reportRoot, summaryMarkdown, args.ciCommentPath);
916
+ const metrics = (0, plan_js_1.appendPlanMetrics)(reportRoot, plan);
833
917
  const ghaOutput = args.githubOutputPath || process.env.GITHUB_OUTPUT;
834
918
  if (ghaOutput) {
835
919
  (0, fs_1.appendFileSync)(ghaOutput, `run_set=${plan.runSet}\n`);
836
920
  (0, fs_1.appendFileSync)(ghaOutput, `action=${plan.decision.action}\n`);
837
921
  (0, fs_1.appendFileSync)(ghaOutput, `confidence=${plan.confidence}\n`);
922
+ (0, fs_1.appendFileSync)(ghaOutput, `enforcement_mode=${plan.enforcement.mode}\n`);
923
+ (0, fs_1.appendFileSync)(ghaOutput, `enforcement_should_fail=${plan.enforcement.shouldFail}\n`);
838
924
  (0, fs_1.appendFileSync)(ghaOutput, `recommended_tests_count=${plan.recommendedTests.length}\n`);
839
925
  (0, fs_1.appendFileSync)(ghaOutput, `required_new_tests_count=${plan.requiredNewTests.length}\n`);
840
926
  (0, fs_1.appendFileSync)(ghaOutput, `plan_path=${planPath}\n`);
841
927
  (0, fs_1.appendFileSync)(ghaOutput, `summary_path=${summaryPath}\n`);
928
+ (0, fs_1.appendFileSync)(ghaOutput, `metrics_events_path=${metrics.eventsPath}\n`);
929
+ (0, fs_1.appendFileSync)(ghaOutput, `metrics_summary_path=${metrics.summaryPath}\n`);
842
930
  }
843
931
  // eslint-disable-next-line no-console
844
932
  console.log(`Suggested run set: ${plan.runSet} (confidence ${plan.confidence})`);
845
933
  // eslint-disable-next-line no-console
846
934
  console.log(`Decision: ${plan.decision.action} - ${plan.decision.summary}`);
847
935
  // eslint-disable-next-line no-console
936
+ console.log(`Enforcement: ${plan.enforcement.mode} (shouldFail=${plan.enforcement.shouldFail})`);
937
+ // eslint-disable-next-line no-console
848
938
  console.log(`Plan data: ${planPath}`);
849
939
  // eslint-disable-next-line no-console
850
940
  console.log(`CI summary: ${summaryPath}`);
941
+ // eslint-disable-next-line no-console
942
+ console.log(`Plan metrics: ${metrics.summaryPath}`);
851
943
  if (plan.nextActions) {
852
944
  // eslint-disable-next-line no-console
853
945
  console.log(`Next action (run existing): ${plan.nextActions.runRecommendedTests || plan.nextActions.runSmokeSuite}`);
@@ -856,7 +948,8 @@ async function main() {
856
948
  // eslint-disable-next-line no-console
857
949
  console.log(`Next action (heal): ${plan.nextActions.healGeneratedTests}`);
858
950
  }
859
- if (args.failOnMustAddTests && plan.decision.action === 'must-add-tests') {
951
+ const failOnLegacyFlag = args.failOnMustAddTests && plan.decision.action === 'must-add-tests';
952
+ if (failOnLegacyFlag || plan.enforcement.shouldFail) {
860
953
  process.exit(2);
861
954
  }
862
955
  return;
@@ -0,0 +1,323 @@
1
+ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
2
+ // See LICENSE.txt for license information.
3
+ import { existsSync, readFileSync } from 'fs';
4
+ import { isAbsolute, join } from 'path';
5
+ import { LLMProviderFactory } from '../provider_factory.js';
6
+ import { normalizePath, tokenize, uniqueTokens } from './utils.js';
7
+ function extractJson(text) {
8
+ const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
9
+ const candidates = fenced ? [fenced[1], text] : [text];
10
+ for (const candidate of candidates) {
11
+ const start = candidate.indexOf('{');
12
+ const end = candidate.lastIndexOf('}');
13
+ if (start < 0 || end <= start) {
14
+ continue;
15
+ }
16
+ const raw = candidate.slice(start, end + 1);
17
+ try {
18
+ const parsed = JSON.parse(raw);
19
+ if (parsed && Array.isArray(parsed.flows)) {
20
+ return parsed;
21
+ }
22
+ }
23
+ catch {
24
+ // Ignore parse failure and keep trying other candidates.
25
+ }
26
+ }
27
+ return null;
28
+ }
29
+ function resolveContextFiles(appRoot, testsRoot, files) {
30
+ const resolved = [];
31
+ const seen = new Set();
32
+ const maxCharsPerFile = 12000;
33
+ const maxTotalChars = 32000;
34
+ let totalChars = 0;
35
+ for (const file of files) {
36
+ const candidates = isAbsolute(file)
37
+ ? [file]
38
+ : [join(testsRoot, file), join(appRoot, file)];
39
+ for (const candidate of candidates) {
40
+ const normalized = normalizePath(candidate);
41
+ if (seen.has(normalized) || !existsSync(candidate)) {
42
+ continue;
43
+ }
44
+ const content = readFileSync(candidate, 'utf-8');
45
+ const trimmed = content.trim();
46
+ if (!trimmed) {
47
+ seen.add(normalized);
48
+ continue;
49
+ }
50
+ const remaining = Math.max(0, maxTotalChars - totalChars);
51
+ if (remaining <= 0) {
52
+ return resolved;
53
+ }
54
+ const clipped = trimmed.slice(0, Math.min(maxCharsPerFile, remaining));
55
+ resolved.push({ path: normalized, content: clipped });
56
+ seen.add(normalized);
57
+ totalChars += clipped.length;
58
+ break;
59
+ }
60
+ }
61
+ return resolved;
62
+ }
63
+ function priorityFromEntry(entry) {
64
+ if (entry.priority === 'P0' || entry.priority === 'P1' || entry.priority === 'P2') {
65
+ return entry.priority;
66
+ }
67
+ const score = typeof entry.score === 'number' ? entry.score : 0;
68
+ if (score >= 8) {
69
+ return 'P0';
70
+ }
71
+ if (score >= 5) {
72
+ return 'P1';
73
+ }
74
+ return 'P2';
75
+ }
76
+ function normalizeFlowId(value) {
77
+ return normalizePath(value)
78
+ .replace(/[^a-zA-Z0-9/_-]+/g, '_')
79
+ .replace(/\/{2,}/g, '/')
80
+ .replace(/^\/+/, '')
81
+ .replace(/\/+$/, '')
82
+ .trim();
83
+ }
84
+ function sanitizeReasons(reasons, fallback) {
85
+ if (!Array.isArray(reasons)) {
86
+ return [fallback];
87
+ }
88
+ const cleaned = reasons.filter((entry) => typeof entry === 'string').map((entry) => entry.trim()).filter(Boolean);
89
+ return cleaned.length > 0 ? cleaned : [fallback];
90
+ }
91
+ function sanitizeKeywords(keywords, fallbackTokens) {
92
+ if (!Array.isArray(keywords)) {
93
+ return uniqueTokens(fallbackTokens).slice(0, 20);
94
+ }
95
+ const fromAI = keywords.filter((entry) => typeof entry === 'string').flatMap((entry) => tokenize(entry));
96
+ return uniqueTokens([...fromAI, ...fallbackTokens]).slice(0, 20);
97
+ }
98
+ function summarizeFiles(files, changedFileSet, maxFiles) {
99
+ const sorted = [...files].sort((a, b) => {
100
+ const aChanged = changedFileSet.has(a.relativePath) ? 1 : 0;
101
+ const bChanged = changedFileSet.has(b.relativePath) ? 1 : 0;
102
+ if (aChanged !== bChanged) {
103
+ return bChanged - aChanged;
104
+ }
105
+ const aSignals = (a.isUI ? 1 : 0) + (a.isScreen ? 1 : 0) + (a.isState ? 1 : 0) + (a.hasInteractions ? 1 : 0);
106
+ const bSignals = (b.isUI ? 1 : 0) + (b.isScreen ? 1 : 0) + (b.isState ? 1 : 0) + (b.hasInteractions ? 1 : 0);
107
+ if (aSignals !== bSignals) {
108
+ return bSignals - aSignals;
109
+ }
110
+ return a.relativePath.localeCompare(b.relativePath);
111
+ }).slice(0, Math.max(20, maxFiles));
112
+ return sorted.map((file) => ({
113
+ path: file.relativePath,
114
+ changed: changedFileSet.has(file.relativePath),
115
+ isUI: file.isUI,
116
+ isScreen: file.isScreen,
117
+ isComponent: file.isComponent,
118
+ isState: file.isState,
119
+ isStyle: file.isStyle,
120
+ hasInteractions: file.hasInteractions,
121
+ keywords: file.keywords.slice(0, 20),
122
+ audience: file.audience,
123
+ flags: file.flags?.map((flag) => ({ name: flag.name, source: flag.source, defaultState: flag.defaultState })),
124
+ }));
125
+ }
126
+ function mergeFlow(existing, candidate) {
127
+ if (!existing) {
128
+ return candidate;
129
+ }
130
+ const priorityOrder = { P0: 0, P1: 1, P2: 2 };
131
+ const priority = priorityOrder[candidate.priority] < priorityOrder[existing.priority]
132
+ ? candidate.priority
133
+ : existing.priority;
134
+ return {
135
+ ...existing,
136
+ name: existing.name || candidate.name,
137
+ kind: existing.kind || candidate.kind,
138
+ score: Math.max(existing.score, candidate.score),
139
+ priority,
140
+ reasons: uniqueTokens([...(existing.reasons || []), ...(candidate.reasons || [])]),
141
+ keywords: uniqueTokens([...(existing.keywords || []), ...(candidate.keywords || [])]),
142
+ files: uniqueTokens([...(existing.files || []), ...(candidate.files || [])]),
143
+ audience: uniqueTokens([...(existing.audience || []), ...(candidate.audience || [])]),
144
+ flags: [...(existing.flags || []), ...(candidate.flags || [])],
145
+ };
146
+ }
147
+ export async function mapAIFlowsFromFiles(appRoot, testsRoot, config, files, changedFiles) {
148
+ const providerName = config.provider === 'auto' ? 'auto' : config.provider;
149
+ const warnings = [];
150
+ if (!config.enabled) {
151
+ return {
152
+ enabled: false,
153
+ used: false,
154
+ provider: providerName,
155
+ flowCount: 0,
156
+ warnings,
157
+ flows: [],
158
+ };
159
+ }
160
+ if (files.length === 0) {
161
+ warnings.push('AI flow analysis skipped: no analyzable files were found.');
162
+ return {
163
+ enabled: true,
164
+ used: false,
165
+ provider: providerName,
166
+ flowCount: 0,
167
+ warnings,
168
+ flows: [],
169
+ };
170
+ }
171
+ const changedFileSet = new Set(changedFiles.map((entry) => normalizePath(entry)));
172
+ const summarizedFiles = summarizeFiles(files, changedFileSet, config.maxFilesPerRequest);
173
+ const allowedFiles = new Set(files.map((entry) => entry.relativePath));
174
+ const fileByPath = new Map(files.map((entry) => [entry.relativePath, entry]));
175
+ const contextFiles = resolveContextFiles(appRoot, testsRoot, config.contextFiles || []);
176
+ const contextBlock = contextFiles.length > 0
177
+ ? contextFiles.map((entry) => `### Context: ${entry.path}\n${entry.content}`).join('\n\n')
178
+ : 'No optional markdown context files were found.';
179
+ if (contextFiles.length === 0) {
180
+ warnings.push('AI flow analysis context files were not found; continuing without optional markdown context.');
181
+ }
182
+ let provider;
183
+ try {
184
+ provider = config.provider === 'auto'
185
+ ? await LLMProviderFactory.createFromEnv()
186
+ : LLMProviderFactory.createFromString(config.provider);
187
+ }
188
+ catch (error) {
189
+ const message = error instanceof Error ? error.message : String(error);
190
+ warnings.push(`AI flow analysis unavailable (${providerName}): ${message}`);
191
+ return {
192
+ enabled: true,
193
+ used: false,
194
+ provider: providerName,
195
+ flowCount: 0,
196
+ warnings,
197
+ flows: [],
198
+ };
199
+ }
200
+ const prompt = [
201
+ 'You are an expert frontend impact analyst for Mattermost.',
202
+ 'Build impacted user flows from changed frontend files.',
203
+ 'This must be flow-centric, not file-centric.',
204
+ '',
205
+ 'Return strict JSON only with this exact shape:',
206
+ '{"flows":[{"id":"<flow_id>","name":"<name>","kind":"flow|screen","priority":"P0|P1|P2","score":10,"reasons":["..."],"keywords":["..."],"files":["relative/path.tsx"]}]}',
207
+ '',
208
+ 'Rules:',
209
+ '- Use only file paths listed in FILES.',
210
+ '- Every flow must have at least one file.',
211
+ '- Keep IDs stable and lowercase with underscores when possible.',
212
+ '- Prioritize true user-impacting flows; avoid low-value internal buckets.',
213
+ '- Keep at most 6 file paths per flow.',
214
+ `- Keep at most ${Math.max(1, config.maxFlowsPerRequest)} flows.`,
215
+ '',
216
+ `CHANGED_FILES (${changedFileSet.size}):`,
217
+ JSON.stringify(Array.from(changedFileSet), null, 2),
218
+ '',
219
+ `FILES (${summarizedFiles.length}):`,
220
+ JSON.stringify(summarizedFiles, null, 2),
221
+ '',
222
+ contextBlock,
223
+ ].join('\n');
224
+ let parsed = null;
225
+ try {
226
+ const response = await provider.generateText(prompt, {
227
+ maxTokens: Math.max(800, config.maxTokens),
228
+ temperature: Math.max(0, Math.min(1, config.temperature)),
229
+ systemPrompt: 'Return only valid JSON. Do not include markdown fences unless necessary.',
230
+ });
231
+ parsed = extractJson(response.text);
232
+ }
233
+ catch (error) {
234
+ const message = error instanceof Error ? error.message : String(error);
235
+ warnings.push(`AI flow analysis request failed (${provider.name}): ${message}`);
236
+ return {
237
+ enabled: true,
238
+ used: false,
239
+ provider: provider.name,
240
+ flowCount: 0,
241
+ warnings,
242
+ flows: [],
243
+ };
244
+ }
245
+ if (!parsed) {
246
+ warnings.push(`AI flow analysis returned invalid JSON (${provider.name}).`);
247
+ return {
248
+ enabled: true,
249
+ used: false,
250
+ provider: provider.name,
251
+ flowCount: 0,
252
+ warnings,
253
+ flows: [],
254
+ };
255
+ }
256
+ const flowsById = new Map();
257
+ for (const entry of parsed.flows) {
258
+ if (!entry || !Array.isArray(entry.files)) {
259
+ continue;
260
+ }
261
+ const validFiles = Array.from(new Set(entry.files
262
+ .filter((value) => typeof value === 'string')
263
+ .map((value) => normalizePath(value))
264
+ .filter((value) => allowedFiles.has(value)))).slice(0, 6);
265
+ if (validFiles.length === 0) {
266
+ continue;
267
+ }
268
+ const rawId = typeof entry.id === 'string' && entry.id.trim()
269
+ ? entry.id
270
+ : (typeof entry.name === 'string' && entry.name.trim() ? entry.name : validFiles[0]);
271
+ const id = normalizeFlowId(rawId);
272
+ if (!id) {
273
+ continue;
274
+ }
275
+ const fallbackTokens = uniqueTokens([
276
+ ...tokenize(id),
277
+ ...(typeof entry.name === 'string' ? tokenize(entry.name) : []),
278
+ ...validFiles.flatMap((value) => tokenize(value)),
279
+ ]);
280
+ const linkedFiles = validFiles.map((path) => fileByPath.get(path)).filter(Boolean);
281
+ const audience = uniqueTokens(linkedFiles.flatMap((file) => file.audience || []));
282
+ const flags = linkedFiles.flatMap((file) => file.flags || []);
283
+ const score = typeof entry.score === 'number' && Number.isFinite(entry.score)
284
+ ? Math.max(1, Math.min(20, Math.round(entry.score)))
285
+ : Math.max(4, validFiles.length * 2);
286
+ const flow = {
287
+ id,
288
+ name: typeof entry.name === 'string' && entry.name.trim() ? entry.name.trim() : id.replace(/[_/.-]+/g, ' '),
289
+ kind: entry.kind === 'screen' ? 'screen' : 'flow',
290
+ score,
291
+ priority: priorityFromEntry(entry),
292
+ reasons: sanitizeReasons(entry.reasons, 'AI flow analysis identified impacted behavior'),
293
+ keywords: sanitizeKeywords(entry.keywords, fallbackTokens),
294
+ files: validFiles,
295
+ audience,
296
+ flags,
297
+ };
298
+ flowsById.set(id, mergeFlow(flowsById.get(id), flow));
299
+ if (flowsById.size >= Math.max(1, config.maxFlowsPerRequest)) {
300
+ break;
301
+ }
302
+ }
303
+ const flows = Array.from(flowsById.values());
304
+ if (flows.length === 0) {
305
+ warnings.push('AI flow analysis did not return any valid flows linked to changed files.');
306
+ return {
307
+ enabled: true,
308
+ used: false,
309
+ provider: provider.name,
310
+ flowCount: 0,
311
+ warnings,
312
+ flows: [],
313
+ };
314
+ }
315
+ return {
316
+ enabled: true,
317
+ used: true,
318
+ provider: provider.name,
319
+ flowCount: flows.length,
320
+ warnings,
321
+ flows,
322
+ };
323
+ }