@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/cli/commands/train.d.ts.map +1 -1
- package/dist/cli/commands/train.js +125 -50
- package/dist/cli/parse_args.d.ts.map +1 -1
- package/dist/cli/parse_args.js +3 -0
- package/dist/cli/types.d.ts +3 -0
- package/dist/cli/types.d.ts.map +1 -1
- package/dist/esm/cli/commands/train.js +125 -50
- package/dist/esm/cli/parse_args.js +3 -0
- package/dist/esm/logger.js +29 -2
- package/dist/esm/pipeline/orchestrator.js +17 -3
- package/dist/esm/training/enricher.js +82 -11
- package/dist/esm/training/merger.js +77 -10
- package/dist/esm/training/scanner.js +523 -2
- package/dist/esm/training/validator.js +58 -2
- package/dist/logger.d.ts +9 -0
- package/dist/logger.d.ts.map +1 -1
- package/dist/logger.js +29 -2
- package/dist/pipeline/orchestrator.d.ts.map +1 -1
- package/dist/pipeline/orchestrator.js +17 -3
- package/dist/training/enricher.d.ts +3 -1
- package/dist/training/enricher.d.ts.map +1 -1
- package/dist/training/enricher.js +82 -11
- package/dist/training/merger.d.ts +11 -1
- package/dist/training/merger.d.ts.map +1 -1
- package/dist/training/merger.js +77 -10
- package/dist/training/scanner.d.ts +28 -2
- package/dist/training/scanner.d.ts.map +1 -1
- package/dist/training/scanner.js +527 -2
- package/dist/training/types.d.ts +8 -0
- package/dist/training/types.d.ts.map +1 -1
- package/dist/training/validator.d.ts +5 -0
- package/dist/training/validator.d.ts.map +1 -1
- package/dist/training/validator.js +59 -2
- package/package.json +1 -1
package/dist/esm/logger.js
CHANGED
|
@@ -48,6 +48,7 @@ function logLevelToString(level) {
|
|
|
48
48
|
export class Logger {
|
|
49
49
|
constructor(minLevel) {
|
|
50
50
|
this.level = minLevel ?? getLogLevelFromEnv();
|
|
51
|
+
this.jsonMode = process.env.LOG_FORMAT?.toLowerCase() === 'json';
|
|
51
52
|
}
|
|
52
53
|
error(message, context) {
|
|
53
54
|
if (this.level >= LogLevel.ERROR) {
|
|
@@ -72,11 +73,37 @@ export class Logger {
|
|
|
72
73
|
setLevel(level) {
|
|
73
74
|
this.level = level;
|
|
74
75
|
}
|
|
76
|
+
setJsonMode(enabled) {
|
|
77
|
+
this.jsonMode = enabled;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Start a timer for measuring duration of an operation.
|
|
81
|
+
* Returns an object with `end()` that logs at DEBUG level and returns elapsed ms.
|
|
82
|
+
*/
|
|
83
|
+
timer(label) {
|
|
84
|
+
const start = performance.now();
|
|
85
|
+
return {
|
|
86
|
+
end: () => {
|
|
87
|
+
const elapsed = Math.round(performance.now() - start);
|
|
88
|
+
this.debug(`${label} completed`, { durationMs: elapsed });
|
|
89
|
+
return elapsed;
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
75
93
|
log(level, message, context) {
|
|
76
94
|
const timestamp = new Date().toISOString();
|
|
77
95
|
const levelStr = logLevelToString(level);
|
|
78
|
-
|
|
79
|
-
|
|
96
|
+
let output;
|
|
97
|
+
if (this.jsonMode) {
|
|
98
|
+
const entry = { ts: timestamp, level: levelStr, msg: message };
|
|
99
|
+
if (context)
|
|
100
|
+
entry.ctx = context;
|
|
101
|
+
output = JSON.stringify(entry);
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
const contextStr = context ? ` ${JSON.stringify(context)}` : '';
|
|
105
|
+
output = `[${timestamp}] [${levelStr}] ${message}${contextStr}`;
|
|
106
|
+
}
|
|
80
107
|
if (level <= LogLevel.WARN) {
|
|
81
108
|
console.error(output);
|
|
82
109
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { existsSync, mkdirSync, writeFileSync } from 'fs';
|
|
4
4
|
import { join } from 'path';
|
|
5
5
|
import { getChangedFiles } from '../agent/git.js';
|
|
6
|
+
import { logger } from '../logger.js';
|
|
6
7
|
import { preprocess } from './stage0_preprocess.js';
|
|
7
8
|
import { runImpactStage } from './stage1_impact.js';
|
|
8
9
|
import { runCoverageStage } from './stage2_coverage.js';
|
|
@@ -58,20 +59,25 @@ export async function runPipeline(config) {
|
|
|
58
59
|
const reportPath = writeReport(config.testsRoot, emptyReport);
|
|
59
60
|
return { report: emptyReport, reportPath, warnings: allWarnings };
|
|
60
61
|
}
|
|
62
|
+
const timings = {};
|
|
61
63
|
// Step 2: Preprocess — deterministic file classification + route family binding
|
|
64
|
+
const preprocessTimer = logger.timer('preprocess');
|
|
62
65
|
const preprocessResult = preprocess(changedFiles, {
|
|
63
66
|
appPath: config.appPath,
|
|
64
67
|
testsRoot: config.testsRoot,
|
|
65
68
|
routeFamilies: config.routeFamilies,
|
|
66
69
|
apiSurface: config.apiSurface,
|
|
67
70
|
});
|
|
71
|
+
timings.preprocess = preprocessTimer.end();
|
|
68
72
|
allWarnings.push(...preprocessResult.warnings);
|
|
69
73
|
let decisions = [];
|
|
70
74
|
// Step 3: Impact stage — AI-powered flow identification per family
|
|
71
75
|
if (stages.includes('impact')) {
|
|
76
|
+
const impactTimer = logger.timer('impact');
|
|
72
77
|
const impactResult = await runImpactStage(preprocessResult.familyGroups, preprocessResult.manifest, preprocessResult.specIndex, preprocessResult.apiSurface, preprocessResult.context, config.impact || {});
|
|
73
78
|
decisions = impactResult.decisions;
|
|
74
79
|
allWarnings.push(...impactResult.warnings);
|
|
80
|
+
timings.impact = impactTimer.end();
|
|
75
81
|
// Check cannot_determine ratio
|
|
76
82
|
const cannotDetermineRatio = computeCannotDetermineRatio(decisions);
|
|
77
83
|
if (cannotDetermineRatio > 0.3) {
|
|
@@ -80,18 +86,23 @@ export async function runPipeline(config) {
|
|
|
80
86
|
}
|
|
81
87
|
// Step 4: Coverage stage — AI-powered spec coverage evaluation
|
|
82
88
|
if (stages.includes('coverage') && decisions.length > 0) {
|
|
89
|
+
const coverageTimer = logger.timer('coverage');
|
|
83
90
|
const coverageResult = await runCoverageStage(decisions, preprocessResult.specIndex, preprocessResult.context, config.testsRoot, config.coverage || {});
|
|
84
91
|
decisions = coverageResult.decisions;
|
|
92
|
+
timings.coverage = coverageTimer.end();
|
|
85
93
|
allWarnings.push(...coverageResult.warnings);
|
|
86
94
|
}
|
|
87
95
|
// Step 5: Generation stage — AI-powered spec generation for create_spec / add_scenarios
|
|
88
96
|
if (stages.includes('generation') && decisions.length > 0) {
|
|
97
|
+
const generationTimer = logger.timer('generation');
|
|
89
98
|
const generationResult = await runGenerationStage(decisions, preprocessResult.apiSurface, config.testsRoot, config.generation || {});
|
|
90
99
|
generatedSpecs = generationResult.generated;
|
|
100
|
+
timings.generation = generationTimer.end();
|
|
91
101
|
allWarnings.push(...generationResult.warnings);
|
|
92
102
|
}
|
|
93
103
|
// Step 6: Heal stage — MCP-backed playwright-test-healer for failing/flaky specs
|
|
94
104
|
if (stages.includes('heal')) {
|
|
105
|
+
const healTimer = logger.timer('heal');
|
|
95
106
|
const healTargets = resolveHealTargets(config.testsRoot, {
|
|
96
107
|
playwrightReportPath: config.playwrightReportPath,
|
|
97
108
|
generatedSpecs,
|
|
@@ -103,6 +114,7 @@ export async function runPipeline(config) {
|
|
|
103
114
|
else {
|
|
104
115
|
allWarnings.push('Heal stage: no targets found (no failing specs in report, no generated specs).');
|
|
105
116
|
}
|
|
117
|
+
timings.heal = healTimer.end();
|
|
106
118
|
}
|
|
107
119
|
// Build report
|
|
108
120
|
const report = {
|
|
@@ -118,16 +130,18 @@ export async function runPipeline(config) {
|
|
|
118
130
|
generationAgent: stages.includes('generation') ? (config.generation?.provider || 'auto') : undefined,
|
|
119
131
|
},
|
|
120
132
|
};
|
|
121
|
-
const reportPath = writeReport(config.testsRoot, report, healResult);
|
|
133
|
+
const reportPath = writeReport(config.testsRoot, report, healResult, timings);
|
|
122
134
|
return { report, reportPath, warnings: allWarnings, generated: generatedSpecs, healResult };
|
|
123
135
|
}
|
|
124
|
-
function writeReport(testsRoot, report, healResult) {
|
|
136
|
+
function writeReport(testsRoot, report, healResult, timings) {
|
|
125
137
|
const outputDir = join(testsRoot, '.e2e-ai-agents');
|
|
126
138
|
if (!existsSync(outputDir)) {
|
|
127
139
|
mkdirSync(outputDir, { recursive: true });
|
|
128
140
|
}
|
|
141
|
+
// Include timings in the JSON report if available
|
|
142
|
+
const reportWithTimings = timings ? { ...report, timings } : report;
|
|
129
143
|
const jsonPath = join(outputDir, 'pipeline-report.json');
|
|
130
|
-
writeFileSync(jsonPath, JSON.stringify(
|
|
144
|
+
writeFileSync(jsonPath, JSON.stringify(reportWithTimings, null, 2), 'utf-8');
|
|
131
145
|
const mdPath = join(outputDir, 'pipeline-report.md');
|
|
132
146
|
writeFileSync(mdPath, renderMarkdown(report, healResult), 'utf-8');
|
|
133
147
|
return jsonPath;
|
|
@@ -59,9 +59,47 @@ function sampleFiles(dir, maxFiles) {
|
|
|
59
59
|
walk(dir);
|
|
60
60
|
return files;
|
|
61
61
|
}
|
|
62
|
-
|
|
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(
|
|
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,13 +249,22 @@ 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;
|
|
201
265
|
let totalCost = 0;
|
|
266
|
+
let requestCount = 0;
|
|
267
|
+
let totalResponseMs = 0;
|
|
202
268
|
const skipped = [];
|
|
203
269
|
// Process in chunks of 4 families
|
|
204
270
|
const chunkSize = 4;
|
|
@@ -218,7 +284,7 @@ export async function enrichFamilies(families, scanned, projectRoot, provider, b
|
|
|
218
284
|
enriched.push(...chunk);
|
|
219
285
|
continue;
|
|
220
286
|
}
|
|
221
|
-
let prompt = buildEnrichPrompt(scannedChunk, projectRoot);
|
|
287
|
+
let prompt = buildEnrichPrompt(scannedChunk, projectRoot, testsRoot);
|
|
222
288
|
if (prompt.length > MAX_PROMPT_CHARS) {
|
|
223
289
|
// Truncate at the last complete section boundary to avoid malformed input
|
|
224
290
|
const lastSectionEnd = prompt.lastIndexOf('\n---\n', MAX_PROMPT_CHARS);
|
|
@@ -231,15 +297,18 @@ export async function enrichFamilies(families, scanned, projectRoot, provider, b
|
|
|
231
297
|
prompt = prompt.slice(0, MAX_PROMPT_CHARS);
|
|
232
298
|
}
|
|
233
299
|
}
|
|
234
|
-
let
|
|
300
|
+
let timeoutTimer;
|
|
235
301
|
try {
|
|
236
302
|
const timeoutPromise = new Promise((_, reject) => {
|
|
237
|
-
|
|
303
|
+
timeoutTimer = setTimeout(() => reject(new Error('LLM request timed out')), LLM_TIMEOUT_MS);
|
|
238
304
|
});
|
|
305
|
+
const reqStart = performance.now();
|
|
239
306
|
const response = await Promise.race([
|
|
240
307
|
provider.generateText(prompt, { maxTokens: 4096, temperature: 0.3 }),
|
|
241
308
|
timeoutPromise,
|
|
242
309
|
]);
|
|
310
|
+
totalResponseMs += performance.now() - reqStart;
|
|
311
|
+
requestCount++;
|
|
243
312
|
totalTokens += (response.usage?.inputTokens ?? 0) + (response.usage?.outputTokens ?? 0);
|
|
244
313
|
totalCost += response.cost ?? 0;
|
|
245
314
|
const entries = parseEnrichResponse(response.text);
|
|
@@ -260,8 +329,8 @@ export async function enrichFamilies(families, scanned, projectRoot, provider, b
|
|
|
260
329
|
enriched.push(...chunk);
|
|
261
330
|
}
|
|
262
331
|
finally {
|
|
263
|
-
if (
|
|
264
|
-
clearTimeout(
|
|
332
|
+
if (timeoutTimer)
|
|
333
|
+
clearTimeout(timeoutTimer);
|
|
265
334
|
}
|
|
266
335
|
}
|
|
267
336
|
return {
|
|
@@ -269,5 +338,7 @@ export async function enrichFamilies(families, scanned, projectRoot, provider, b
|
|
|
269
338
|
tokensUsed: totalTokens,
|
|
270
339
|
costUSD: Math.round(totalCost * 100) / 100,
|
|
271
340
|
skippedFamilies: skipped,
|
|
341
|
+
requestCount,
|
|
342
|
+
avgResponseMs: requestCount > 0 ? Math.round(totalResponseMs / requestCount) : 0,
|
|
272
343
|
};
|
|
273
344
|
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
112
|
-
|
|
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
|
|
128
|
-
|
|
129
|
-
|
|
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);
|