@sdods/mcp 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE +17 -0
  2. package/dist/.tsbuildinfo +1 -0
  3. package/dist/cli.d.ts +50 -0
  4. package/dist/cli.js +171 -0
  5. package/dist/fs.d.ts +45 -0
  6. package/dist/fs.js +184 -0
  7. package/dist/index.d.ts +15 -0
  8. package/dist/index.js +15 -0
  9. package/dist/install/index.d.ts +46 -0
  10. package/dist/install/index.js +146 -0
  11. package/dist/prompts/index.d.ts +33 -0
  12. package/dist/prompts/index.js +151 -0
  13. package/dist/proposals.d.ts +71 -0
  14. package/dist/proposals.js +188 -0
  15. package/dist/registry/adapters.d.ts +46 -0
  16. package/dist/registry/adapters.js +76 -0
  17. package/dist/registry/registry.d.ts +96 -0
  18. package/dist/registry/registry.js +141 -0
  19. package/dist/resources.d.ts +5 -0
  20. package/dist/resources.js +130 -0
  21. package/dist/server.d.ts +29 -0
  22. package/dist/server.js +143 -0
  23. package/dist/tools/analyze.d.ts +87 -0
  24. package/dist/tools/analyze.js +820 -0
  25. package/dist/tools/data.d.ts +18 -0
  26. package/dist/tools/data.js +153 -0
  27. package/dist/tools/feature.d.ts +53 -0
  28. package/dist/tools/feature.js +340 -0
  29. package/dist/tools/index.d.ts +12 -0
  30. package/dist/tools/index.js +23 -0
  31. package/dist/tools/issue-schedule.d.ts +22 -0
  32. package/dist/tools/issue-schedule.js +193 -0
  33. package/dist/tools/project.d.ts +11 -0
  34. package/dist/tools/project.js +144 -0
  35. package/dist/tools/proposal.d.ts +19 -0
  36. package/dist/tools/proposal.js +89 -0
  37. package/dist/tools/run.d.ts +49 -0
  38. package/dist/tools/run.js +387 -0
  39. package/dist/transports/http.d.ts +40 -0
  40. package/dist/transports/http.js +151 -0
  41. package/dist/transports/stdio.d.ts +6 -0
  42. package/dist/transports/stdio.js +15 -0
  43. package/package.json +57 -0
@@ -0,0 +1,820 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, readFileSync, statSync } from 'node:fs';
3
+ import { join, relative, resolve } from 'node:path';
4
+ import { z } from 'zod';
5
+ import { projectRoot, readJson, readYaml, walk } from '../fs.js';
6
+ import { defineTool, summarize } from '../registry/registry.js';
7
+ import { parseGherkin } from './feature.js';
8
+ const FRAMEWORKS = [
9
+ { name: 'next', deps: ['next'], files: ['next.config.js', 'next.config.mjs', 'next.config.ts'] },
10
+ { name: 'nuxt', deps: ['nuxt'], files: ['nuxt.config.ts'] },
11
+ { name: 'react', deps: ['react', 'react-dom'] },
12
+ { name: 'vue', deps: ['vue'] },
13
+ { name: 'angular', deps: ['@angular/core'], files: ['angular.json'] },
14
+ { name: 'svelte', deps: ['svelte', '@sveltejs/kit'], files: ['svelte.config.js'] },
15
+ { name: 'express', deps: ['express'] },
16
+ { name: 'fastify', deps: ['fastify'] },
17
+ { name: 'nestjs', deps: ['@nestjs/core'] },
18
+ { name: 'spring', files: ['pom.xml', 'build.gradle'] },
19
+ { name: 'django', files: ['manage.py'] },
20
+ { name: 'flask', deps: ['flask'] },
21
+ ];
22
+ const ROUTE_PATTERNS = [
23
+ {
24
+ framework: 'express/fastify',
25
+ re: /\.(get|post|put|patch|delete)\(\s*['"`]([^'"`]+)['"`]/g,
26
+ fileFilter: (f) => /\.(js|ts|mjs|cjs)$/.test(f),
27
+ },
28
+ {
29
+ framework: 'react-router',
30
+ re: /path\s*[:=]\s*['"`]([^'"`]+)['"`]/g,
31
+ fileFilter: (f) => /\.(jsx|tsx|js|ts)$/.test(f),
32
+ },
33
+ {
34
+ framework: 'nestjs',
35
+ re: /@(Get|Post|Put|Patch|Delete)\(\s*['"`]?([^'"`)]*)['"`]?\)/g,
36
+ fileFilter: (f) => /\.ts$/.test(f),
37
+ },
38
+ {
39
+ framework: 'spring',
40
+ re: /@(Get|Post|Put|Patch|Delete|Request)Mapping\(\s*(?:value\s*=\s*)?"([^"]+)"/g,
41
+ fileFilter: (f) => /\.(java|kt)$/.test(f),
42
+ },
43
+ {
44
+ framework: 'flask/django',
45
+ re: /(?:@app\.route|path)\(\s*['"]([^'"]+)['"]/g,
46
+ fileFilter: (f) => /\.py$/.test(f),
47
+ },
48
+ ];
49
+ const TEST_ID_ATTRS = ['data-testid', 'data-test', 'data-cy', 'data-qa', 'data-test-id'];
50
+ export function analyzeApp(appPath) {
51
+ const root = resolve(appPath);
52
+ if (!existsSync(root))
53
+ throw Object.assign(new Error(`Path not found: ${root}`), { error: { code: 'NOT_FOUND' } });
54
+ const pkg = readJson(join(root, 'package.json'));
55
+ const deps = { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) };
56
+ const frameworks = [];
57
+ for (const fw of FRAMEWORKS) {
58
+ const evidence = [];
59
+ for (const d of fw.deps ?? [])
60
+ if (deps[d])
61
+ evidence.push(`dependency ${d}@${deps[d]}`);
62
+ for (const f of fw.files ?? [])
63
+ if (existsSync(join(root, f)))
64
+ evidence.push(`file ${f}`);
65
+ if (evidence.length)
66
+ frameworks.push({
67
+ finding: fw.name,
68
+ confidence: Math.min(1, 0.5 + 0.25 * evidence.length),
69
+ evidence,
70
+ });
71
+ }
72
+ const files = walk(root, (f) => /\.(js|jsx|ts|tsx|mjs|cjs|vue|svelte|html|java|kt|py)$/.test(f) && statSync(f).size < 400_000, 4000);
73
+ // routes
74
+ const routes = new Map();
75
+ const nextRoutes = [];
76
+ for (const f of files) {
77
+ const rel = relative(root, f).replace(/\\/g, '/');
78
+ if (/^(src\/)?(app|pages)\/.*\/?(page|route)\.(tsx|jsx|ts|js)$/.test(rel) ||
79
+ /^(src\/)?pages\/.*\.(tsx|jsx)$/.test(rel)) {
80
+ const r = '/' +
81
+ rel
82
+ .replace(/^(src\/)?(app|pages)\//, '')
83
+ .replace(/\/(page|route)\.(tsx|jsx|ts|js)$/, '')
84
+ .replace(/\.(tsx|jsx)$/, '')
85
+ .replace(/index$/, '')
86
+ .replace(/\[([^\]]+)\]/g, ':$1');
87
+ nextRoutes.push(r.replace(/\/$/, '') || '/');
88
+ }
89
+ let text;
90
+ try {
91
+ text = readFileSync(f, 'utf8');
92
+ }
93
+ catch {
94
+ continue;
95
+ }
96
+ for (const p of ROUTE_PATTERNS) {
97
+ if (!p.fileFilter(f))
98
+ continue;
99
+ for (const m of text.matchAll(p.re)) {
100
+ const path = m[2] ?? m[1];
101
+ if (!path || !path.startsWith('/') || path.length > 120)
102
+ continue;
103
+ const key = `${p.framework}:${path}`;
104
+ const entry = routes.get(key) ?? { path, framework: p.framework, files: new Set() };
105
+ entry.files.add(rel);
106
+ routes.set(key, entry);
107
+ }
108
+ }
109
+ }
110
+ for (const r of nextRoutes)
111
+ routes.set(`next:${r}`, { path: r, framework: 'next/file-routing', files: new Set() });
112
+ // test-id attribute usage
113
+ const testIds = {};
114
+ for (const f of files.filter((x) => /\.(jsx|tsx|vue|svelte|html)$/.test(x))) {
115
+ let text;
116
+ try {
117
+ text = readFileSync(f, 'utf8');
118
+ }
119
+ catch {
120
+ continue;
121
+ }
122
+ for (const attr of TEST_ID_ATTRS) {
123
+ const n = (text.match(new RegExp(`${attr}\\s*=`, 'g')) ?? []).length;
124
+ if (n)
125
+ testIds[attr] = (testIds[attr] ?? 0) + n;
126
+ }
127
+ }
128
+ const testIdAttribute = Object.entries(testIds).sort((a, b) => b[1] - a[1])[0]?.[0];
129
+ // openapi
130
+ const openapi = walk(root, (f) => /(openapi|swagger)[^/]*\.(json|ya?ml)$/i.test(f), 200).map((f) => relative(root, f));
131
+ // existing tests
132
+ const tests = {
133
+ playwright: walk(root, (f) => /\.(spec|test)\.(ts|js|mjs)$/.test(f) && /playwright|page\./.test(safeRead(f)), 500).length + (existsSync(join(root, 'playwright.config.ts')) ? 1 : 0),
134
+ cypress: existsSync(join(root, 'cypress')) || Boolean(deps['cypress']),
135
+ cucumber: walk(root, (f) => f.endsWith('.feature'), 500).length,
136
+ jest: Boolean(deps['jest'] || deps['vitest']),
137
+ };
138
+ // auth hints
139
+ const authEvidence = [];
140
+ for (const d of [
141
+ 'next-auth',
142
+ '@auth/core',
143
+ 'passport',
144
+ 'keycloak-js',
145
+ '@okta/okta-auth-js',
146
+ 'firebase',
147
+ '@azure/msal-browser',
148
+ 'jsonwebtoken',
149
+ 'express-session',
150
+ ])
151
+ if (deps[d])
152
+ authEvidence.push(`dependency ${d}`);
153
+ const loginFiles = files
154
+ .filter((f) => /login|signin|sign-in|auth/i.test(relative(root, f)))
155
+ .slice(0, 10)
156
+ .map((f) => relative(root, f));
157
+ const auth = {
158
+ finding: authEvidence.some((e) => /next-auth|@auth\/core|keycloak|okta|msal|firebase/.test(e))
159
+ ? 'sso'
160
+ : loginFiles.length
161
+ ? 'form'
162
+ : 'none',
163
+ confidence: authEvidence.length || loginFiles.length ? 0.6 : 0.3,
164
+ evidence: [...authEvidence, ...loginFiles.map((f) => `file ${f}`)],
165
+ };
166
+ // env + urls
167
+ const envFiles = [
168
+ '.env',
169
+ '.env.example',
170
+ '.env.local',
171
+ '.env.development',
172
+ '.env.staging',
173
+ '.env.production',
174
+ ].filter((f) => existsSync(join(root, f)));
175
+ const urls = new Set();
176
+ for (const f of envFiles)
177
+ for (const m of safeRead(join(root, f)).matchAll(/https?:\/\/[^\s'"]+/g))
178
+ urls.add(m[0]);
179
+ const ports = new Set();
180
+ for (const f of [
181
+ 'docker-compose.yml',
182
+ 'docker-compose.yaml',
183
+ 'vite.config.ts',
184
+ 'vite.config.js',
185
+ 'package.json',
186
+ ].filter((x) => existsSync(join(root, x)))) {
187
+ for (const m of safeRead(join(root, f)).matchAll(/(?:port\s*[:=]\s*|-p\s*|--port\s+|localhost:)(\d{4,5})/g))
188
+ ports.add(Number(m[1]));
189
+ }
190
+ const ci = [
191
+ '.github/workflows',
192
+ '.gitlab-ci.yml',
193
+ 'Jenkinsfile',
194
+ 'azure-pipelines.yml',
195
+ 'bitbucket-pipelines.yml',
196
+ ].filter((f) => existsSync(join(root, f)));
197
+ const routeList = [...routes.values()]
198
+ .map((r) => ({ path: r.path, framework: r.framework, files: [...r.files].slice(0, 3) }))
199
+ .sort((a, b) => a.path.localeCompare(b.path));
200
+ const slug = (pkg?.name ?? root.split('/').pop() ?? 'app')
201
+ .replace(/^@[^/]+\//, '')
202
+ .toLowerCase()
203
+ .replace(/[^a-z0-9-]+/g, '-')
204
+ .replace(/^-+|-+$/g, '');
205
+ const guessedUi = [...urls].find((u) => !/api/.test(u)) ?? `http://localhost:${[...ports][0] ?? 3000}`;
206
+ const guessedApi = [...urls].find((u) => /api/.test(u)) ?? `${guessedUi.replace(/\/$/, '')}/api`;
207
+ const proposal = {
208
+ slug,
209
+ name: pkg?.name ?? slug,
210
+ layers: [
211
+ ...new Set([
212
+ ...(routeList.length ||
213
+ frameworks.some((f) => /react|vue|angular|svelte|next|nuxt/.test(f.finding))
214
+ ? ['ui']
215
+ : []),
216
+ ...(openapi.length ||
217
+ routeList.some((r) => /express|fastify|nestjs|spring|flask/.test(r.framework))
218
+ ? ['api']
219
+ : []),
220
+ 'hybrid',
221
+ ]),
222
+ ],
223
+ browsers: ['chromium'],
224
+ testIdAttribute: testIdAttribute ?? 'data-testid',
225
+ routes: Object.fromEntries(routeList
226
+ .filter((r) => !/:/.test(r.path))
227
+ .slice(0, 40)
228
+ .map((r) => [
229
+ r.path === '/'
230
+ ? 'home'
231
+ : r.path
232
+ .replace(/^\//, '')
233
+ .replace(/[^a-z0-9]+/gi, '-')
234
+ .toLowerCase(),
235
+ r.path,
236
+ ])),
237
+ envs: { default: 'local', available: ['local'] },
238
+ auth: { strategy: auth.finding },
239
+ modules: groupModules(routeList.map((r) => r.path)),
240
+ env: {
241
+ local: { ui: { baseUrl: guessedUi }, api: { baseUrl: guessedApi, openapi: openapi[0] } },
242
+ },
243
+ };
244
+ const checklist = [
245
+ {
246
+ check: 'test-id attribute in templates',
247
+ ok: Boolean(testIdAttribute),
248
+ detail: testIdAttribute
249
+ ? `${testIdAttribute} used ${testIds[testIdAttribute]} times`
250
+ : 'no data-testid/data-test/data-cy attributes found; role/label locators will carry the load',
251
+ },
252
+ {
253
+ check: 'OpenAPI spec available',
254
+ ok: openapi.length > 0,
255
+ detail: openapi.join(', ') || 'none found; API contract tests need a spec',
256
+ },
257
+ { check: 'routes detected', ok: routeList.length > 0, detail: `${routeList.length} route(s)` },
258
+ {
259
+ check: 'existing automated tests',
260
+ ok: tests.playwright > 0 || tests.cucumber > 0 || tests.cypress,
261
+ detail: JSON.stringify(tests),
262
+ },
263
+ { check: 'CI configured', ok: ci.length > 0, detail: ci.join(', ') || 'none' },
264
+ { check: 'environment files', ok: envFiles.length > 0, detail: envFiles.join(', ') || 'none' },
265
+ ];
266
+ return {
267
+ root,
268
+ package: pkg?.name,
269
+ frameworks,
270
+ routes: routeList,
271
+ testIdUsage: testIds,
272
+ testIdAttribute,
273
+ openapi,
274
+ tests,
275
+ auth,
276
+ envFiles,
277
+ urls: [...urls],
278
+ ports: [...ports],
279
+ ci,
280
+ proposal,
281
+ checklist,
282
+ };
283
+ }
284
+ function groupModules(paths) {
285
+ const groups = new Map();
286
+ for (const p of paths) {
287
+ const seg = p
288
+ .split('/')
289
+ .filter(Boolean)[0]
290
+ ?.replace(/[^a-z0-9-]/gi, '-')
291
+ .toLowerCase() || 'home';
292
+ groups.set(seg, [...(groups.get(seg) ?? []), p]);
293
+ }
294
+ return [...groups.entries()].slice(0, 30).map(([name, routes]) => ({
295
+ name,
296
+ routes: routes.slice(0, 20),
297
+ testingTypes: ['functional', 'smoke', 'regression'],
298
+ }));
299
+ }
300
+ function safeRead(f) {
301
+ try {
302
+ return readFileSync(f, 'utf8');
303
+ }
304
+ catch {
305
+ return '';
306
+ }
307
+ }
308
+ function loadProjectYaml(root) {
309
+ return readYaml(join(root, 'sdods.project.yaml')) ?? {};
310
+ }
311
+ export function analyzeCoverage(root) {
312
+ const yaml = loadProjectYaml(root);
313
+ const features = walk(join(root, 'features'), (f) => f.endsWith('.feature')).map((f) => ({
314
+ rel: relative(root, f).replace(/\\/g, '/'),
315
+ parsed: parseGherkin(readFileSync(f, 'utf8'), relative(root, f)),
316
+ }));
317
+ const routeNames = Object.entries(yaml.routes ?? {});
318
+ const stepText = features.flatMap((f) => f.parsed.scenarios.flatMap((s) => s.steps.map((st) => ({ feature: f.rel, scenario: s.name, tags: s.tags, text: st.text }))));
319
+ const pageSrc = walk(join(root, 'pages'), (f) => f.endsWith('.ts'))
320
+ .map((f) => readFileSync(f, 'utf8'))
321
+ .join('\n');
322
+ const routes = routeNames.map(([name, path]) => {
323
+ const hits = stepText.filter((s) => s.text.includes(`"${name}"`) || s.text.includes(`"${path}"`));
324
+ const inPom = new RegExp(`goto\\(['"\`]${name}['"\`]\\)`).test(pageSrc);
325
+ const suites = [
326
+ ...new Set(hits.flatMap((h) => h.tags.filter((t) => /^@(smoke|regression|sanity)$/.test(t)))),
327
+ ];
328
+ return { route: name, path, scenarios: hits.length, viaPageObject: inPom, suites };
329
+ });
330
+ const endpoints = (yaml.modules ?? []).flatMap((m) => (m.endpoints ?? []).map((e) => ({ module: m.name, endpoint: e })));
331
+ const endpointRows = endpoints.map((e) => {
332
+ const hits = stepText.filter((s) => s.text.includes(e.endpoint.replace(/^[A-Z]+\s+/, '')));
333
+ return {
334
+ ...e,
335
+ scenarios: hits.length,
336
+ suites: [
337
+ ...new Set(hits.flatMap((h) => h.tags.filter((t) => /^@(smoke|regression|sanity)$/.test(t)))),
338
+ ],
339
+ };
340
+ });
341
+ const modules = (yaml.modules ?? []).map((m) => {
342
+ const dir = (m.path ?? m.name).replace(/^features\//, '');
343
+ const fs = features.filter((f) => f.rel.replace(/^features\//, '').startsWith(dir + '/') ||
344
+ f.rel.replace(/^features\//, '') === dir);
345
+ return {
346
+ module: m.name,
347
+ features: fs.length,
348
+ scenarios: fs.reduce((n, f) => n + f.parsed.scenarios.length, 0),
349
+ };
350
+ });
351
+ const uncoveredRoutes = routes
352
+ .filter((r) => r.scenarios === 0 && !r.viaPageObject)
353
+ .map((r) => r.route);
354
+ return {
355
+ routes,
356
+ endpoints: endpointRows,
357
+ modules,
358
+ uncoveredRoutes,
359
+ totals: {
360
+ features: features.length,
361
+ scenarios: stepText.length ? features.reduce((n, f) => n + f.parsed.scenarios.length, 0) : 0,
362
+ },
363
+ };
364
+ }
365
+ export function analyzeBestPractices(root) {
366
+ const yaml = loadProjectYaml(root);
367
+ const suites = (yaml.tags?.suites ?? ['smoke', 'regression', 'sanity']).map((s) => `@${s}`);
368
+ const findings = [];
369
+ const features = walk(join(root, 'features'), (f) => f.endsWith('.feature'));
370
+ let hasA11y = false;
371
+ let hasVisual = false;
372
+ let hasPerf = false;
373
+ const stepCounts = new Map();
374
+ for (const f of features) {
375
+ const rel = relative(root, f).replace(/\\/g, '/');
376
+ const text = readFileSync(f, 'utf8');
377
+ const parsed = parseGherkin(text, rel);
378
+ for (const e of parsed.errors)
379
+ findings.push({ severity: 'error', rule: 'gherkin', file: rel, message: e });
380
+ for (const sc of parsed.scenarios) {
381
+ const layers = sc.tags.filter((t) => ['@ui', '@api', '@hybrid'].includes(t));
382
+ if (layers.length !== 1)
383
+ findings.push({
384
+ severity: 'error',
385
+ rule: 'tag-layer',
386
+ file: rel,
387
+ line: sc.line,
388
+ message: `"${sc.name}" needs exactly one layer tag`,
389
+ fix: 'Add @ui, @api or @hybrid at Feature level',
390
+ });
391
+ const st = sc.tags.filter((t) => suites.includes(t));
392
+ if (st.length !== 1)
393
+ findings.push({
394
+ severity: 'error',
395
+ rule: 'tag-suite',
396
+ file: rel,
397
+ line: sc.line,
398
+ message: `"${sc.name}" needs exactly one suite tag (${suites.join(', ')})`,
399
+ });
400
+ if (sc.tags.includes('@a11y'))
401
+ hasA11y = true;
402
+ if (sc.tags.includes('@visual'))
403
+ hasVisual = true;
404
+ if (sc.tags.includes('@perf'))
405
+ hasPerf = true;
406
+ if (sc.keyword.toLowerCase().includes('outline') && !/#\s*title-format:/.test(text))
407
+ findings.push({
408
+ severity: 'warning',
409
+ rule: 'outline-title',
410
+ file: rel,
411
+ line: sc.line,
412
+ message: `Scenario Outline "${sc.name}" has no "# title-format:" comment; rows will be reported as Example #n`,
413
+ });
414
+ for (const step of sc.steps) {
415
+ stepCounts.set(step.text.replace(/"[^"]*"/g, '"…"'), (stepCounts.get(step.text.replace(/"[^"]*"/g, '"…"')) ?? 0) + 1);
416
+ if (/https?:\/\//.test(step.text))
417
+ findings.push({
418
+ severity: 'warning',
419
+ rule: 'hardcoded-url',
420
+ file: rel,
421
+ line: step.line,
422
+ message: 'Hardcoded URL in a step; use routes from the project yaml',
423
+ fix: 'Replace with `I navigate to the "<route>" page`',
424
+ });
425
+ if (/password|secret|token/i.test(step.text) &&
426
+ /"[^"$]{6,}"/.test(step.text) &&
427
+ !/\{\{/.test(step.text))
428
+ findings.push({
429
+ severity: 'warning',
430
+ rule: 'secret-literal',
431
+ file: rel,
432
+ line: step.line,
433
+ message: 'Possible secret literal in a step; use a dataset or {{var}}',
434
+ });
435
+ if (/wait(s)? (for )?\d+ (ms|seconds?)/i.test(step.text))
436
+ findings.push({
437
+ severity: 'warning',
438
+ rule: 'sleep',
439
+ file: rel,
440
+ line: step.line,
441
+ message: 'Fixed wait in a step; prefer auto-waiting assertions or polling steps',
442
+ });
443
+ }
444
+ }
445
+ const literalLogins = parsed.scenarios.filter((s) => s.steps.some((st) => /login with "[^{]+" and "[^{]+"/.test(st.text)));
446
+ if (literalLogins.length >= 2)
447
+ findings.push({
448
+ severity: 'info',
449
+ rule: 'data-driven-candidate',
450
+ file: rel,
451
+ message: `${literalLogins.length} scenarios log in with literal credentials; consider a Scenario Outline + dataset (@data-driven)`,
452
+ });
453
+ }
454
+ for (const f of walk(join(root, 'pages'), (x) => x.endsWith('.ts'))) {
455
+ const rel = relative(root, f).replace(/\\/g, '/');
456
+ const lines = readFileSync(f, 'utf8').split('\n');
457
+ lines.forEach((l, i) => {
458
+ if (/locator\(\s*['"`](\/\/|\.\.|xpath=)/.test(l))
459
+ findings.push({
460
+ severity: 'warning',
461
+ rule: 'xpath-locator',
462
+ file: rel,
463
+ line: i + 1,
464
+ message: 'XPath locator; prefer role/label/test id',
465
+ });
466
+ else if (/page\.locator\(\s*['"`][.#[]/.test(l) && !/heal\.locator/.test(l))
467
+ findings.push({
468
+ severity: 'info',
469
+ rule: 'css-locator',
470
+ file: rel,
471
+ line: i + 1,
472
+ message: 'Raw CSS locator without heal context',
473
+ fix: 'Wrap with this.heal.locator(primary, { role, name, testId, description })',
474
+ });
475
+ });
476
+ }
477
+ if (features.length && !hasA11y)
478
+ findings.push({
479
+ severity: 'info',
480
+ rule: 'coverage-a11y',
481
+ file: 'features/',
482
+ message: 'No @a11y scenario; add one per key page',
483
+ });
484
+ if (features.length && !hasVisual)
485
+ findings.push({
486
+ severity: 'info',
487
+ rule: 'coverage-visual',
488
+ file: 'features/',
489
+ message: 'No @visual baseline scenario',
490
+ });
491
+ if (features.length && !hasPerf)
492
+ findings.push({
493
+ severity: 'info',
494
+ rule: 'coverage-perf',
495
+ file: 'features/',
496
+ message: 'No @perf budget scenario',
497
+ });
498
+ const duplicates = [...stepCounts.entries()]
499
+ .filter(([, n]) => n >= 5)
500
+ .map(([text, n]) => ({ text, uses: n }));
501
+ return {
502
+ findings,
503
+ stats: {
504
+ features: features.length,
505
+ errors: findings.filter((f) => f.severity === 'error').length,
506
+ warnings: findings.filter((f) => f.severity === 'warning').length,
507
+ },
508
+ frequentSteps: duplicates.slice(0, 20),
509
+ };
510
+ }
511
+ export function analyzeLocators(root) {
512
+ const rows = [];
513
+ const files = [
514
+ ...walk(join(root, 'pages'), (f) => f.endsWith('.ts')),
515
+ ...walk(join(root, 'steps'), (f) => f.endsWith('.ts')),
516
+ ...walk(join(root, 'recorded'), (f) => f.endsWith('.ts')),
517
+ ];
518
+ for (const f of files) {
519
+ const rel = relative(root, f).replace(/\\/g, '/');
520
+ readFileSync(f, 'utf8')
521
+ .split('\n')
522
+ .forEach((l, i) => {
523
+ const kind = /getByRole\(/.test(l)
524
+ ? 'role'
525
+ : /getByLabel\(/.test(l)
526
+ ? 'label'
527
+ : /getByTestId\(/.test(l)
528
+ ? 'testid'
529
+ : /getByPlaceholder\(/.test(l)
530
+ ? 'placeholder'
531
+ : /getByText\(/.test(l)
532
+ ? 'text'
533
+ : /locator\(\s*['"`](\/\/|xpath=)/.test(l)
534
+ ? 'xpath'
535
+ : /locator\(\s*['"`]/.test(l)
536
+ ? 'css'
537
+ : undefined;
538
+ if (kind)
539
+ rows.push({
540
+ file: rel,
541
+ line: i + 1,
542
+ kind,
543
+ snippet: l.trim().slice(0, 140),
544
+ healed: /heal\.locator/.test(l),
545
+ });
546
+ });
547
+ }
548
+ const byKind = {};
549
+ for (const r of rows)
550
+ byKind[r.kind] = (byKind[r.kind] ?? 0) + 1;
551
+ const fragile = rows.filter((r) => (r.kind === 'css' || r.kind === 'xpath') && !r.healed);
552
+ return {
553
+ byKind,
554
+ total: rows.length,
555
+ fragile,
556
+ score: rows.length ? Math.round((100 * (rows.length - fragile.length)) / rows.length) : 100,
557
+ };
558
+ }
559
+ export function analyzeChangeImpact(root, rootDir, range) {
560
+ let changed = [];
561
+ try {
562
+ changed = execFileSync('git', ['diff', '--name-only', range], {
563
+ cwd: rootDir,
564
+ encoding: 'utf8',
565
+ })
566
+ .split('\n')
567
+ .filter(Boolean);
568
+ }
569
+ catch (e) {
570
+ throw Object.assign(new Error(`git diff failed for range "${range}": ${e.message.split('\n')[0]}`), { error: { code: 'INVALID_ARGS' } });
571
+ }
572
+ const projectRel = relative(rootDir, root).replace(/\\/g, '/');
573
+ const features = walk(join(root, 'features'), (f) => f.endsWith('.feature')).map((f) => ({
574
+ rel: relative(root, f).replace(/\\/g, '/'),
575
+ parsed: parseGherkin(readFileSync(f, 'utf8')),
576
+ }));
577
+ const impacted = new Map();
578
+ const add = (feature, why) => impacted.set(feature, new Set([...(impacted.get(feature) ?? []), why]));
579
+ const yaml = loadProjectYaml(root);
580
+ for (const file of changed) {
581
+ if (file.startsWith(`${projectRel}/features/`))
582
+ add(file.replace(`${projectRel}/`, ''), 'feature changed');
583
+ if (file.startsWith(`${projectRel}/pages/`) || file.startsWith(`${projectRel}/steps/`)) {
584
+ const base = file
585
+ .split('/')
586
+ .pop()
587
+ .replace(/\.(steps|page)?\.ts$/i, '')
588
+ .replace(/Page$/, '')
589
+ .toLowerCase();
590
+ for (const f of features)
591
+ if (f.rel.toLowerCase().includes(base))
592
+ add(f.rel, `${file} changed`);
593
+ }
594
+ if (file.startsWith(`${projectRel}/data/`))
595
+ for (const f of features)
596
+ if (f.parsed.scenarios.some((s) => s.tags.some((t) => t.startsWith('@data')) ||
597
+ s.steps.some((st) => /dataset/.test(st.text))))
598
+ add(f.rel, `data changed: ${file}`);
599
+ if (file.startsWith(`${projectRel}/sdods.project.yaml`) ||
600
+ file.startsWith(`${projectRel}/envs/`))
601
+ for (const f of features)
602
+ add(f.rel, `config changed: ${file}`);
603
+ // application source: map by route/module keywords
604
+ for (const m of yaml.modules ?? []) {
605
+ if (file.toLowerCase().includes(`/${m.name}`))
606
+ for (const f of features)
607
+ if (f.rel.includes(`/${m.name}/`))
608
+ add(f.rel, `module ${m.name} source changed: ${file}`);
609
+ }
610
+ }
611
+ const rows = [...impacted.entries()].map(([feature, reasons]) => ({
612
+ feature,
613
+ reasons: [...reasons],
614
+ }));
615
+ const tagExpr = rows.length
616
+ ? `re-run: sdods run -p <slug> --feature ${rows.map((r) => r.feature).join(' --feature ')}`
617
+ : 'no impacted features detected';
618
+ return { range, changedFiles: changed, impacted: rows, suggestion: tagExpr };
619
+ }
620
+ export const analyzeTools = [
621
+ defineTool({
622
+ name: 'analyze_project',
623
+ title: 'Analyze an application',
624
+ description: 'Scan an application repository: framework, routes, OpenAPI spec, test-id attribute, auth hints, env files, CI. Returns evidence, a proposed sdods.project.yaml and an onboarding checklist.',
625
+ shape: {
626
+ path: z
627
+ .string()
628
+ .describe('path to the application repo (absolute or relative to the SDODS repo)'),
629
+ },
630
+ access: 'read',
631
+ domain: 'projects',
632
+ capability: 'analyze',
633
+ annotations: { openWorldHint: false },
634
+ docsPath: '/docs/getting-started/onboard-existing-app',
635
+ handler: async (args, ctx) => {
636
+ const data = analyzeApp(resolve(ctx.rootDir, args.path));
637
+ return {
638
+ text: summarize(`Analysis of ${args.path}`, {
639
+ frameworks: data.frameworks,
640
+ testIdAttribute: data.testIdAttribute,
641
+ routes: data.routes.length,
642
+ checklist: data.checklist,
643
+ proposal: data.proposal,
644
+ }),
645
+ data,
646
+ };
647
+ },
648
+ }),
649
+ defineTool({
650
+ name: 'analyze_routes',
651
+ title: 'Detect routes',
652
+ description: 'Routes and endpoints detected in an application repository (framework-specific heuristics).',
653
+ shape: { path: z.string() },
654
+ access: 'read',
655
+ domain: 'projects',
656
+ capability: 'analyze',
657
+ handler: async (args, ctx) => {
658
+ const data = analyzeApp(resolve(ctx.rootDir, args.path));
659
+ return {
660
+ text: summarize('Routes', data.routes),
661
+ data: { routes: data.routes, openapi: data.openapi },
662
+ };
663
+ },
664
+ }),
665
+ defineTool({
666
+ name: 'analyze_coverage',
667
+ title: 'Coverage by scenarios',
668
+ description: 'Which project routes, module endpoints and modules are exercised by scenarios, by suite tag; lists uncovered routes.',
669
+ shape: { project: z.string() },
670
+ access: 'read',
671
+ domain: 'projects',
672
+ capability: 'analyze',
673
+ handler: async (args, ctx) => {
674
+ const data = analyzeCoverage(projectRoot(ctx.rootDir, args.project));
675
+ return { text: summarize(`Coverage of ${args.project}`, data), data };
676
+ },
677
+ }),
678
+ defineTool({
679
+ name: 'analyze_best_practices',
680
+ title: 'Best-practice checklist',
681
+ description: 'Tag policy, outline titles, hardcoded URLs/secrets, sleeps, brittle locators in page objects, missing @a11y/@visual/@perf, data-driven candidates.',
682
+ shape: { project: z.string() },
683
+ access: 'read',
684
+ domain: 'projects',
685
+ capability: 'analyze',
686
+ handler: async (args, ctx) => {
687
+ const data = analyzeBestPractices(projectRoot(ctx.rootDir, args.project));
688
+ return {
689
+ text: summarize(`Best practices for ${args.project}: ${data.stats.errors} errors, ${data.stats.warnings} warnings`, data),
690
+ data,
691
+ };
692
+ },
693
+ }),
694
+ defineTool({
695
+ name: 'analyze_locators',
696
+ title: 'Locator audit',
697
+ description: 'Audit locators in pages/, steps/ and recorded/: kinds (role/label/testid/css/xpath), heal wrapping, fragility score.',
698
+ shape: { project: z.string() },
699
+ access: 'read',
700
+ domain: 'projects',
701
+ capability: 'analyze',
702
+ handler: async (args, ctx) => {
703
+ const data = analyzeLocators(projectRoot(ctx.rootDir, args.project));
704
+ return {
705
+ text: summarize(`Locators of ${args.project}: score ${data.score}/100`, data),
706
+ data,
707
+ };
708
+ },
709
+ }),
710
+ defineTool({
711
+ name: 'analyze_change_impact',
712
+ title: 'Change impact',
713
+ description: 'Map a git diff range (e.g. main..HEAD) to impacted features of a project and suggest what to re-run.',
714
+ shape: { project: z.string(), range: z.string().default('HEAD~1..HEAD') },
715
+ access: 'read',
716
+ domain: 'projects',
717
+ capability: 'analyze',
718
+ handler: async (args, ctx) => {
719
+ const data = analyzeChangeImpact(projectRoot(ctx.rootDir, args.project), ctx.rootDir, args.range);
720
+ return { text: summarize(`Impact of ${args.range} on ${args.project}`, data), data };
721
+ },
722
+ }),
723
+ defineTool({
724
+ name: 'analyze_failure',
725
+ title: 'Explain a failure',
726
+ description: 'Root-cause hints for a failed scenario: error classification (locator/timing/data/env/product), related heal events, screenshots and suggested next action.',
727
+ shape: { runId: z.string(), fingerprint: z.string() },
728
+ access: 'read',
729
+ domain: 'runs',
730
+ capability: 'analyze',
731
+ handler: async (args, ctx) => {
732
+ const { getRun, lastRun, listScenarioDirs, screenshotUri } = await import('../fs.js');
733
+ const run = args.runId === 'last' ? lastRun(ctx.rootDir) : getRun(ctx.rootDir, args.runId);
734
+ if (!run)
735
+ throw Object.assign(new Error(`Unknown run ${args.runId}`), {
736
+ error: { code: 'NOT_FOUND' },
737
+ });
738
+ const s = listScenarioDirs(run.dir, run.manifest?.projectSlug)
739
+ .filter((x) => x.fingerprint === args.fingerprint)
740
+ .sort((a, b) => b.retry - a.retry)[0];
741
+ if (!s)
742
+ throw Object.assign(new Error(`Scenario ${args.fingerprint} not in run ${run.runId}`), {
743
+ error: { code: 'NOT_FOUND' },
744
+ });
745
+ const err = s.meta?.errorMessage ?? '';
746
+ const kind = /locator|strict mode|waiting for|toBeVisible|getBy/i.test(err)
747
+ ? 'locator'
748
+ : /Timeout|timed out|exceeded/i.test(err)
749
+ ? 'timing'
750
+ : /ECONNREFUSED|ENOTFOUND|5\d\d|network|fetch failed/i.test(err)
751
+ ? 'environment'
752
+ : /expect\(|toEqual|toBe\(|toContain/i.test(err)
753
+ ? 'assertion'
754
+ : /dataset|row|user pool|lease/i.test(err)
755
+ ? 'data'
756
+ : 'unknown';
757
+ const selector = /locator\(['"`]([^'"`]+)['"`]\)/.exec(err)?.[1] ??
758
+ /getBy[A-Za-z]+\(([^)]*)\)/.exec(err)?.[0];
759
+ const nextAction = kind === 'locator'
760
+ ? 'Check heal events; if a candidate succeeded, propose the healed locator in the page object (agent heal).'
761
+ : kind === 'timing'
762
+ ? 'Inspect before/after screenshots around the failing step; consider a polling step or a longer action timeout for this scenario (@timeout:).'
763
+ : kind === 'environment'
764
+ ? 'Verify env base URLs (project_list_envs) and connectivity; re-run with --har-replay if a HAR exists.'
765
+ : kind === 'assertion'
766
+ ? 'Compare expected vs actual in the API snapshot or screenshot; this may be a product bug worth issue_create.'
767
+ : 'Read the run log and the trace.';
768
+ const data = {
769
+ runId: run.runId,
770
+ fingerprint: s.fingerprint,
771
+ scenario: s.meta?.scenarioName,
772
+ status: s.meta?.status,
773
+ error: err,
774
+ classification: kind,
775
+ selector,
776
+ healEvents: s.healEvents,
777
+ screenshots: s.screenshots.map((sh) => ({
778
+ ...sh,
779
+ uri: screenshotUri(run.runId, s.fingerprint, s.retry, sh.file),
780
+ })),
781
+ lastApiCalls: s.apiSnapshots.slice(-4),
782
+ nextAction,
783
+ };
784
+ return { text: summarize(`Failure analysis: ${kind}`, data), data };
785
+ },
786
+ }),
787
+ defineTool({
788
+ name: 'analyze_suggest_scenarios',
789
+ title: 'Suggest scenarios',
790
+ description: 'Template-level Gherkin drafts for uncovered routes and endpoints of a project (no LLM required).',
791
+ shape: { project: z.string(), limit: z.number().int().positive().max(50).optional() },
792
+ access: 'read',
793
+ domain: 'projects',
794
+ capability: 'analyze',
795
+ handler: async (args, ctx) => {
796
+ const root = projectRoot(ctx.rootDir, args.project);
797
+ const cov = analyzeCoverage(root);
798
+ const yaml = loadProjectYaml(root);
799
+ const drafts = cov.uncoveredRoutes.slice(0, args.limit ?? 10).map((route) => ({
800
+ route,
801
+ gherkin: `@ui @smoke\nFeature: ${route} page\n Scenario: The ${route} page loads\n Given I navigate to the "${route}" page\n Then the page URL should contain "${yaml.routes?.[route] ?? '/'}"\n`,
802
+ }));
803
+ const endpointDrafts = cov.endpoints
804
+ .filter((e) => e.scenarios === 0)
805
+ .slice(0, args.limit ?? 10)
806
+ .map((e) => ({
807
+ endpoint: e.endpoint,
808
+ gherkin: `@api @smoke\nFeature: ${e.endpoint}\n Scenario: ${e.endpoint} responds\n When I send a ${e.endpoint.split(' ')[0] ?? 'GET'} request to "${e.endpoint.split(' ')[1] ?? e.endpoint}"\n Then the response status should be 200\n`,
809
+ }));
810
+ return {
811
+ text: summarize(`Suggested scenarios for ${args.project}`, {
812
+ routes: drafts,
813
+ endpoints: endpointDrafts,
814
+ }),
815
+ data: { routes: drafts, endpoints: endpointDrafts },
816
+ };
817
+ },
818
+ }),
819
+ ];
820
+ //# sourceMappingURL=analyze.js.map