@yasserkhanorg/e2e-agents 1.3.2 → 1.4.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/README.md +40 -9
- package/dist/cli/commands/train.d.ts +3 -0
- package/dist/cli/commands/train.d.ts.map +1 -0
- package/dist/cli/commands/train.js +307 -0
- package/dist/cli/parse_args.d.ts.map +1 -1
- package/dist/cli/parse_args.js +7 -1
- package/dist/cli/types.d.ts +6 -1
- package/dist/cli/types.d.ts.map +1 -1
- package/dist/cli/usage.d.ts.map +1 -1
- package/dist/cli/usage.js +7 -1
- package/dist/cli.js +5 -0
- package/dist/esm/cli/commands/train.js +271 -0
- package/dist/esm/cli/parse_args.js +7 -1
- package/dist/esm/cli/usage.js +7 -1
- package/dist/esm/cli.js +5 -0
- package/dist/esm/index.js +5 -0
- package/dist/esm/knowledge/route_families.js +2 -2
- package/dist/esm/training/enricher.js +273 -0
- package/dist/esm/training/merger.js +137 -0
- package/dist/esm/training/scanner.js +386 -0
- package/dist/esm/training/types.js +6 -0
- package/dist/esm/training/validator.js +153 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -1
- package/dist/knowledge/route_families.d.ts +2 -0
- package/dist/knowledge/route_families.d.ts.map +1 -1
- package/dist/knowledge/route_families.js +2 -0
- package/dist/training/enricher.d.ts +15 -0
- package/dist/training/enricher.d.ts.map +1 -0
- package/dist/training/enricher.js +278 -0
- package/dist/training/merger.d.ts +5 -0
- package/dist/training/merger.d.ts.map +1 -0
- package/dist/training/merger.js +141 -0
- package/dist/training/scanner.d.ts +5 -0
- package/dist/training/scanner.d.ts.map +1 -0
- package/dist/training/scanner.js +391 -0
- package/dist/training/types.d.ts +109 -0
- package/dist/training/types.d.ts.map +1 -0
- package/dist/training/types.js +9 -0
- package/dist/training/validator.d.ts +16 -0
- package/dist/training/validator.d.ts.map +1 -0
- package/dist/training/validator.js +160 -0
- package/package.json +1 -1
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
2
|
+
// See LICENSE.txt for license information.
|
|
3
|
+
import { existsSync } from 'fs';
|
|
4
|
+
import { join, resolve } from 'path';
|
|
5
|
+
import { isGuessedRoute } from './types.js';
|
|
6
|
+
function unionArrays(existing, incoming) {
|
|
7
|
+
const set = new Set(existing || []);
|
|
8
|
+
for (const item of incoming) {
|
|
9
|
+
set.add(item);
|
|
10
|
+
}
|
|
11
|
+
return Array.from(set);
|
|
12
|
+
}
|
|
13
|
+
function mergeFamily(existing, scanned) {
|
|
14
|
+
const merged = { ...existing };
|
|
15
|
+
// Structural fields: union arrays
|
|
16
|
+
merged.webappPaths = unionArrays(existing.webappPaths, scanned.webappPaths);
|
|
17
|
+
merged.serverPaths = unionArrays(existing.serverPaths, scanned.serverPaths);
|
|
18
|
+
merged.specDirs = unionArrays(existing.specDirs, scanned.specDirs);
|
|
19
|
+
merged.cypressSpecDirs = unionArrays(existing.cypressSpecDirs, scanned.cypressSpecDirs);
|
|
20
|
+
merged.tags = unionArrays(existing.tags, scanned.tags);
|
|
21
|
+
// Routes: only update if existing looks like a guess
|
|
22
|
+
if (isGuessedRoute(existing.routes) && !isGuessedRoute(scanned.routes)) {
|
|
23
|
+
merged.routes = scanned.routes;
|
|
24
|
+
}
|
|
25
|
+
// Human-curated fields: never overwrite (priority, userFlows, pageObjects, components)
|
|
26
|
+
// Merge features
|
|
27
|
+
if (scanned.features.length > 0) {
|
|
28
|
+
const existingFeatures = existing.features || [];
|
|
29
|
+
const existingIds = new Set(existingFeatures.map((f) => f.id));
|
|
30
|
+
const newFeatures = scanned.features.filter((f) => !existingIds.has(f.id));
|
|
31
|
+
if (newFeatures.length > 0) {
|
|
32
|
+
merged.features = [
|
|
33
|
+
...existingFeatures,
|
|
34
|
+
...newFeatures.map((f) => ({
|
|
35
|
+
id: f.id,
|
|
36
|
+
webappPaths: f.webappPaths,
|
|
37
|
+
serverPaths: f.serverPaths,
|
|
38
|
+
specDirs: f.specDirs,
|
|
39
|
+
})),
|
|
40
|
+
];
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return merged;
|
|
44
|
+
}
|
|
45
|
+
function scannedToRouteFamily(scanned) {
|
|
46
|
+
const family = {
|
|
47
|
+
id: scanned.id,
|
|
48
|
+
routes: scanned.routes,
|
|
49
|
+
};
|
|
50
|
+
if (scanned.webappPaths.length > 0)
|
|
51
|
+
family.webappPaths = scanned.webappPaths;
|
|
52
|
+
if (scanned.serverPaths.length > 0)
|
|
53
|
+
family.serverPaths = scanned.serverPaths;
|
|
54
|
+
if (scanned.specDirs.length > 0)
|
|
55
|
+
family.specDirs = scanned.specDirs;
|
|
56
|
+
if (scanned.cypressSpecDirs.length > 0)
|
|
57
|
+
family.cypressSpecDirs = scanned.cypressSpecDirs;
|
|
58
|
+
if (scanned.tags.length > 0)
|
|
59
|
+
family.tags = scanned.tags;
|
|
60
|
+
if (scanned.features.length > 0) {
|
|
61
|
+
family.features = scanned.features.map((f) => ({
|
|
62
|
+
id: f.id,
|
|
63
|
+
webappPaths: f.webappPaths.length > 0 ? f.webappPaths : undefined,
|
|
64
|
+
serverPaths: f.serverPaths.length > 0 ? f.serverPaths : undefined,
|
|
65
|
+
specDirs: f.specDirs.length > 0 ? f.specDirs : undefined,
|
|
66
|
+
}));
|
|
67
|
+
}
|
|
68
|
+
return family;
|
|
69
|
+
}
|
|
70
|
+
export function mergeFamilies(existing, scanned) {
|
|
71
|
+
const existingFamilies = existing?.families || [];
|
|
72
|
+
const existingMap = new Map(existingFamilies.map((f) => [f.id, f]));
|
|
73
|
+
const scannedMap = new Map(scanned.map((f) => [f.id, f]));
|
|
74
|
+
const newFamilies = [];
|
|
75
|
+
const updatedFamilies = [];
|
|
76
|
+
const mergedFamilies = [];
|
|
77
|
+
// Process existing families
|
|
78
|
+
for (const ef of existingFamilies) {
|
|
79
|
+
const sf = scannedMap.get(ef.id);
|
|
80
|
+
if (sf) {
|
|
81
|
+
mergedFamilies.push(mergeFamily(ef, sf));
|
|
82
|
+
updatedFamilies.push(ef.id);
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
// Keep untouched
|
|
86
|
+
mergedFamilies.push({ ...ef });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// Add new families from scanner
|
|
90
|
+
for (const sf of scanned) {
|
|
91
|
+
if (!existingMap.has(sf.id)) {
|
|
92
|
+
mergedFamilies.push(scannedToRouteFamily(sf));
|
|
93
|
+
newFamilies.push(sf.id);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const parts = [];
|
|
97
|
+
if (updatedFamilies.length > 0)
|
|
98
|
+
parts.push(`${updatedFamilies.length} families updated`);
|
|
99
|
+
if (newFamilies.length > 0)
|
|
100
|
+
parts.push(`${newFamilies.length} new families added`);
|
|
101
|
+
if (parts.length === 0)
|
|
102
|
+
parts.push('no changes');
|
|
103
|
+
return {
|
|
104
|
+
manifest: { families: mergedFamilies, source: existing?.source || 'train-scan' },
|
|
105
|
+
newFamilies,
|
|
106
|
+
updatedFamilies,
|
|
107
|
+
staleFamilies: [],
|
|
108
|
+
summary: parts.join(', '),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
export function detectStaleFamilies(manifest, projectRoot) {
|
|
112
|
+
const resolved = resolve(projectRoot);
|
|
113
|
+
const stale = [];
|
|
114
|
+
for (const family of manifest.families) {
|
|
115
|
+
const allPatterns = [
|
|
116
|
+
...(family.webappPaths || []),
|
|
117
|
+
...(family.serverPaths || []),
|
|
118
|
+
...(family.specDirs || []),
|
|
119
|
+
];
|
|
120
|
+
if (allPatterns.length === 0)
|
|
121
|
+
continue;
|
|
122
|
+
// Check if any pattern resolves to existing files/dirs
|
|
123
|
+
let hasAny = false;
|
|
124
|
+
for (const pattern of allPatterns) {
|
|
125
|
+
// Strip trailing glob (* or **) to get the directory
|
|
126
|
+
const dirPart = pattern.replace(/\/?\*.*$/, '');
|
|
127
|
+
if (dirPart && existsSync(join(resolved, dirPart))) {
|
|
128
|
+
hasAny = true;
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (!hasAny) {
|
|
133
|
+
stale.push(family.id);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return stale;
|
|
137
|
+
}
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
2
|
+
// See LICENSE.txt for license information.
|
|
3
|
+
import { readdirSync, readFileSync, lstatSync, existsSync } from 'fs';
|
|
4
|
+
import { join, relative, basename, resolve } from 'path';
|
|
5
|
+
const SOURCE_MAX_DEPTH = 3;
|
|
6
|
+
// One deeper than source to account for test framework wrapper dirs (e2e/, integration/)
|
|
7
|
+
const TEST_MAX_DEPTH = 5;
|
|
8
|
+
const SPEC_FILES_MAX_DEPTH = 10;
|
|
9
|
+
const SOURCE_ROOTS = ['src', 'app', 'pages', 'components', 'features', 'modules'];
|
|
10
|
+
const SERVER_ROOTS = ['server', 'api', 'cmd', 'model', 'services'];
|
|
11
|
+
const SKIP_DIRS = new Set([
|
|
12
|
+
'node_modules', '.git', '.next', '.nuxt', 'dist', 'build',
|
|
13
|
+
'coverage', '__pycache__', '.e2e-ai-agents', '.cache',
|
|
14
|
+
'vendor', 'third_party',
|
|
15
|
+
]);
|
|
16
|
+
const TEST_EXTENSIONS = ['.spec.ts', '.test.ts', '.spec.js', '.test.js', '.spec.tsx', '.test.tsx'];
|
|
17
|
+
const GO_TEST_SUFFIX = '_test.go';
|
|
18
|
+
/** Type-safe includes check for readonly arrays */
|
|
19
|
+
const includes = (arr, v) => arr.includes(v);
|
|
20
|
+
function isSkipped(name) {
|
|
21
|
+
return name.startsWith('.') || SKIP_DIRS.has(name);
|
|
22
|
+
}
|
|
23
|
+
function normalizeId(name) {
|
|
24
|
+
return name
|
|
25
|
+
.replace(/[A-Z]/g, (c, idx) => (idx > 0 ? `_${c.toLowerCase()}` : c.toLowerCase()))
|
|
26
|
+
.replace(/[^a-z0-9_]/g, '_')
|
|
27
|
+
.replace(/_+/g, '_')
|
|
28
|
+
.replace(/^_|_$/g, '');
|
|
29
|
+
}
|
|
30
|
+
function extractFamilyHint(dirPath, projectRoot) {
|
|
31
|
+
const rel = relative(projectRoot, dirPath).replace(/\\/g, '/');
|
|
32
|
+
const parts = rel.split('/').filter(Boolean);
|
|
33
|
+
// Skip the root category dir (src/, server/, tests/, etc.)
|
|
34
|
+
// Return the first meaningful subdirectory name
|
|
35
|
+
for (let i = 1; i < parts.length; i++) {
|
|
36
|
+
const part = parts[i];
|
|
37
|
+
if (!isSkipped(part) && part !== 'e2e' && part !== 'integration' && part !== 'functional') {
|
|
38
|
+
return normalizeId(part);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return normalizeId(parts[parts.length - 1] || basename(dirPath));
|
|
42
|
+
}
|
|
43
|
+
function walkDirs(root, projectRoot, category, maxDepth, results, depth = 0) {
|
|
44
|
+
if (depth > maxDepth || !existsSync(root)) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
let entries;
|
|
48
|
+
try {
|
|
49
|
+
entries = readdirSync(root);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// ENOENT or EACCES — skip inaccessible entries
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const hasSourceFiles = entries.some((e) => {
|
|
56
|
+
const ext = e.slice(e.lastIndexOf('.'));
|
|
57
|
+
return ['.ts', '.tsx', '.js', '.jsx', '.go', '.py', '.rs'].includes(ext);
|
|
58
|
+
});
|
|
59
|
+
const subdirs = entries.filter((e) => {
|
|
60
|
+
if (isSkipped(e))
|
|
61
|
+
return false;
|
|
62
|
+
try {
|
|
63
|
+
const stat = lstatSync(join(root, e));
|
|
64
|
+
if (stat.isSymbolicLink())
|
|
65
|
+
return false;
|
|
66
|
+
return stat.isDirectory();
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// ENOENT or EACCES — skip inaccessible entries
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
if (hasSourceFiles && depth >= 1) {
|
|
74
|
+
results.push({
|
|
75
|
+
path: resolve(root),
|
|
76
|
+
relativePath: relative(projectRoot, root).replace(/\\/g, '/'),
|
|
77
|
+
category,
|
|
78
|
+
familyHint: extractFamilyHint(root, projectRoot),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
for (const sub of subdirs) {
|
|
82
|
+
walkDirs(join(root, sub), projectRoot, category, maxDepth, results, depth + 1);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
export function discoverSourceDirs(projectRoot) {
|
|
86
|
+
const results = [];
|
|
87
|
+
const resolved = resolve(projectRoot);
|
|
88
|
+
let entries;
|
|
89
|
+
try {
|
|
90
|
+
entries = readdirSync(resolved);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// ENOENT or EACCES — skip inaccessible entries
|
|
94
|
+
return results;
|
|
95
|
+
}
|
|
96
|
+
for (const entry of entries) {
|
|
97
|
+
if (isSkipped(entry))
|
|
98
|
+
continue;
|
|
99
|
+
const fullPath = join(resolved, entry);
|
|
100
|
+
try {
|
|
101
|
+
const stat = lstatSync(fullPath);
|
|
102
|
+
if (stat.isSymbolicLink() || !stat.isDirectory())
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// ENOENT or EACCES — skip inaccessible entries
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (includes(SOURCE_ROOTS, entry)) {
|
|
110
|
+
walkDirs(fullPath, resolved, 'webapp', SOURCE_MAX_DEPTH, results);
|
|
111
|
+
}
|
|
112
|
+
else if (includes(SERVER_ROOTS, entry)) {
|
|
113
|
+
walkDirs(fullPath, resolved, 'server', SOURCE_MAX_DEPTH, results);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return results;
|
|
117
|
+
}
|
|
118
|
+
export function discoverTestDirs(projectRoot) {
|
|
119
|
+
const results = [];
|
|
120
|
+
const resolved = resolve(projectRoot);
|
|
121
|
+
function walk(dir, category, depth) {
|
|
122
|
+
if (depth > TEST_MAX_DEPTH || !existsSync(dir))
|
|
123
|
+
return;
|
|
124
|
+
let entries;
|
|
125
|
+
try {
|
|
126
|
+
entries = readdirSync(dir);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
// ENOENT or EACCES — skip inaccessible entries
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const hasTests = entries.some((e) => {
|
|
133
|
+
return TEST_EXTENSIONS.some((ext) => e.endsWith(ext)) || e.endsWith(GO_TEST_SUFFIX);
|
|
134
|
+
});
|
|
135
|
+
if (hasTests) {
|
|
136
|
+
results.push({
|
|
137
|
+
path: resolve(dir),
|
|
138
|
+
relativePath: relative(resolved, dir).replace(/\\/g, '/'),
|
|
139
|
+
category,
|
|
140
|
+
familyHint: extractFamilyHint(dir, resolved),
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
for (const entry of entries) {
|
|
144
|
+
if (isSkipped(entry))
|
|
145
|
+
continue;
|
|
146
|
+
const full = join(dir, entry);
|
|
147
|
+
try {
|
|
148
|
+
const stat = lstatSync(full);
|
|
149
|
+
if (stat.isSymbolicLink())
|
|
150
|
+
continue;
|
|
151
|
+
if (stat.isDirectory()) {
|
|
152
|
+
walk(full, category, depth + 1);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
// ENOENT or EACCES — skip inaccessible entries
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const testRoots = ['tests', 'test', 'e2e-tests', 'e2e', 'specs', 'spec'];
|
|
161
|
+
const cypressRoots = ['cypress/e2e', 'cypress/integration'];
|
|
162
|
+
for (const root of testRoots) {
|
|
163
|
+
walk(join(resolved, root), 'test', 0);
|
|
164
|
+
}
|
|
165
|
+
for (const root of cypressRoots) {
|
|
166
|
+
walk(join(resolved, root), 'cypress', 0);
|
|
167
|
+
}
|
|
168
|
+
// Also scan server dirs for Go test files
|
|
169
|
+
for (const root of SERVER_ROOTS) {
|
|
170
|
+
const serverPath = join(resolved, root);
|
|
171
|
+
if (existsSync(serverPath)) {
|
|
172
|
+
walk(serverPath, 'test', 0);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return results;
|
|
176
|
+
}
|
|
177
|
+
function extractTags(specFiles) {
|
|
178
|
+
const tags = new Set();
|
|
179
|
+
for (const file of specFiles) {
|
|
180
|
+
try {
|
|
181
|
+
const content = readFileSync(file, 'utf-8');
|
|
182
|
+
const matches = content.match(/@[a-zA-Z][a-zA-Z0-9_-]*/g);
|
|
183
|
+
if (matches) {
|
|
184
|
+
for (const m of matches) {
|
|
185
|
+
if (!m.startsWith('@playwright') && !m.startsWith('@param') && !m.startsWith('@returns')) {
|
|
186
|
+
tags.add(m);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
// ENOENT or EACCES — skip unreadable files
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return Array.from(tags);
|
|
196
|
+
}
|
|
197
|
+
function getSpecFiles(dir, depth = 0) {
|
|
198
|
+
if (depth > SPEC_FILES_MAX_DEPTH)
|
|
199
|
+
return [];
|
|
200
|
+
const files = [];
|
|
201
|
+
try {
|
|
202
|
+
for (const entry of readdirSync(dir)) {
|
|
203
|
+
const full = join(dir, entry);
|
|
204
|
+
try {
|
|
205
|
+
const stat = lstatSync(full);
|
|
206
|
+
if (stat.isSymbolicLink())
|
|
207
|
+
continue;
|
|
208
|
+
if (stat.isDirectory()) {
|
|
209
|
+
files.push(...getSpecFiles(full, depth + 1));
|
|
210
|
+
}
|
|
211
|
+
else if (TEST_EXTENSIONS.some((ext) => entry.endsWith(ext))) {
|
|
212
|
+
files.push(full);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
// ENOENT or EACCES — skip inaccessible entries
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
// ENOENT or EACCES — skip inaccessible directories
|
|
222
|
+
}
|
|
223
|
+
return files;
|
|
224
|
+
}
|
|
225
|
+
function buildGlobPattern(relativePath) {
|
|
226
|
+
const normalized = relativePath.replace(/\\/g, '/');
|
|
227
|
+
return `${normalized}/*`;
|
|
228
|
+
}
|
|
229
|
+
function groupByFamily(dirs) {
|
|
230
|
+
const groups = new Map();
|
|
231
|
+
for (const dir of dirs) {
|
|
232
|
+
const key = normalizeId(dir.familyHint);
|
|
233
|
+
if (!groups.has(key)) {
|
|
234
|
+
groups.set(key, { webapp: [], server: [], test: [], cypress: [] });
|
|
235
|
+
}
|
|
236
|
+
const group = groups.get(key);
|
|
237
|
+
if (dir.category === 'webapp')
|
|
238
|
+
group.webapp.push(dir);
|
|
239
|
+
else if (dir.category === 'server')
|
|
240
|
+
group.server.push(dir);
|
|
241
|
+
else if (dir.category === 'cypress')
|
|
242
|
+
group.cypress.push(dir);
|
|
243
|
+
else
|
|
244
|
+
group.test.push(dir);
|
|
245
|
+
}
|
|
246
|
+
return groups;
|
|
247
|
+
}
|
|
248
|
+
function detectFeatures(familyId, group, projectRoot) {
|
|
249
|
+
const features = [];
|
|
250
|
+
const webappSubdirs = new Map();
|
|
251
|
+
for (const dir of group.webapp) {
|
|
252
|
+
try {
|
|
253
|
+
for (const entry of readdirSync(dir.path)) {
|
|
254
|
+
if (isSkipped(entry))
|
|
255
|
+
continue;
|
|
256
|
+
const full = join(dir.path, entry);
|
|
257
|
+
try {
|
|
258
|
+
const stat = lstatSync(full);
|
|
259
|
+
if (stat.isSymbolicLink())
|
|
260
|
+
continue;
|
|
261
|
+
if (stat.isDirectory()) {
|
|
262
|
+
const hint = normalizeId(entry);
|
|
263
|
+
if (!webappSubdirs.has(hint))
|
|
264
|
+
webappSubdirs.set(hint, []);
|
|
265
|
+
webappSubdirs.get(hint).push({
|
|
266
|
+
path: full,
|
|
267
|
+
relativePath: relative(projectRoot, full).replace(/\\/g, '/'),
|
|
268
|
+
category: 'webapp',
|
|
269
|
+
familyHint: entry,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
// ENOENT or EACCES — skip inaccessible entries
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
// ENOENT or EACCES — skip inaccessible directories
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
for (const testDir of group.test) {
|
|
283
|
+
try {
|
|
284
|
+
for (const entry of readdirSync(testDir.path)) {
|
|
285
|
+
if (isSkipped(entry))
|
|
286
|
+
continue;
|
|
287
|
+
const full = join(testDir.path, entry);
|
|
288
|
+
try {
|
|
289
|
+
const stat = lstatSync(full);
|
|
290
|
+
if (stat.isSymbolicLink())
|
|
291
|
+
continue;
|
|
292
|
+
if (!stat.isDirectory())
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
// ENOENT or EACCES — skip inaccessible entries
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
const hint = normalizeId(entry);
|
|
300
|
+
if (webappSubdirs.has(hint)) {
|
|
301
|
+
const webDirs = webappSubdirs.get(hint);
|
|
302
|
+
features.push({
|
|
303
|
+
id: `${familyId}/${hint}`,
|
|
304
|
+
webappPaths: webDirs.map((d) => buildGlobPattern(d.relativePath)),
|
|
305
|
+
serverPaths: [],
|
|
306
|
+
specDirs: [relative(projectRoot, full).replace(/\\/g, '/') + '/'],
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
// ENOENT or EACCES — skip inaccessible directories
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return features;
|
|
316
|
+
}
|
|
317
|
+
export function scanProject(projectRoot) {
|
|
318
|
+
const resolved = resolve(projectRoot);
|
|
319
|
+
const sourceDirs = discoverSourceDirs(resolved);
|
|
320
|
+
const testDirs = discoverTestDirs(resolved);
|
|
321
|
+
const allDirs = [...sourceDirs, ...testDirs];
|
|
322
|
+
const groups = groupByFamily(allDirs);
|
|
323
|
+
const families = [];
|
|
324
|
+
for (const [familyId, group] of groups) {
|
|
325
|
+
const hasSrc = group.webapp.length > 0 || group.server.length > 0;
|
|
326
|
+
const hasTests = group.test.length > 0 || group.cypress.length > 0;
|
|
327
|
+
if (!hasSrc && !hasTests)
|
|
328
|
+
continue;
|
|
329
|
+
const allSpecFiles = [];
|
|
330
|
+
for (const td of [...group.test, ...group.cypress]) {
|
|
331
|
+
allSpecFiles.push(...getSpecFiles(td.path));
|
|
332
|
+
}
|
|
333
|
+
const features = detectFeatures(familyId, group, resolved);
|
|
334
|
+
families.push({
|
|
335
|
+
id: familyId,
|
|
336
|
+
routes: [`/${familyId}`],
|
|
337
|
+
webappPaths: group.webapp.map((d) => buildGlobPattern(d.relativePath)),
|
|
338
|
+
serverPaths: group.server.map((d) => buildGlobPattern(d.relativePath)),
|
|
339
|
+
specDirs: group.test.map((d) => d.relativePath + '/'),
|
|
340
|
+
cypressSpecDirs: group.cypress.map((d) => d.relativePath + '/'),
|
|
341
|
+
tags: extractTags(allSpecFiles),
|
|
342
|
+
features,
|
|
343
|
+
routesGuessed: true,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
const familyIds = new Set(families.map((f) => f.id));
|
|
347
|
+
const unmatchedSourceDirs = sourceDirs.filter((d) => !familyIds.has(normalizeId(d.familyHint)));
|
|
348
|
+
const unmatchedTestDirs = testDirs.filter((d) => !familyIds.has(normalizeId(d.familyHint)));
|
|
349
|
+
let totalSourceFiles = 0;
|
|
350
|
+
let totalTestFiles = 0;
|
|
351
|
+
for (const dir of sourceDirs) {
|
|
352
|
+
try {
|
|
353
|
+
totalSourceFiles += readdirSync(dir.path).filter((e) => {
|
|
354
|
+
try {
|
|
355
|
+
const stat = lstatSync(join(dir.path, e));
|
|
356
|
+
return !stat.isSymbolicLink() && !stat.isDirectory();
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
// ENOENT or EACCES — skip inaccessible entries
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
}).length;
|
|
363
|
+
}
|
|
364
|
+
catch {
|
|
365
|
+
// ENOENT or EACCES — skip inaccessible directories
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
for (const dir of testDirs) {
|
|
369
|
+
try {
|
|
370
|
+
totalTestFiles += getSpecFiles(dir.path).length;
|
|
371
|
+
}
|
|
372
|
+
catch {
|
|
373
|
+
// ENOENT or EACCES — skip inaccessible directories
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return {
|
|
377
|
+
families,
|
|
378
|
+
unmatchedSourceDirs,
|
|
379
|
+
unmatchedTestDirs,
|
|
380
|
+
stats: {
|
|
381
|
+
totalSourceFiles,
|
|
382
|
+
totalTestFiles,
|
|
383
|
+
familyCount: families.length,
|
|
384
|
+
},
|
|
385
|
+
};
|
|
386
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
2
|
+
// See LICENSE.txt for license information.
|
|
3
|
+
/** Routes that look like bare "/<id>" are scanner-generated guesses */
|
|
4
|
+
export function isGuessedRoute(routes) {
|
|
5
|
+
return routes.every((r) => /^\/[a-z][a-z0-9_]*$/.test(r));
|
|
6
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
2
|
+
// See LICENSE.txt for license information.
|
|
3
|
+
import { execFileSync } from 'child_process';
|
|
4
|
+
import { resolve } from 'path';
|
|
5
|
+
import { bindFilesToFamilies } from '../knowledge/route_families.js';
|
|
6
|
+
export function parseGitLog(log) {
|
|
7
|
+
const commits = [];
|
|
8
|
+
let current = null;
|
|
9
|
+
for (const line of log.split('\n')) {
|
|
10
|
+
const trimmed = line.trim();
|
|
11
|
+
if (!trimmed) {
|
|
12
|
+
if (current) {
|
|
13
|
+
commits.push(current);
|
|
14
|
+
current = null;
|
|
15
|
+
}
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
if (trimmed.includes('|') && /^[0-9a-f]{7,40}\|/.test(trimmed)) {
|
|
19
|
+
if (current) {
|
|
20
|
+
commits.push(current);
|
|
21
|
+
}
|
|
22
|
+
const [hash, ...rest] = trimmed.split('|');
|
|
23
|
+
current = { hash, message: rest.join('|'), files: [] };
|
|
24
|
+
}
|
|
25
|
+
else if (current) {
|
|
26
|
+
current.files.push(trimmed);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (current) {
|
|
30
|
+
commits.push(current);
|
|
31
|
+
}
|
|
32
|
+
return commits;
|
|
33
|
+
}
|
|
34
|
+
export function getCommitFiles(projectRoot, since) {
|
|
35
|
+
const resolved = resolve(projectRoot);
|
|
36
|
+
let log;
|
|
37
|
+
try {
|
|
38
|
+
log = execFileSync('git', ['log', '--name-only', '--pretty=format:%H|%s', `${since}..HEAD`], {
|
|
39
|
+
cwd: resolved,
|
|
40
|
+
encoding: 'utf-8',
|
|
41
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
42
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
console.warn(`[train] git log failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
return parseGitLog(log);
|
|
50
|
+
}
|
|
51
|
+
export function validateCommit(manifest, files, hash, message) {
|
|
52
|
+
// Filter out non-source files
|
|
53
|
+
const sourceFiles = files.filter((f) => {
|
|
54
|
+
return !f.endsWith('.md') && !f.endsWith('.json') && !f.endsWith('.yml') && !f.endsWith('.yaml') &&
|
|
55
|
+
!f.startsWith('.') && !f.includes('node_modules/');
|
|
56
|
+
});
|
|
57
|
+
if (sourceFiles.length === 0) {
|
|
58
|
+
return { hash, message, changedFiles: [], boundFiles: 0, unboundFiles: [], familiesHit: [] };
|
|
59
|
+
}
|
|
60
|
+
const bindings = bindFilesToFamilies(sourceFiles, manifest);
|
|
61
|
+
const bound = bindings.filter((b) => b.bindings.length > 0);
|
|
62
|
+
const unbound = bindings.filter((b) => b.bindings.length === 0);
|
|
63
|
+
const familiesHit = new Set();
|
|
64
|
+
for (const b of bound) {
|
|
65
|
+
for (const binding of b.bindings) {
|
|
66
|
+
familiesHit.add(binding.family);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
hash,
|
|
71
|
+
message,
|
|
72
|
+
changedFiles: sourceFiles,
|
|
73
|
+
boundFiles: bound.length,
|
|
74
|
+
unboundFiles: unbound.map((b) => b.file),
|
|
75
|
+
familiesHit: Array.from(familiesHit),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
export function buildValidationReport(commits, manifest) {
|
|
79
|
+
let totalFiles = 0;
|
|
80
|
+
let boundFiles = 0;
|
|
81
|
+
let unboundFiles = 0;
|
|
82
|
+
const familyHits = {};
|
|
83
|
+
const unboundCounts = {};
|
|
84
|
+
for (const commit of commits) {
|
|
85
|
+
totalFiles += commit.changedFiles.length;
|
|
86
|
+
boundFiles += commit.boundFiles;
|
|
87
|
+
unboundFiles += commit.unboundFiles.length;
|
|
88
|
+
for (const fam of commit.familiesHit) {
|
|
89
|
+
familyHits[fam] = (familyHits[fam] || 0) + 1;
|
|
90
|
+
}
|
|
91
|
+
for (const uf of commit.unboundFiles) {
|
|
92
|
+
unboundCounts[uf] = (unboundCounts[uf] || 0) + 1;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const allFamilyIds = manifest.families.map((f) => f.id);
|
|
96
|
+
const hitFamilyIds = new Set(Object.keys(familyHits));
|
|
97
|
+
const neverHitFamilies = allFamilyIds.filter((id) => !hitFamilyIds.has(id));
|
|
98
|
+
// Cluster unbound files by directory
|
|
99
|
+
const dirCounts = {};
|
|
100
|
+
for (const [file, count] of Object.entries(unboundCounts)) {
|
|
101
|
+
const dir = file.split('/').slice(0, -1).join('/');
|
|
102
|
+
dirCounts[dir] = (dirCounts[dir] || 0) + count;
|
|
103
|
+
}
|
|
104
|
+
const unboundFileClusters = Object.entries(dirCounts)
|
|
105
|
+
.sort(([, a], [, b]) => b - a)
|
|
106
|
+
.slice(0, 20)
|
|
107
|
+
.map(([pattern, count]) => ({
|
|
108
|
+
pattern: `${pattern}/*`,
|
|
109
|
+
count,
|
|
110
|
+
suggestedFamily: pattern.split('/').pop() || 'unknown',
|
|
111
|
+
}));
|
|
112
|
+
return {
|
|
113
|
+
totalCommits: commits.length,
|
|
114
|
+
totalFiles,
|
|
115
|
+
boundFiles,
|
|
116
|
+
unboundFiles,
|
|
117
|
+
coveragePercent: totalFiles > 0 ? Math.round((boundFiles / totalFiles) * 100) : 100,
|
|
118
|
+
commits,
|
|
119
|
+
familyHits,
|
|
120
|
+
neverHitFamilies,
|
|
121
|
+
unboundFileClusters,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
export function formatValidationReport(report) {
|
|
125
|
+
const lines = [];
|
|
126
|
+
lines.push(`Validated against ${report.totalCommits} commits`);
|
|
127
|
+
lines.push('');
|
|
128
|
+
lines.push(`Coverage: ${report.coveragePercent}% of files bound (${report.boundFiles}/${report.totalFiles})`);
|
|
129
|
+
lines.push('');
|
|
130
|
+
// Family hit distribution
|
|
131
|
+
const sorted = Object.entries(report.familyHits).sort(([, a], [, b]) => b - a);
|
|
132
|
+
if (sorted.length > 0) {
|
|
133
|
+
lines.push('Family hit distribution:');
|
|
134
|
+
const maxHits = sorted[0][1];
|
|
135
|
+
for (const [family, hits] of sorted) {
|
|
136
|
+
const bar = '\u2588'.repeat(Math.max(1, Math.round((hits / maxHits) * 12)));
|
|
137
|
+
lines.push(` ${family.padEnd(20)} ${bar} ${hits} commits`);
|
|
138
|
+
}
|
|
139
|
+
if (report.neverHitFamilies.length > 0) {
|
|
140
|
+
lines.push(` (never hit)${' '.repeat(8)}${report.neverHitFamilies.join(', ')}`);
|
|
141
|
+
}
|
|
142
|
+
lines.push('');
|
|
143
|
+
}
|
|
144
|
+
// Unbound file clusters
|
|
145
|
+
if (report.unboundFileClusters.length > 0) {
|
|
146
|
+
lines.push(`Unbound files (${report.unboundFiles} files across ${report.totalCommits} commits):`);
|
|
147
|
+
for (const cluster of report.unboundFileClusters.slice(0, 10)) {
|
|
148
|
+
lines.push(` ${cluster.pattern.padEnd(50)} — ${cluster.count} commits (suggest: ${cluster.suggestedFamily})`);
|
|
149
|
+
}
|
|
150
|
+
lines.push('');
|
|
151
|
+
}
|
|
152
|
+
return lines.join('\n');
|
|
153
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -54,4 +54,9 @@ export type { PlanReport } from './agent/plan.js';
|
|
|
54
54
|
export { runAgenticGeneration } from './agentic/runner.js';
|
|
55
55
|
export type { ScenarioInput, AgenticRunOptions } from './agentic/runner.js';
|
|
56
56
|
export type { AgenticConfig, AgenticResult, AgenticSummary, PlaywrightRunResult, TestFailure } from './agentic/types.js';
|
|
57
|
+
export { scanProject } from './training/scanner.js';
|
|
58
|
+
export { mergeFamilies, detectStaleFamilies } from './training/merger.js';
|
|
59
|
+
export { enrichFamilies } from './training/enricher.js';
|
|
60
|
+
export { getCommitFiles, validateCommit, buildValidationReport, formatValidationReport } from './training/validator.js';
|
|
61
|
+
export type { ScanResult, ScannedFamily, ScannedFeature, DiscoveredDir, EnrichmentResult, ValidationReport, CommitValidation, MergeResult, TrainOptions, } from './training/types.js';
|
|
57
62
|
//# sourceMappingURL=index.d.ts.map
|