@qulib/core 0.2.2 → 0.3.1
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 +31 -3
- package/dist/analyze.d.ts +16 -4
- package/dist/analyze.d.ts.map +1 -1
- package/dist/analyze.js +98 -38
- package/dist/cli/cost-doctor.d.ts +2 -0
- package/dist/cli/cost-doctor.d.ts.map +1 -0
- package/dist/cli/cost-doctor.js +72 -0
- package/dist/cli/index.js +14 -0
- package/dist/harness/progress-log.d.ts +7 -0
- package/dist/harness/progress-log.d.ts.map +1 -0
- package/dist/harness/progress-log.js +1 -0
- package/dist/harness/run-options.d.ts +2 -0
- package/dist/harness/run-options.d.ts.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/llm/content-hash.d.ts +2 -0
- package/dist/llm/content-hash.d.ts.map +1 -0
- package/dist/llm/content-hash.js +4 -0
- package/dist/llm/context-builder.js +1 -1
- package/dist/llm/cost-intelligence.d.ts +29 -0
- package/dist/llm/cost-intelligence.d.ts.map +1 -0
- package/dist/llm/cost-intelligence.js +153 -0
- package/dist/llm/provider.d.ts +11 -1
- package/dist/llm/provider.d.ts.map +1 -1
- package/dist/llm/provider.js +43 -4
- package/dist/phases/act.d.ts.map +1 -1
- package/dist/phases/act.js +4 -1
- package/dist/phases/observe.js +1 -1
- package/dist/phases/think-finalize.d.ts +6 -0
- package/dist/phases/think-finalize.d.ts.map +1 -0
- package/dist/phases/think-finalize.js +164 -0
- package/dist/phases/think.d.ts +2 -0
- package/dist/phases/think.d.ts.map +1 -1
- package/dist/phases/think.js +16 -65
- package/dist/reporters/markdown-reporter.d.ts.map +1 -1
- package/dist/reporters/markdown-reporter.js +23 -3
- package/dist/schemas/config.schema.d.ts +7 -0
- package/dist/schemas/config.schema.d.ts.map +1 -1
- package/dist/schemas/config.schema.js +18 -1
- package/dist/schemas/cost-intelligence.schema.d.ts +229 -0
- package/dist/schemas/cost-intelligence.schema.d.ts.map +1 -0
- package/dist/schemas/cost-intelligence.schema.js +41 -0
- package/dist/schemas/decision-log.schema.d.ts +2 -2
- package/dist/schemas/gap-analysis.schema.d.ts +270 -31
- package/dist/schemas/gap-analysis.schema.d.ts.map +1 -1
- package/dist/schemas/gap-analysis.schema.js +7 -3
- package/dist/schemas/index.d.ts +3 -1
- package/dist/schemas/index.d.ts.map +1 -1
- package/dist/schemas/index.js +3 -1
- package/dist/schemas/public-surface.schema.d.ts +268 -0
- package/dist/schemas/public-surface.schema.d.ts.map +1 -0
- package/dist/schemas/public-surface.schema.js +15 -0
- package/dist/tools/auth-block-gap.d.ts +3 -0
- package/dist/tools/auth-block-gap.d.ts.map +1 -0
- package/dist/tools/auth-block-gap.js +19 -0
- package/dist/tools/auth-detector.d.ts +2 -1
- package/dist/tools/auth-detector.d.ts.map +1 -1
- package/dist/tools/auth-detector.js +28 -3
- package/dist/tools/auth-explorer.d.ts +2 -1
- package/dist/tools/auth-explorer.d.ts.map +1 -1
- package/dist/tools/auth-explorer.js +30 -8
- package/dist/tools/auth-surface-analyzer.d.ts +4 -0
- package/dist/tools/auth-surface-analyzer.d.ts.map +1 -0
- package/dist/tools/auth-surface-analyzer.js +154 -0
- package/dist/tools/cypress-explorer.d.ts +2 -1
- package/dist/tools/cypress-explorer.d.ts.map +1 -1
- package/dist/tools/cypress-explorer.js +1 -1
- package/dist/tools/explorer.interface.d.ts +2 -1
- package/dist/tools/explorer.interface.d.ts.map +1 -1
- package/dist/tools/gap-engine.d.ts +3 -1
- package/dist/tools/gap-engine.d.ts.map +1 -1
- package/dist/tools/gap-engine.js +39 -12
- package/dist/tools/playwright-explorer.d.ts +2 -1
- package/dist/tools/playwright-explorer.d.ts.map +1 -1
- package/dist/tools/playwright-explorer.js +21 -3
- package/dist/tools/public-surface.d.ts +5 -0
- package/dist/tools/public-surface.d.ts.map +1 -0
- package/dist/tools/public-surface.js +13 -0
- package/package.json +7 -3
|
@@ -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(
|
|
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;
|
|
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,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;
|
|
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,
|
|
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"}
|
package/dist/tools/gap-engine.js
CHANGED
|
@@ -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'
|
|
61
|
-
? '
|
|
62
|
-
: impact === '
|
|
63
|
-
? '
|
|
64
|
-
: '
|
|
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
|
|
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';
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { AppExplorer } from './explorer.interface.js';
|
|
2
2
|
import { type RouteInventory } from '../schemas/route-inventory.schema.js';
|
|
3
3
|
import type { HarnessConfig } from '../schemas/config.schema.js';
|
|
4
|
+
import type { RunArtifactsOptions } from '../harness/run-options.js';
|
|
4
5
|
export declare class PlaywrightExplorer 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=playwright-explorer.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"playwright-explorer.d.ts","sourceRoot":"","sources":["../../src/tools/playwright-explorer.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAE3D,OAAO,EAAwB,KAAK,cAAc,EAAc,MAAM,sCAAsC,CAAC;AAC7G,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,6BAA6B,CAAC;
|
|
1
|
+
{"version":3,"file":"playwright-explorer.d.ts","sourceRoot":"","sources":["../../src/tools/playwright-explorer.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAE3D,OAAO,EAAwB,KAAK,cAAc,EAAc,MAAM,sCAAsC,CAAC;AAC7G,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,6BAA6B,CAAC;AACjE,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,2BAA2B,CAAC;AAoBrE,qBAAa,kBAAmB,YAAW,WAAW;IAC9C,OAAO,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,aAAa,EAAE,SAAS,CAAC,EAAE,mBAAmB,GAAG,OAAO,CAAC,cAAc,CAAC;CAsKhH"}
|
|
@@ -15,8 +15,12 @@ function isInternalHref(href, baseUrlStr) {
|
|
|
15
15
|
return false;
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
|
+
function debugMode() {
|
|
19
|
+
return process.env.QULIB_DEBUG === '1';
|
|
20
|
+
}
|
|
18
21
|
export class PlaywrightExplorer {
|
|
19
|
-
async explore(baseUrl, config) {
|
|
22
|
+
async explore(baseUrl, config, artifacts) {
|
|
23
|
+
const progress = artifacts?.progressLog;
|
|
20
24
|
const browser = await launchBrowser();
|
|
21
25
|
let context;
|
|
22
26
|
try {
|
|
@@ -28,7 +32,10 @@ export class PlaywrightExplorer {
|
|
|
28
32
|
}
|
|
29
33
|
if (config.auth) {
|
|
30
34
|
const label = config.auth.type === 'form-login' ? config.auth.credentials.username : 'storage-state';
|
|
31
|
-
|
|
35
|
+
progress?.info(`Authenticated context: ${label}`);
|
|
36
|
+
if (!progress) {
|
|
37
|
+
process.stderr.write(`[qulib] authenticated as ${label}\n`);
|
|
38
|
+
}
|
|
32
39
|
}
|
|
33
40
|
const visited = new Set();
|
|
34
41
|
const queue = [baseUrl];
|
|
@@ -56,10 +63,15 @@ export class PlaywrightExplorer {
|
|
|
56
63
|
}
|
|
57
64
|
});
|
|
58
65
|
try {
|
|
59
|
-
await page.goto(url, {
|
|
66
|
+
const navResponse = await page.goto(url, {
|
|
60
67
|
timeout: config.timeoutMs,
|
|
61
68
|
waitUntil: 'domcontentloaded',
|
|
62
69
|
});
|
|
70
|
+
const httpStatus = navResponse?.status() ?? 0;
|
|
71
|
+
if (debugMode()) {
|
|
72
|
+
const html = await page.content();
|
|
73
|
+
progress?.debug(`page HTML byteLength=${Buffer.byteLength(html, 'utf8')} url=${normalized}`);
|
|
74
|
+
}
|
|
63
75
|
const pageTitle = await page.title();
|
|
64
76
|
const formCount = await page.locator('form').count();
|
|
65
77
|
const buttonLabels = await page.locator('button').allInnerTexts();
|
|
@@ -92,6 +104,9 @@ export class PlaywrightExplorer {
|
|
|
92
104
|
const axeResults = await new AxeBuilder({ page })
|
|
93
105
|
.withTags(['wcag2a', 'wcag2aa'])
|
|
94
106
|
.analyze();
|
|
107
|
+
if (debugMode()) {
|
|
108
|
+
progress?.debug(`raw axe violations (pre-map) count=${axeResults.violations.length} json=${JSON.stringify(axeResults.violations)}`);
|
|
109
|
+
}
|
|
95
110
|
a11yViolations = axeResults.violations.map((v) => ({
|
|
96
111
|
id: v.id,
|
|
97
112
|
impact: v.impact ?? 'unknown',
|
|
@@ -103,6 +118,7 @@ export class PlaywrightExplorer {
|
|
|
103
118
|
consoleErrors.push(`axe-core failure: ${String(err)}`);
|
|
104
119
|
}
|
|
105
120
|
const path = new URL(url).pathname || '/';
|
|
121
|
+
progress?.info(`Crawled ${normalized} status=${httpStatus} a11yViolations=${a11yViolations.length}`);
|
|
106
122
|
routes.push({
|
|
107
123
|
path,
|
|
108
124
|
pageTitle,
|
|
@@ -112,6 +128,7 @@ export class PlaywrightExplorer {
|
|
|
112
128
|
consoleErrors,
|
|
113
129
|
brokenLinks,
|
|
114
130
|
a11yViolations,
|
|
131
|
+
statusCode: httpStatus,
|
|
115
132
|
});
|
|
116
133
|
}
|
|
117
134
|
catch (err) {
|
|
@@ -123,6 +140,7 @@ export class PlaywrightExplorer {
|
|
|
123
140
|
return url;
|
|
124
141
|
}
|
|
125
142
|
})();
|
|
143
|
+
progress?.info(`Crawled ${normalized} status=error a11yViolations=0 err=${String(err).slice(0, 120)}`);
|
|
126
144
|
routes.push({
|
|
127
145
|
path,
|
|
128
146
|
pageTitle: '',
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { RouteInventory } from '../schemas/route-inventory.schema.js';
|
|
2
|
+
import type { Gap } from '../schemas/gap-analysis.schema.js';
|
|
3
|
+
import type { PublicSurface } from '../schemas/public-surface.schema.js';
|
|
4
|
+
export declare function buildPublicSurface(pages: RouteInventory['routes'], gaps: Gap[]): PublicSurface;
|
|
5
|
+
//# sourceMappingURL=public-surface.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"public-surface.d.ts","sourceRoot":"","sources":["../../src/tools/public-surface.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sCAAsC,CAAC;AAC3E,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,mCAAmC,CAAC;AAC7D,OAAO,KAAK,EAAE,aAAa,EAAmD,MAAM,qCAAqC,CAAC;AAE1H,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,cAAc,CAAC,QAAQ,CAAC,EAAE,IAAI,EAAE,GAAG,EAAE,GAAG,aAAa,CAY9F"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function buildPublicSurface(pages, gaps) {
|
|
2
|
+
const accessibilityViolations = [];
|
|
3
|
+
const brokenLinks = [];
|
|
4
|
+
for (const r of pages) {
|
|
5
|
+
for (const v of r.a11yViolations) {
|
|
6
|
+
accessibilityViolations.push({ ...v, path: r.path });
|
|
7
|
+
}
|
|
8
|
+
for (const b of r.brokenLinks) {
|
|
9
|
+
brokenLinks.push({ ...b, path: r.path });
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
return { pages, gaps, accessibilityViolations, brokenLinks };
|
|
13
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qulib/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Qulib — analyze deployed web apps for honest quality gaps (CLI + programmatic API)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Tapesh Nagarwal",
|
|
@@ -31,13 +31,17 @@
|
|
|
31
31
|
"README.md"
|
|
32
32
|
],
|
|
33
33
|
"bin": {
|
|
34
|
-
"qulib": "
|
|
34
|
+
"qulib": "bin/qulib.js"
|
|
35
35
|
},
|
|
36
36
|
"scripts": {
|
|
37
37
|
"dev": "tsx src/cli/index.ts",
|
|
38
38
|
"analyze": "tsx src/cli/index.ts analyze",
|
|
39
39
|
"clean": "tsx src/cli/index.ts clean",
|
|
40
|
-
"build": "tsc"
|
|
40
|
+
"build": "tsc",
|
|
41
|
+
"test": "node --import tsx/esm --test src/llm/cost-intelligence.test.ts src/tools/gap-engine.test.ts src/tools/auth-block-gap.test.ts",
|
|
42
|
+
"test:integration": "node --import tsx/esm --test src/analyze.integration.test.ts",
|
|
43
|
+
"smoke": "tsx src/cli/index.ts analyze --url https://example.com --ephemeral",
|
|
44
|
+
"cost-doctor": "tsx src/cli/index.ts cost doctor"
|
|
41
45
|
},
|
|
42
46
|
"dependencies": {
|
|
43
47
|
"@axe-core/playwright": "^4.9.0",
|