@qulib/core 0.2.1 → 0.3.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 (87) hide show
  1. package/README.md +45 -3
  2. package/dist/analyze.d.ts +16 -4
  3. package/dist/analyze.d.ts.map +1 -1
  4. package/dist/analyze.js +98 -38
  5. package/dist/cli/cost-doctor.d.ts +2 -0
  6. package/dist/cli/cost-doctor.d.ts.map +1 -0
  7. package/dist/cli/cost-doctor.js +72 -0
  8. package/dist/cli/index.js +61 -0
  9. package/dist/harness/progress-log.d.ts +7 -0
  10. package/dist/harness/progress-log.d.ts.map +1 -0
  11. package/dist/harness/progress-log.js +1 -0
  12. package/dist/harness/run-options.d.ts +2 -0
  13. package/dist/harness/run-options.d.ts.map +1 -1
  14. package/dist/index.d.ts +6 -2
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +3 -0
  17. package/dist/llm/content-hash.d.ts +2 -0
  18. package/dist/llm/content-hash.d.ts.map +1 -0
  19. package/dist/llm/content-hash.js +4 -0
  20. package/dist/llm/context-builder.js +1 -1
  21. package/dist/llm/cost-intelligence.d.ts +29 -0
  22. package/dist/llm/cost-intelligence.d.ts.map +1 -0
  23. package/dist/llm/cost-intelligence.js +153 -0
  24. package/dist/llm/provider.d.ts +11 -1
  25. package/dist/llm/provider.d.ts.map +1 -1
  26. package/dist/llm/provider.js +43 -4
  27. package/dist/phases/act.d.ts.map +1 -1
  28. package/dist/phases/act.js +4 -1
  29. package/dist/phases/observe.js +1 -1
  30. package/dist/phases/think-finalize.d.ts +6 -0
  31. package/dist/phases/think-finalize.d.ts.map +1 -0
  32. package/dist/phases/think-finalize.js +164 -0
  33. package/dist/phases/think.d.ts +2 -0
  34. package/dist/phases/think.d.ts.map +1 -1
  35. package/dist/phases/think.js +16 -65
  36. package/dist/reporters/markdown-reporter.d.ts.map +1 -1
  37. package/dist/reporters/markdown-reporter.js +23 -3
  38. package/dist/schemas/config.schema.d.ts +364 -0
  39. package/dist/schemas/config.schema.d.ts.map +1 -1
  40. package/dist/schemas/config.schema.js +55 -1
  41. package/dist/schemas/cost-intelligence.schema.d.ts +229 -0
  42. package/dist/schemas/cost-intelligence.schema.d.ts.map +1 -0
  43. package/dist/schemas/cost-intelligence.schema.js +41 -0
  44. package/dist/schemas/decision-log.schema.d.ts +2 -2
  45. package/dist/schemas/gap-analysis.schema.d.ts +288 -49
  46. package/dist/schemas/gap-analysis.schema.d.ts.map +1 -1
  47. package/dist/schemas/gap-analysis.schema.js +7 -3
  48. package/dist/schemas/index.d.ts +3 -1
  49. package/dist/schemas/index.d.ts.map +1 -1
  50. package/dist/schemas/index.js +3 -1
  51. package/dist/schemas/public-surface.schema.d.ts +268 -0
  52. package/dist/schemas/public-surface.schema.d.ts.map +1 -0
  53. package/dist/schemas/public-surface.schema.js +15 -0
  54. package/dist/schemas/repo-analysis.schema.d.ts +6 -6
  55. package/dist/tools/auth-block-gap.d.ts +3 -0
  56. package/dist/tools/auth-block-gap.d.ts.map +1 -0
  57. package/dist/tools/auth-block-gap.js +19 -0
  58. package/dist/tools/auth-detector.d.ts +2 -1
  59. package/dist/tools/auth-detector.d.ts.map +1 -1
  60. package/dist/tools/auth-detector.js +28 -3
  61. package/dist/tools/auth-explorer.d.ts +4 -0
  62. package/dist/tools/auth-explorer.d.ts.map +1 -0
  63. package/dist/tools/auth-explorer.js +346 -0
  64. package/dist/tools/auth-surface-analyzer.d.ts +4 -0
  65. package/dist/tools/auth-surface-analyzer.d.ts.map +1 -0
  66. package/dist/tools/auth-surface-analyzer.js +154 -0
  67. package/dist/tools/cypress-explorer.d.ts +2 -1
  68. package/dist/tools/cypress-explorer.d.ts.map +1 -1
  69. package/dist/tools/cypress-explorer.js +1 -1
  70. package/dist/tools/explorer.interface.d.ts +2 -1
  71. package/dist/tools/explorer.interface.d.ts.map +1 -1
  72. package/dist/tools/gap-engine.d.ts +3 -1
  73. package/dist/tools/gap-engine.d.ts.map +1 -1
  74. package/dist/tools/gap-engine.js +39 -12
  75. package/dist/tools/oauth-providers.d.ts +7 -0
  76. package/dist/tools/oauth-providers.d.ts.map +1 -0
  77. package/dist/tools/oauth-providers.js +21 -0
  78. package/dist/tools/playwright-explorer.d.ts +2 -1
  79. package/dist/tools/playwright-explorer.d.ts.map +1 -1
  80. package/dist/tools/playwright-explorer.js +21 -3
  81. package/dist/tools/public-surface.d.ts +5 -0
  82. package/dist/tools/public-surface.d.ts.map +1 -0
  83. package/dist/tools/public-surface.js +13 -0
  84. package/dist/tools/user-providers.d.ts +15 -0
  85. package/dist/tools/user-providers.d.ts.map +1 -0
  86. package/dist/tools/user-providers.js +62 -0
  87. package/package.json +6 -2
@@ -0,0 +1,346 @@
1
+ import { AuthExplorationSchema, } from '../schemas/config.schema.js';
2
+ import { launchBrowser } from './browser.js';
3
+ import { BUILT_IN_OAUTH_PROVIDERS } from './oauth-providers.js';
4
+ import { loadUserProviders } from './user-providers.js';
5
+ const MAGIC_LINK_PATTERNS = [
6
+ /email me a (sign[- ]?in )?link/i,
7
+ /sign in with email/i,
8
+ /passwordless/i,
9
+ /we'll send you a link/i,
10
+ ];
11
+ async function waitNetworkIdleBestEffort(page) {
12
+ try {
13
+ await page.waitForLoadState('networkidle', { timeout: 5000 });
14
+ }
15
+ catch {
16
+ // best-effort
17
+ }
18
+ }
19
+ function textLooksLikeOAuthIdpButton(text) {
20
+ const t = text.trim();
21
+ if (t.length === 0 || t.length > 120) {
22
+ return false;
23
+ }
24
+ return (/\b(sign in with|log in with|continue with|sign up with)\b/i.test(t) ||
25
+ /^(github|google|microsoft|apple)$/i.test(t));
26
+ }
27
+ function slugifyLabel(text) {
28
+ const s = text
29
+ .trim()
30
+ .toLowerCase()
31
+ .replace(/\s+/g, '-')
32
+ .replace(/[^a-z0-9-]/g, '')
33
+ .slice(0, 48);
34
+ return s.length > 0 ? s : 'unknown';
35
+ }
36
+ function onLoginishPage(url) {
37
+ return /login|sign[- ]?in|auth|sso|oauth/i.test(new URL(url).pathname + new URL(url).hostname);
38
+ }
39
+ function debugExplore() {
40
+ return process.env.QULIB_DEBUG === '1';
41
+ }
42
+ function isHeuristicUnknownSso(text, loginish) {
43
+ const t = text.trim();
44
+ if (!loginish || t.length < 3 || t.length > 80) {
45
+ return false;
46
+ }
47
+ if (/^(submit|cancel|back|close|next|skip|help|faq)$/i.test(t)) {
48
+ return false;
49
+ }
50
+ if (/\b(sign in with|log in with|continue with)\b/i.test(t)) {
51
+ return true;
52
+ }
53
+ if (/\b(sync|sso|portal|workspace|federation)\b/i.test(t)) {
54
+ return true;
55
+ }
56
+ return false;
57
+ }
58
+ function storageRequirement() {
59
+ return {
60
+ method: 'storage-state',
61
+ instruction: 'OAuth and most SSO flows cannot be scripted. Run `qulib auth init --base-url <app-url>` on this machine, then pass the saved storage state JSON to `analyze` or MCP `analyze_app` as `auth: { type: "storage-state", path: "..." }`.',
62
+ };
63
+ }
64
+ async function collectVisibleControlTexts(page) {
65
+ return page.evaluate(() => {
66
+ const seen = new Set();
67
+ const out = [];
68
+ const nodes = document.querySelectorAll('button, a[href], [role="button"]');
69
+ for (const el of nodes) {
70
+ const t = (el.textContent ?? '').trim().replace(/\s+/g, ' ');
71
+ if (!t || t.length > 120) {
72
+ continue;
73
+ }
74
+ const style = window.getComputedStyle(el);
75
+ if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
76
+ continue;
77
+ }
78
+ if (!seen.has(t)) {
79
+ seen.add(t);
80
+ out.push(t);
81
+ }
82
+ }
83
+ return out;
84
+ });
85
+ }
86
+ function buildAllProviders() {
87
+ const builtIn = BUILT_IN_OAUTH_PROVIDERS.map((p) => ({ ...p, source: 'built-in' }));
88
+ const user = loadUserProviders().map((p) => ({ ...p, source: 'user-local' }));
89
+ return [...builtIn, ...user];
90
+ }
91
+ function matchProvider(text, p) {
92
+ return p.patterns.some((re) => re.test(text));
93
+ }
94
+ function oauthConfidence(source, loginish) {
95
+ if (source === 'user-local') {
96
+ return 'high';
97
+ }
98
+ if (source === 'built-in' && loginish) {
99
+ return 'high';
100
+ }
101
+ if (source === 'built-in') {
102
+ return 'medium';
103
+ }
104
+ return 'low';
105
+ }
106
+ async function buildFormPaths(page) {
107
+ const passwordCount = await page.locator('input[type="password"]').count();
108
+ if (passwordCount === 0) {
109
+ return [];
110
+ }
111
+ const formType = passwordCount > 1 ? 'form-multi' : 'form-login';
112
+ const fields = await page.evaluate(() => {
113
+ const pwd = document.querySelector('input[type="password"]');
114
+ if (!pwd) {
115
+ return [];
116
+ }
117
+ const form = pwd.closest('form') ?? document.body;
118
+ const out = [];
119
+ const inputs = form.querySelectorAll('input, select, textarea');
120
+ for (const el of inputs) {
121
+ if (!(el instanceof HTMLElement)) {
122
+ continue;
123
+ }
124
+ const tag = el.tagName.toLowerCase();
125
+ if (tag === 'input') {
126
+ const inp = el;
127
+ const t = (inp.type || 'text').toLowerCase();
128
+ if (['hidden', 'submit', 'button', 'image', 'reset'].includes(t)) {
129
+ continue;
130
+ }
131
+ let fieldType = 'text';
132
+ if (t === 'password') {
133
+ fieldType = 'password';
134
+ }
135
+ else if (t === 'email') {
136
+ fieldType = 'email';
137
+ }
138
+ else if (t === 'checkbox') {
139
+ fieldType = 'checkbox';
140
+ }
141
+ const id = inp.id;
142
+ let label = inp.getAttribute('aria-label') ?? inp.placeholder ?? inp.name ?? fieldType;
143
+ if (id) {
144
+ const lab = document.querySelector(`label[for="${CSS.escape(id)}"]`);
145
+ if (lab?.textContent) {
146
+ label = lab.textContent.trim();
147
+ }
148
+ }
149
+ out.push({
150
+ name: inp.name || inp.id || fieldType,
151
+ label: label.slice(0, 120),
152
+ type: fieldType,
153
+ observedOptions: [],
154
+ });
155
+ }
156
+ else if (tag === 'select') {
157
+ const sel = el;
158
+ const opts = Array.from(sel.options).map((o) => o.text.trim()).filter(Boolean);
159
+ out.push({
160
+ name: sel.name || sel.id || 'select',
161
+ label: (sel.getAttribute('aria-label') ?? sel.name ?? 'select').slice(0, 120),
162
+ type: 'select',
163
+ observedOptions: opts.slice(0, 50),
164
+ });
165
+ }
166
+ }
167
+ return out;
168
+ });
169
+ const requirements = fields.length > 0
170
+ ? { method: 'credentials', fields }
171
+ : {
172
+ method: 'unknown',
173
+ instruction: 'A password field exists but field metadata could not be read. Inspect the page in devtools and configure form-login selectors manually, or use `qulib auth init`.',
174
+ };
175
+ return [
176
+ {
177
+ id: formType === 'form-multi' ? 'form-multi' : 'form-login',
178
+ label: formType === 'form-multi' ? 'Multi-field sign-in form' : 'Username / password form',
179
+ type: formType,
180
+ provider: null,
181
+ source: 'heuristic',
182
+ automatable: requirements.method === 'credentials',
183
+ confidence: requirements.method === 'credentials' ? 'medium' : 'low',
184
+ requirements,
185
+ },
186
+ ];
187
+ }
188
+ export async function exploreAuth(url, timeoutMs = 20000, progress) {
189
+ const browser = await launchBrowser();
190
+ try {
191
+ const context = await browser.newContext();
192
+ const page = await context.newPage();
193
+ progress?.info(`explore_auth URL=${url}`);
194
+ await page.goto(url, { timeout: timeoutMs, waitUntil: 'domcontentloaded' });
195
+ await waitNetworkIdleBestEffort(page);
196
+ if (debugExplore()) {
197
+ const html = await page.content();
198
+ progress?.debug(`explore_auth HTML byteLength=${Buffer.byteLength(html, 'utf8')}`);
199
+ }
200
+ const loginishAfterFirst = /login|sign[- ]?in|auth/i.test(page.url()) || (await page.locator('input[type="password"]').count()) > 0;
201
+ if (!loginishAfterFirst) {
202
+ const loginLink = page.locator('a').filter({ hasText: /^(log ?in|sign ?in|sign in)$/i }).first();
203
+ const cnt = await loginLink.count();
204
+ progress?.debug(`explore_auth selector loginLink count=${cnt}`);
205
+ if (cnt > 0) {
206
+ const href = await loginLink.getAttribute('href');
207
+ progress?.debug(`explore_auth selector loginLink href matched=${Boolean(href)}`);
208
+ if (href) {
209
+ const next = new URL(href, url).toString();
210
+ await page.goto(next, { timeout: timeoutMs, waitUntil: 'domcontentloaded' });
211
+ await waitNetworkIdleBestEffort(page);
212
+ }
213
+ }
214
+ }
215
+ const finalUrl = page.url();
216
+ const loginish = onLoginishPage(finalUrl) || (await page.locator('input[type="password"]').count()) > 0;
217
+ const allProviders = buildAllProviders();
218
+ const texts = await collectVisibleControlTexts(page);
219
+ const consumed = new Set();
220
+ const authPaths = [];
221
+ const unrecognizedButtons = [];
222
+ for (const rawText of texts) {
223
+ const text = rawText.trim();
224
+ if (!text) {
225
+ continue;
226
+ }
227
+ let providerMatch = null;
228
+ for (const p of allProviders) {
229
+ const hit = matchProvider(text, p);
230
+ if (debugExplore()) {
231
+ progress?.debug(`explore_auth provider try id=${p.id} matched=${hit}`);
232
+ }
233
+ if (!hit) {
234
+ continue;
235
+ }
236
+ if (p.source === 'built-in' && !(textLooksLikeOAuthIdpButton(text) || loginish)) {
237
+ continue;
238
+ }
239
+ providerMatch = { p, gate: textLooksLikeOAuthIdpButton(text) || loginish };
240
+ break;
241
+ }
242
+ if (providerMatch) {
243
+ const { p, gate } = providerMatch;
244
+ const id = `oauth:${p.id}`;
245
+ if (consumed.has(id)) {
246
+ continue;
247
+ }
248
+ consumed.add(id);
249
+ authPaths.push({
250
+ id,
251
+ label: p.label,
252
+ type: 'oauth',
253
+ provider: p.id,
254
+ source: p.source,
255
+ automatable: false,
256
+ confidence: oauthConfidence(p.source, loginish || gate),
257
+ requirements: storageRequirement(),
258
+ });
259
+ progress?.info(`explore_auth path id=${id} type=oauth provider=${p.id} automatable=false`);
260
+ continue;
261
+ }
262
+ if (isHeuristicUnknownSso(text, loginish)) {
263
+ const slug = slugifyLabel(text);
264
+ const id = `oauth-unknown:${slug}`;
265
+ if (consumed.has(id)) {
266
+ continue;
267
+ }
268
+ consumed.add(id);
269
+ authPaths.push({
270
+ id,
271
+ label: text.slice(0, 100),
272
+ type: 'oauth-unknown',
273
+ provider: null,
274
+ source: 'heuristic',
275
+ automatable: false,
276
+ confidence: 'low',
277
+ requirements: storageRequirement(),
278
+ });
279
+ progress?.info(`explore_auth path id=${id} type=oauth-unknown automatable=false`);
280
+ const safePattern = text.slice(0, 48).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
281
+ unrecognizedButtons.push({
282
+ label: text.slice(0, 100),
283
+ hint: `If this is your org SSO, register it: qulib auth providers add --id "${slug}" --label "${text.replace(/"/g, '\\"').slice(0, 80)}" --pattern "${safePattern}"`,
284
+ });
285
+ }
286
+ }
287
+ const pageText = await page.locator('body').innerText().catch(() => '');
288
+ if (MAGIC_LINK_PATTERNS.some((re) => re.test(pageText))) {
289
+ authPaths.push({
290
+ id: 'magic-link',
291
+ label: 'Magic link / passwordless',
292
+ type: 'magic-link',
293
+ provider: null,
294
+ source: 'heuristic',
295
+ automatable: false,
296
+ confidence: 'medium',
297
+ requirements: {
298
+ method: 'storage-state',
299
+ instruction: 'Magic-link flows need a human in the loop. Use `qulib auth init --base-url <app-url>` and complete email or provider steps in the opened browser, then reuse the saved storage state for scans.',
300
+ },
301
+ });
302
+ progress?.info('explore_auth path id=magic-link type=magic-link automatable=false');
303
+ }
304
+ const formPaths = await buildFormPaths(page);
305
+ for (const fp of formPaths) {
306
+ authPaths.push(fp);
307
+ progress?.info(`explore_auth path id=${fp.id} type=${fp.type} automatable=${fp.automatable}`);
308
+ }
309
+ const authRequired = authPaths.length > 0;
310
+ let authScope = 'none';
311
+ if (authRequired) {
312
+ if (loginish) {
313
+ authScope = 'site-wide';
314
+ }
315
+ else {
316
+ authScope = /login|signin|auth/i.test(new URL(finalUrl).pathname) ? 'site-wide' : 'optional';
317
+ }
318
+ }
319
+ const suggestedParts = [];
320
+ if (authPaths.some((p) => p.type === 'oauth' || p.type === 'oauth-unknown')) {
321
+ suggestedParts.push('For OAuth or unrecognized SSO buttons, collect a Playwright storage state with `qulib auth init` before calling `analyze_app`.');
322
+ }
323
+ if (authPaths.some((p) => p.type === 'form-login' || p.type === 'form-multi')) {
324
+ suggestedParts.push('For password forms, gather username/password and stable selectors (or use storage state if MFA applies).');
325
+ }
326
+ if (authPaths.some((p) => p.type === 'magic-link')) {
327
+ suggestedParts.push('For magic-link, use `qulib auth init` after the user completes email delivery.');
328
+ }
329
+ if (!authRequired) {
330
+ suggestedParts.push('No sign-in surface detected at this URL; you can run `analyze_app` without auth unless gated deeper in the app.');
331
+ }
332
+ const exploration = {
333
+ url: finalUrl,
334
+ authRequired,
335
+ authScope,
336
+ authPaths,
337
+ observedAt: new Date().toISOString(),
338
+ suggestedAgentBehavior: suggestedParts.join(' '),
339
+ unrecognizedButtons,
340
+ };
341
+ return AuthExplorationSchema.parse(exploration);
342
+ }
343
+ finally {
344
+ await browser.close();
345
+ }
346
+ }
@@ -0,0 +1,4 @@
1
+ import type { DetectedAuth } from '../schemas/config.schema.js';
2
+ import type { Gap } from '../schemas/gap-analysis.schema.js';
3
+ export declare function analyzeAuthSurfaceGaps(url: string, detection: DetectedAuth, timeoutMs: number): Promise<Gap[]>;
4
+ //# sourceMappingURL=auth-surface-analyzer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth-surface-analyzer.d.ts","sourceRoot":"","sources":["../../src/tools/auth-surface-analyzer.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAChE,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,mCAAmC,CAAC;AAW7D,wBAAsB,sBAAsB,CAC1C,GAAG,EAAE,MAAM,EACX,SAAS,EAAE,YAAY,EACvB,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,GAAG,EAAE,CAAC,CAuJhB"}
@@ -0,0 +1,154 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { launchBrowser } from './browser.js';
3
+ async function waitNetworkIdleBestEffort(page) {
4
+ try {
5
+ await page.waitForLoadState('networkidle', { timeout: 5000 });
6
+ }
7
+ catch {
8
+ /* best-effort */
9
+ }
10
+ }
11
+ export async function analyzeAuthSurfaceGaps(url, detection, timeoutMs) {
12
+ if (!detection.hasAuth) {
13
+ return [];
14
+ }
15
+ const gaps = [];
16
+ const browser = await launchBrowser();
17
+ try {
18
+ const context = await browser.newContext();
19
+ const page = await context.newPage();
20
+ const resp = await page.goto(url, { timeout: timeoutMs, waitUntil: 'domcontentloaded' }).catch(() => null);
21
+ if (!resp || !resp.ok()) {
22
+ gaps.push({
23
+ id: randomUUID(),
24
+ path: new URL(url).pathname || '/',
25
+ severity: 'critical',
26
+ category: 'auth-surface',
27
+ reason: 'Sign-in surface did not load successfully for evaluation.',
28
+ description: 'The auth entry URL failed to load or returned a non-OK status before DOM checks could run.',
29
+ recommendation: 'Verify DNS, TLS, and that the URL is reachable from the scan environment.',
30
+ });
31
+ return gaps;
32
+ }
33
+ await waitNetworkIdleBestEffort(page);
34
+ const title = await page.title().catch(() => '');
35
+ if (!title || title.trim().length < 3) {
36
+ gaps.push({
37
+ id: randomUUID(),
38
+ path: '/',
39
+ severity: 'medium',
40
+ category: 'auth-surface',
41
+ reason: 'Missing or trivial document title on the sign-in surface.',
42
+ description: 'Users and assistive tech rely on a meaningful window title.',
43
+ recommendation: 'Set a concise, unique <title> for the login experience.',
44
+ });
45
+ }
46
+ const metaDesc = await page.locator('meta[name="description"]').getAttribute('content').catch(() => null);
47
+ if (!metaDesc || metaDesc.trim().length < 8) {
48
+ gaps.push({
49
+ id: randomUUID(),
50
+ path: '/',
51
+ severity: 'low',
52
+ category: 'auth-surface',
53
+ reason: 'No meta description on the sign-in surface.',
54
+ description: 'Search and sharing previews benefit from meta description on public entry pages.',
55
+ recommendation: 'Add <meta name="description" content="..."> with a short summary of the product.',
56
+ });
57
+ }
58
+ const h1Count = await page.locator('h1').count();
59
+ if (h1Count === 0) {
60
+ gaps.push({
61
+ id: randomUUID(),
62
+ path: '/',
63
+ severity: 'medium',
64
+ category: 'auth-surface',
65
+ reason: 'No visible primary heading (h1) on the sign-in surface.',
66
+ description: 'A primary heading helps users orient on the login page.',
67
+ recommendation: 'Add a single descriptive <h1> for the sign-in view.',
68
+ });
69
+ }
70
+ const oauthButtons = page.locator('button, a[href], [role="button"]');
71
+ const n = await oauthButtons.count();
72
+ for (let i = 0; i < Math.min(n, 25); i++) {
73
+ const el = oauthButtons.nth(i);
74
+ const text = ((await el.textContent()) ?? '').trim();
75
+ if (!text || text.length > 120)
76
+ continue;
77
+ const isOAuthish = /google|microsoft|github|apple|sso|sign in with|log in with|continue with|oauth/i.test(text);
78
+ if (!isOAuthish)
79
+ continue;
80
+ const role = await el.getAttribute('role');
81
+ const tag = await el.evaluate((node) => node.tagName.toLowerCase());
82
+ const tabIndex = await el.getAttribute('tabindex');
83
+ const aria = await el.getAttribute('aria-label');
84
+ const keyboardable = tag === 'button' || tag === 'a' || role === 'button';
85
+ const labeled = Boolean(aria && aria.trim().length > 0) || text.length > 0;
86
+ if (!keyboardable || tabIndex === '-1') {
87
+ gaps.push({
88
+ id: randomUUID(),
89
+ path: '/',
90
+ severity: 'high',
91
+ category: 'auth-surface',
92
+ reason: `OAuth control "${text.slice(0, 60)}" may not be keyboard-accessible.`,
93
+ description: 'SSO entry points should be real buttons or links with focus support.',
94
+ recommendation: 'Use <button> or <a href> with visible label; avoid tabindex=-1 on the only sign-in path.',
95
+ });
96
+ }
97
+ else if (!labeled) {
98
+ gaps.push({
99
+ id: randomUUID(),
100
+ path: '/',
101
+ severity: 'medium',
102
+ category: 'auth-surface',
103
+ reason: `OAuth control "${text.slice(0, 60)}" lacks aria-label and has weak visible text.`,
104
+ description: 'Assistive technologies need a clear accessible name for IdP buttons.',
105
+ recommendation: 'Add aria-label or visible text that names the provider and action.',
106
+ });
107
+ }
108
+ }
109
+ const hasPassword = (await page.locator('input[type="password"]').count()) > 0;
110
+ const hasEmailLink = await page.getByText(/magic link|email.*link|passwordless/i).count();
111
+ const hasOAuthUi = detection.oauthButtons.length > 0 ||
112
+ (await page.getByText(/sign in with|continue with google|microsoft|github/i).count()) > 0;
113
+ if (hasOAuthUi && !hasPassword && !hasEmailLink) {
114
+ gaps.push({
115
+ id: randomUUID(),
116
+ path: '/',
117
+ severity: 'medium',
118
+ category: 'auth-surface',
119
+ reason: 'OAuth-only entry with no visible password or magic-link fallback.',
120
+ description: 'Users who cannot use a social IdP need another path (email/password, help, or support).',
121
+ recommendation: 'Add a documented fallback (email/password, help desk link, or alternate IdP).',
122
+ });
123
+ }
124
+ const errorSelectors = '[role="alert"], [data-testid*="error" i], .error, .alert-danger, [class*="error" i]';
125
+ const errCount = await page.locator(errorSelectors).count();
126
+ if (errCount === 0 && hasOAuthUi) {
127
+ gaps.push({
128
+ id: randomUUID(),
129
+ path: '/',
130
+ severity: 'low',
131
+ category: 'auth-surface',
132
+ reason: 'No obvious in-DOM error container found for OAuth sign-in failures.',
133
+ description: 'IdP failures should surface recoverable feedback in the page.',
134
+ recommendation: 'Reserve a live region or inline alert for OAuth errors returned from the provider.',
135
+ });
136
+ }
137
+ const help = await page.getByText(/forgot password|need help|contact support|get help/i).count();
138
+ if (help === 0 && hasOAuthUi) {
139
+ gaps.push({
140
+ id: randomUUID(),
141
+ path: '/',
142
+ severity: 'low',
143
+ category: 'auth-surface',
144
+ reason: 'No visible “forgot password” or help path detected near OAuth controls.',
145
+ description: 'Users locked out of an IdP need a support or recovery affordance.',
146
+ recommendation: 'Link to account recovery, IT help, or a support URL near the sign-in actions.',
147
+ });
148
+ }
149
+ }
150
+ finally {
151
+ await browser.close();
152
+ }
153
+ return gaps;
154
+ }
@@ -1,7 +1,8 @@
1
1
  import type { AppExplorer } from './explorer.interface.js';
2
2
  import type { HarnessConfig } from '../schemas/config.schema.js';
3
3
  import type { RouteInventory } from '../schemas/route-inventory.schema.js';
4
+ import type { RunArtifactsOptions } from '../harness/run-options.js';
4
5
  export declare class CypressExplorer implements AppExplorer {
5
- explore(baseUrl: string, config: HarnessConfig): Promise<RouteInventory>;
6
+ explore(_baseUrl: string, _config: HarnessConfig, _artifacts?: RunArtifactsOptions): Promise<RouteInventory>;
6
7
  }
7
8
  //# sourceMappingURL=cypress-explorer.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"cypress-explorer.d.ts","sourceRoot":"","sources":["../../src/tools/cypress-explorer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAC3D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,6BAA6B,CAAC;AACjE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sCAAsC,CAAC;AAE3E,qBAAa,eAAgB,YAAW,WAAW;IAC3C,OAAO,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,aAAa,GAAG,OAAO,CAAC,cAAc,CAAC;CAG/E"}
1
+ {"version":3,"file":"cypress-explorer.d.ts","sourceRoot":"","sources":["../../src/tools/cypress-explorer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAC3D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,6BAA6B,CAAC;AACjE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sCAAsC,CAAC;AAC3E,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,2BAA2B,CAAC;AAErE,qBAAa,eAAgB,YAAW,WAAW;IAC3C,OAAO,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,aAAa,EAAE,UAAU,CAAC,EAAE,mBAAmB,GAAG,OAAO,CAAC,cAAc,CAAC;CAGnH"}
@@ -1,5 +1,5 @@
1
1
  export class CypressExplorer {
2
- async explore(baseUrl, config) {
2
+ async explore(_baseUrl, _config, _artifacts) {
3
3
  throw new Error('Not implemented');
4
4
  }
5
5
  }
@@ -1,6 +1,7 @@
1
1
  import type { HarnessConfig } from '../schemas/config.schema.js';
2
2
  import type { RouteInventory } from '../schemas/route-inventory.schema.js';
3
+ import type { RunArtifactsOptions } from '../harness/run-options.js';
3
4
  export interface AppExplorer {
4
- explore(baseUrl: string, config: HarnessConfig): Promise<RouteInventory>;
5
+ explore(baseUrl: string, config: HarnessConfig, artifacts?: RunArtifactsOptions): Promise<RouteInventory>;
5
6
  }
6
7
  //# sourceMappingURL=explorer.interface.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"explorer.interface.d.ts","sourceRoot":"","sources":["../../src/tools/explorer.interface.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,6BAA6B,CAAC;AACjE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sCAAsC,CAAC;AAE3E,MAAM,WAAW,WAAW;IAC1B,OAAO,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,aAAa,GAAG,OAAO,CAAC,cAAc,CAAC,CAAC;CAC1E"}
1
+ {"version":3,"file":"explorer.interface.d.ts","sourceRoot":"","sources":["../../src/tools/explorer.interface.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,6BAA6B,CAAC;AACjE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sCAAsC,CAAC;AAC3E,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,2BAA2B,CAAC;AAErE,MAAM,WAAW,WAAW;IAC1B,OAAO,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,aAAa,EAAE,SAAS,CAAC,EAAE,mBAAmB,GAAG,OAAO,CAAC,cAAc,CAAC,CAAC;CAC3G"}
@@ -1,6 +1,8 @@
1
- import { type GapAnalysis } from '../schemas/gap-analysis.schema.js';
1
+ import { type GapAnalysis, type Gap } from '../schemas/gap-analysis.schema.js';
2
2
  import type { RouteInventory } from '../schemas/route-inventory.schema.js';
3
3
  import type { RepoAnalysis } from '../schemas/repo-analysis.schema.js';
4
4
  import type { HarnessConfig } from '../schemas/config.schema.js';
5
+ export declare function computeQualityScoreFromGaps(gaps: Gap[]): number;
6
+ export declare function computeCoverageScore(routes: RouteInventory): number | null;
5
7
  export declare function analyzeGaps(routes: RouteInventory, repo: RepoAnalysis | null, mode: 'url-only' | 'url-repo', config: HarnessConfig): Omit<GapAnalysis, 'scenarios' | 'generatedTests'>;
6
8
  //# sourceMappingURL=gap-engine.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"gap-engine.d.ts","sourceRoot":"","sources":["../../src/tools/gap-engine.ts"],"names":[],"mappings":"AACA,OAAO,EAAa,KAAK,WAAW,EAAY,MAAM,mCAAmC,CAAC;AAC1F,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sCAAsC,CAAC;AAC3E,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oCAAoC,CAAC;AACvE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,6BAA6B,CAAC;AAEjE,wBAAgB,WAAW,CACzB,MAAM,EAAE,cAAc,EACtB,IAAI,EAAE,YAAY,GAAG,IAAI,EACzB,IAAI,EAAE,UAAU,GAAG,UAAU,EAC7B,MAAM,EAAE,aAAa,GACpB,IAAI,CAAC,WAAW,EAAE,WAAW,GAAG,gBAAgB,CAAC,CA0GnD"}
1
+ {"version":3,"file":"gap-engine.d.ts","sourceRoot":"","sources":["../../src/tools/gap-engine.ts"],"names":[],"mappings":"AACA,OAAO,EAAa,KAAK,WAAW,EAAE,KAAK,GAAG,EAAE,MAAM,mCAAmC,CAAC;AAC1F,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sCAAsC,CAAC;AAC3E,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oCAAoC,CAAC;AACvE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,6BAA6B,CAAC;AAEjE,wBAAgB,2BAA2B,CAAC,IAAI,EAAE,GAAG,EAAE,GAAG,MAAM,CAY/D;AAED,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,cAAc,GAAG,MAAM,GAAG,IAAI,CAa1E;AAED,wBAAgB,WAAW,CACzB,MAAM,EAAE,cAAc,EACtB,IAAI,EAAE,YAAY,GAAG,IAAI,EACzB,IAAI,EAAE,UAAU,GAAG,UAAU,EAC7B,MAAM,EAAE,aAAa,GACpB,IAAI,CAAC,WAAW,EAAE,WAAW,GAAG,gBAAgB,CAAC,CAqGnD"}
@@ -1,5 +1,36 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { GapSchema } from '../schemas/gap-analysis.schema.js';
3
+ export function computeQualityScoreFromGaps(gaps) {
4
+ let critical = 0;
5
+ let high = 0;
6
+ let medium = 0;
7
+ let low = 0;
8
+ for (const g of gaps) {
9
+ if (g.severity === 'critical')
10
+ critical++;
11
+ else if (g.severity === 'high')
12
+ high++;
13
+ else if (g.severity === 'medium')
14
+ medium++;
15
+ else
16
+ low++;
17
+ }
18
+ return Math.max(0, 100 - critical * 25 - high * 20 - medium * 8 - low * 3);
19
+ }
20
+ export function computeCoverageScore(routes) {
21
+ const scanned = routes.routes.length;
22
+ const skipped = routes.pagesSkipped;
23
+ const denom = scanned + skipped;
24
+ // TODO: return null here once the explorer exposes an explicit "discovered-but-unknown" signal
25
+ // (i.e. routes were found but the full set couldn't be confirmed — a low score is misleading)
26
+ if (denom === 0) {
27
+ if (routes.budgetExceeded) {
28
+ return 0;
29
+ }
30
+ return scanned === 0 ? 0 : 100;
31
+ }
32
+ return Math.round((100 * scanned) / denom);
33
+ }
3
34
  export function analyzeGaps(routes, repo, mode, config) {
4
35
  const coveredPaths = new Set();
5
36
  if (repo) {
@@ -57,11 +88,13 @@ export function analyzeGaps(routes, repo, mode, config) {
57
88
  }
58
89
  for (const violation of route.a11yViolations) {
59
90
  const impact = violation.impact.toLowerCase();
60
- const severity = impact === 'critical' || impact === 'serious'
61
- ? 'high'
62
- : impact === 'moderate'
63
- ? 'medium'
64
- : 'low';
91
+ const severity = impact === 'critical'
92
+ ? 'critical'
93
+ : impact === 'serious'
94
+ ? 'high'
95
+ : impact === 'moderate'
96
+ ? 'medium'
97
+ : 'low';
65
98
  addGap({
66
99
  id: randomUUID(),
67
100
  path: route.path,
@@ -71,14 +104,8 @@ export function analyzeGaps(routes, repo, mode, config) {
71
104
  });
72
105
  }
73
106
  }
74
- const highCount = gaps.filter((g) => g.severity === 'high').length;
75
- const mediumCount = gaps.filter((g) => g.severity === 'medium').length;
76
- const lowCount = gaps.filter((g) => g.severity === 'low').length;
77
- let releaseConfidence = Math.max(0, 100 - highCount * 20 - mediumCount * 8 - lowCount * 3);
107
+ const releaseConfidence = computeQualityScoreFromGaps(gaps);
78
108
  const pagesScanned = routes.routes.length;
79
- if (pagesScanned < config.minPagesForConfidence) {
80
- releaseConfidence = Math.min(releaseConfidence, 40);
81
- }
82
109
  let coverageWarning;
83
110
  if (routes.budgetExceeded) {
84
111
  coverageWarning = 'budget-exceeded';
@@ -0,0 +1,7 @@
1
+ export interface OAuthProvider {
2
+ id: string;
3
+ label: string;
4
+ patterns: RegExp[];
5
+ }
6
+ export declare const BUILT_IN_OAUTH_PROVIDERS: OAuthProvider[];
7
+ //# sourceMappingURL=oauth-providers.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"oauth-providers.d.ts","sourceRoot":"","sources":["../../src/tools/oauth-providers.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAED,eAAO,MAAM,wBAAwB,EAAE,aAAa,EAsBnD,CAAC"}