@qulib/core 0.4.0 → 0.4.2
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 +22 -0
- package/dist/analyze.js +2 -2
- package/dist/cli/auth-login-resolve.d.ts +14 -0
- package/dist/cli/auth-login-resolve.d.ts.map +1 -0
- package/dist/cli/auth-login-resolve.js +68 -0
- package/dist/cli/auth-login-run.d.ts +13 -0
- package/dist/cli/auth-login-run.d.ts.map +1 -0
- package/dist/cli/auth-login-run.js +128 -0
- package/dist/cli/index.js +51 -1
- package/dist/harness/state-manager.d.ts +10 -0
- package/dist/harness/state-manager.d.ts.map +1 -1
- package/dist/harness/state-manager.js +15 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/phases/act.js +3 -3
- package/dist/phases/observe.js +3 -3
- package/dist/schemas/automation-maturity.schema.d.ts +40 -0
- package/dist/schemas/automation-maturity.schema.d.ts.map +1 -1
- package/dist/schemas/automation-maturity.schema.js +27 -0
- package/dist/schemas/config.schema.d.ts +229 -73
- package/dist/schemas/config.schema.d.ts.map +1 -1
- package/dist/schemas/config.schema.js +19 -18
- package/dist/schemas/index.d.ts +1 -1
- package/dist/schemas/index.d.ts.map +1 -1
- package/dist/schemas/index.js +1 -1
- package/dist/schemas/repo-analysis.schema.d.ts +22 -0
- package/dist/schemas/repo-analysis.schema.d.ts.map +1 -1
- package/dist/schemas/repo-analysis.schema.js +1 -0
- package/dist/telemetry/emit.d.ts +22 -0
- package/dist/telemetry/emit.d.ts.map +1 -1
- package/dist/telemetry/emit.js +37 -0
- package/dist/tools/auth-detector.d.ts +18 -0
- package/dist/tools/auth-detector.d.ts.map +1 -1
- package/dist/tools/auth-detector.js +287 -28
- package/dist/tools/auth-surface-analyzer.d.ts.map +1 -1
- package/dist/tools/auth-surface-analyzer.js +26 -10
- package/dist/tools/automation-maturity.d.ts.map +1 -1
- package/dist/tools/automation-maturity.js +76 -20
- package/dist/tools/repo-scanner.d.ts.map +1 -1
- package/dist/tools/repo-scanner.js +7 -2
- package/package.json +12 -2
package/README.md
CHANGED
|
@@ -47,6 +47,28 @@ qulib auth init --base-url https://app.example.com
|
|
|
47
47
|
|
|
48
48
|
This opens a real browser. Log in normally (OAuth, magic link, password manager, whatever). Press ENTER in the terminal when you reach a logged-in page. Qulib saves your session to `qulib-storage-state.json`.
|
|
49
49
|
|
|
50
|
+
### Automated form login (`auth login`)
|
|
51
|
+
|
|
52
|
+
When **`detect-auth`** shows **`authOptions`** with **`type: "form-login"`** and **`requirements.method: "credentials"`** (including click-to-reveal paths such as Scholastic Sync), you can save a storage state **without** manual clicking:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
qulib auth login --base-url https://platform.scholastic.com \
|
|
56
|
+
--auth-path scholastic-sync \
|
|
57
|
+
--credentials-file ~/.qulib/scholastic-creds.json \
|
|
58
|
+
--out ~/.qulib/scholastic-state.json
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The JSON file must map **field `name`** values from `authOptions` to secrets, e.g. `{"username":"…","password":"…","hidden.datasource":"…"}`. Prefer **`--credentials-file`** over **`--credentials`** so values are not stored in shell history.
|
|
62
|
+
|
|
63
|
+
Then analyze with the saved session:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
qulib analyze --url https://platform.scholastic.com \
|
|
67
|
+
--auth-storage-state ~/.qulib/scholastic-state.json
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Use **`--auth-path <id>`** when multiple **`form-login`** paths appear in **`authOptions`**. Use **`--success-url-contains <substring>`** for stricter success detection; otherwise Qulib infers success from URL changes or the password field disappearing (and warns if it cannot confirm).
|
|
71
|
+
|
|
50
72
|
Then scan with it:
|
|
51
73
|
|
|
52
74
|
```bash
|
package/dist/analyze.js
CHANGED
|
@@ -10,7 +10,7 @@ import { analyzeAuthSurfaceGaps } from './tools/auth-surface-analyzer.js';
|
|
|
10
10
|
import { buildPublicSurface } from './tools/public-surface.js';
|
|
11
11
|
import { buildAuthBlockGap } from './tools/auth-block-gap.js';
|
|
12
12
|
import { finalizeGapAnalysisFromDraft } from './phases/think-finalize.js';
|
|
13
|
-
import { emitTelemetry } from './telemetry/emit.js';
|
|
13
|
+
import { emitTelemetry, redactUrlForTelemetry } from './telemetry/emit.js';
|
|
14
14
|
function logScanEnd(progress, result) {
|
|
15
15
|
const rc = result.releaseConfidence === null ? 'null' : String(result.releaseConfidence);
|
|
16
16
|
const cs = result.coverageScore === null ? 'null' : String(result.coverageScore);
|
|
@@ -35,7 +35,7 @@ export async function analyzeApp(options) {
|
|
|
35
35
|
...(progress !== undefined && { progressLog: progress }),
|
|
36
36
|
};
|
|
37
37
|
emitTelemetry(options.telemetry, 'scan.started', sessionId, {
|
|
38
|
-
url: options.url,
|
|
38
|
+
url: redactUrlForTelemetry(options.url),
|
|
39
39
|
maxPagesToScan: options.config.maxPagesToScan,
|
|
40
40
|
hasAuth: Boolean(options.config.auth),
|
|
41
41
|
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { AuthPath } from '../schemas/config.schema.js';
|
|
2
|
+
export declare function assertExactlyOneCredentialSource(credentials?: string, credentialsFile?: string): void;
|
|
3
|
+
export declare function parseCredentialsJsonString(json: string): Record<string, string>;
|
|
4
|
+
export declare function resolveFormLoginPath(baseUrl: string, authOptions: AuthPath[] | undefined, authPathId?: string): AuthPath;
|
|
5
|
+
export declare function assertCredentialsCoverFields(credentials: Record<string, string>, path: AuthPath): void;
|
|
6
|
+
export declare function resolveAuthLoginConfig(params: {
|
|
7
|
+
baseUrl: string;
|
|
8
|
+
authOptions: AuthPath[] | undefined;
|
|
9
|
+
credentials: Record<string, string>;
|
|
10
|
+
authPathId?: string;
|
|
11
|
+
}): {
|
|
12
|
+
path: AuthPath;
|
|
13
|
+
};
|
|
14
|
+
//# sourceMappingURL=auth-login-resolve.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth-login-resolve.d.ts","sourceRoot":"","sources":["../../src/cli/auth-login-resolve.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,6BAA6B,CAAC;AAE5D,wBAAgB,gCAAgC,CAAC,WAAW,CAAC,EAAE,MAAM,EAAE,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CASrG;AAED,wBAAgB,0BAA0B,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAkB/E;AAED,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,QAAQ,EAAE,GAAG,SAAS,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,QAAQ,CAsBxH;AAED,wBAAgB,4BAA4B,CAAC,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,IAAI,EAAE,QAAQ,GAAG,IAAI,CAatG;AAED,wBAAgB,sBAAsB,CAAC,MAAM,EAAE;IAC7C,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,QAAQ,EAAE,GAAG,SAAS,CAAC;IACpC,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACpC,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,GAAG;IAAE,IAAI,EAAE,QAAQ,CAAA;CAAE,CAIrB"}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export function assertExactlyOneCredentialSource(credentials, credentialsFile) {
|
|
2
|
+
const hasC = Boolean(credentials && String(credentials).trim().length > 0);
|
|
3
|
+
const hasF = Boolean(credentialsFile && String(credentialsFile).trim().length > 0);
|
|
4
|
+
if (hasC && hasF) {
|
|
5
|
+
throw new Error('Provide either --credentials or --credentials-file, not both.');
|
|
6
|
+
}
|
|
7
|
+
if (!hasC && !hasF) {
|
|
8
|
+
throw new Error('One of --credentials or --credentials-file is required.');
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export function parseCredentialsJsonString(json) {
|
|
12
|
+
let parsed;
|
|
13
|
+
try {
|
|
14
|
+
parsed = JSON.parse(json);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
throw new Error('Invalid JSON in --credentials');
|
|
18
|
+
}
|
|
19
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
20
|
+
throw new Error('--credentials must be a JSON object mapping field name → value.');
|
|
21
|
+
}
|
|
22
|
+
const out = {};
|
|
23
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
24
|
+
if (v === undefined || v === null) {
|
|
25
|
+
throw new Error(`Credential value for "${k}" cannot be null or undefined.`);
|
|
26
|
+
}
|
|
27
|
+
out[k] = String(v);
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
export function resolveFormLoginPath(baseUrl, authOptions, authPathId) {
|
|
32
|
+
const formPaths = (authOptions ?? []).filter((o) => o.type === 'form-login' && o.requirements.method === 'credentials');
|
|
33
|
+
if (formPaths.length === 0) {
|
|
34
|
+
throw new Error(`No automatable form-login path detected on ${baseUrl}. Use \`qulib auth init\` for manual login.`);
|
|
35
|
+
}
|
|
36
|
+
if (formPaths.length === 1) {
|
|
37
|
+
return formPaths[0];
|
|
38
|
+
}
|
|
39
|
+
if (!authPathId || !authPathId.trim()) {
|
|
40
|
+
const ids = formPaths.map((p) => p.id).join(', ');
|
|
41
|
+
throw new Error(`Multiple form-login options found: ${ids}. Re-run with --auth-path <id>.`);
|
|
42
|
+
}
|
|
43
|
+
const found = formPaths.find((p) => p.id === authPathId.trim());
|
|
44
|
+
if (!found) {
|
|
45
|
+
const ids = formPaths.map((p) => p.id).join(', ');
|
|
46
|
+
throw new Error(`No form-login authOption with id "${authPathId}". Available: ${ids}.`);
|
|
47
|
+
}
|
|
48
|
+
return found;
|
|
49
|
+
}
|
|
50
|
+
export function assertCredentialsCoverFields(credentials, path) {
|
|
51
|
+
if (path.requirements.method !== 'credentials') {
|
|
52
|
+
throw new Error('Internal error: expected credentials requirements on form-login path.');
|
|
53
|
+
}
|
|
54
|
+
const missing = [];
|
|
55
|
+
for (const f of path.requirements.fields) {
|
|
56
|
+
if (!(f.name in credentials) || credentials[f.name] === '') {
|
|
57
|
+
missing.push(f.name);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (missing.length > 0) {
|
|
61
|
+
throw new Error(`Missing credential value(s) for field name(s): ${missing.join(', ')}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export function resolveAuthLoginConfig(params) {
|
|
65
|
+
const path = resolveFormLoginPath(params.baseUrl, params.authOptions, params.authPathId);
|
|
66
|
+
assertCredentialsCoverFields(params.credentials, path);
|
|
67
|
+
return { path };
|
|
68
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { AuthPath } from '../schemas/config.schema.js';
|
|
2
|
+
export declare function authPathNeedsClickReveal(path: AuthPath): boolean;
|
|
3
|
+
export declare function runAutomatedAuthLogin(params: {
|
|
4
|
+
loginUrl: string;
|
|
5
|
+
path: AuthPath;
|
|
6
|
+
credentials: Record<string, string>;
|
|
7
|
+
outPath: string;
|
|
8
|
+
headed: boolean;
|
|
9
|
+
timeoutMs: number;
|
|
10
|
+
successUrlContains?: string;
|
|
11
|
+
baseUrlHint: string;
|
|
12
|
+
}): Promise<void>;
|
|
13
|
+
//# sourceMappingURL=auth-login-run.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth-login-run.d.ts","sourceRoot":"","sources":["../../src/cli/auth-login-run.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,6BAA6B,CAAC;AAqB5D,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,QAAQ,GAAG,OAAO,CAEhE;AAED,wBAAsB,qBAAqB,CAAC,MAAM,EAAE;IAClD,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,QAAQ,CAAC;IACf,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACpC,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,OAAO,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,WAAW,EAAE,MAAM,CAAC;CACrB,GAAG,OAAO,CAAC,IAAI,CAAC,CA4GhB"}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { BUILT_IN_OAUTH_PROVIDERS } from '../tools/oauth-providers.js';
|
|
2
|
+
const builtInOAuthIds = new Set(BUILT_IN_OAUTH_PROVIDERS.map((p) => p.id));
|
|
3
|
+
function escapeRegExp(s) {
|
|
4
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
5
|
+
}
|
|
6
|
+
async function waitNetworkIdleBestEffort(page) {
|
|
7
|
+
try {
|
|
8
|
+
await page.waitForLoadState('networkidle', { timeout: 5000 });
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
/* best-effort */
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function sleep(ms) {
|
|
15
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
16
|
+
}
|
|
17
|
+
export function authPathNeedsClickReveal(path) {
|
|
18
|
+
return path.type === 'form-login' && path.source === 'heuristic' && !builtInOAuthIds.has(path.id);
|
|
19
|
+
}
|
|
20
|
+
export async function runAutomatedAuthLogin(params) {
|
|
21
|
+
const { chromium } = await import('@playwright/test');
|
|
22
|
+
const browser = await chromium.launch({ headless: !params.headed });
|
|
23
|
+
const context = await browser.newContext();
|
|
24
|
+
const page = await context.newPage();
|
|
25
|
+
let confirmed = false;
|
|
26
|
+
try {
|
|
27
|
+
await page.goto(params.loginUrl, { waitUntil: 'domcontentloaded', timeout: params.timeoutMs });
|
|
28
|
+
await waitNetworkIdleBestEffort(page);
|
|
29
|
+
if (authPathNeedsClickReveal(params.path)) {
|
|
30
|
+
try {
|
|
31
|
+
await page.getByRole('button', { name: params.path.label, exact: true }).first().click({ timeout: 2000 });
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
await page
|
|
35
|
+
.locator('button')
|
|
36
|
+
.filter({ hasText: new RegExp(`^\\s*${escapeRegExp(params.path.label)}\\s*$`, 'i') })
|
|
37
|
+
.first()
|
|
38
|
+
.click({ timeout: 2000 });
|
|
39
|
+
}
|
|
40
|
+
await page.locator('input[type="password"]').first().waitFor({ state: 'visible', timeout: 2000 });
|
|
41
|
+
}
|
|
42
|
+
if (params.path.requirements.method !== 'credentials') {
|
|
43
|
+
throw new Error('Internal error: expected credentials method on form-login path.');
|
|
44
|
+
}
|
|
45
|
+
for (const field of params.path.requirements.fields) {
|
|
46
|
+
const val = params.credentials[field.name];
|
|
47
|
+
const nameJson = JSON.stringify(field.name);
|
|
48
|
+
const inputByName = `input[name=${nameJson}]`;
|
|
49
|
+
const selectByName = `select[name=${nameJson}]`;
|
|
50
|
+
try {
|
|
51
|
+
if (field.type === 'select') {
|
|
52
|
+
const sel = page.locator(selectByName).first();
|
|
53
|
+
try {
|
|
54
|
+
await sel.selectOption(val, { timeout: 8000 });
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
await sel.selectOption({ label: val }, { timeout: 8000 });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
else if (field.type === 'checkbox') {
|
|
61
|
+
const loc = page.locator(`input[type="checkbox"][name=${nameJson}]`).first();
|
|
62
|
+
if (val === 'true' || val === '1' || val === 'on' || val === 'yes') {
|
|
63
|
+
await loc.check({ timeout: 8000 });
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
await loc.uncheck({ timeout: 8000 });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
await page.locator(inputByName).first().fill(val, { timeout: 8000 });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch (e) {
|
|
74
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
75
|
+
throw new Error(`Failed to fill field "${field.name}" (${field.label}): ${msg}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const preSubmit = page.url();
|
|
79
|
+
try {
|
|
80
|
+
await page.locator('button[type="submit"]').first().click({ timeout: 8000 });
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
await page.locator('input[type="password"]').first().press('Enter');
|
|
84
|
+
}
|
|
85
|
+
if (params.successUrlContains && params.successUrlContains.trim().length > 0) {
|
|
86
|
+
const frag = params.successUrlContains.trim();
|
|
87
|
+
try {
|
|
88
|
+
await page.waitForURL((u) => u.toString().includes(frag), { timeout: params.timeoutMs });
|
|
89
|
+
confirmed = true;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
confirmed = false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
const t0 = Date.now();
|
|
97
|
+
while (Date.now() - t0 < params.timeoutMs) {
|
|
98
|
+
if (page.url() !== preSubmit) {
|
|
99
|
+
confirmed = true;
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
if (Date.now() - t0 >= 5000) {
|
|
103
|
+
const vis = await page.locator('input[type="password"]:visible').count();
|
|
104
|
+
if (vis === 0) {
|
|
105
|
+
confirmed = true;
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
await sleep(250);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (!confirmed) {
|
|
113
|
+
console.error('[qulib] Could not confirm login success. Storage state saved; verify manually before relying on it.');
|
|
114
|
+
}
|
|
115
|
+
const fs = await import('node:fs/promises');
|
|
116
|
+
const pathMod = await import('node:path');
|
|
117
|
+
const outAbs = pathMod.resolve(params.outPath);
|
|
118
|
+
await fs.mkdir(pathMod.dirname(outAbs), { recursive: true });
|
|
119
|
+
await context.storageState({ path: outAbs });
|
|
120
|
+
console.log(`\n[qulib] Saved storage state to ${outAbs}`);
|
|
121
|
+
console.log('[qulib] To use it, pass to qulib like:');
|
|
122
|
+
console.log(` qulib analyze --url ${params.baseUrlHint} --auth-storage-state ${outAbs}`);
|
|
123
|
+
console.log(`[qulib] Or in MCP, pass auth: { type: 'storage-state', path: '${outAbs}' }`);
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
await browser.close();
|
|
127
|
+
}
|
|
128
|
+
}
|
package/dist/cli/index.js
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from 'commander';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
3
4
|
import { resolve } from 'node:path';
|
|
4
5
|
import { pathToFileURL } from 'node:url';
|
|
5
6
|
import { z } from 'zod';
|
|
7
|
+
const requirePkg = createRequire(import.meta.url);
|
|
8
|
+
const pkg = requirePkg('../../package.json');
|
|
6
9
|
import { HarnessConfigSchema } from '../schemas/config.schema.js';
|
|
7
10
|
import { analyzeApp } from '../analyze.js';
|
|
8
11
|
import { detectAuth } from '../tools/auth-detector.js';
|
|
9
12
|
import { exploreAuth } from '../tools/auth-explorer.js';
|
|
13
|
+
import { assertExactlyOneCredentialSource, parseCredentialsJsonString, resolveAuthLoginConfig, } from './auth-login-resolve.js';
|
|
14
|
+
import { runAutomatedAuthLogin } from './auth-login-run.js';
|
|
10
15
|
const program = new Command();
|
|
11
16
|
const AnalyzeUrlSchema = z.string().url();
|
|
12
17
|
const FormLoginCliSchema = z.object({
|
|
@@ -109,7 +114,7 @@ async function runAnalyze(options) {
|
|
|
109
114
|
program
|
|
110
115
|
.name('qulib')
|
|
111
116
|
.description('Qulib — QA harness')
|
|
112
|
-
.version(
|
|
117
|
+
.version(pkg.version);
|
|
113
118
|
program
|
|
114
119
|
.command('clean')
|
|
115
120
|
.description('Remove all generated reports and scan state')
|
|
@@ -265,6 +270,7 @@ authCmd
|
|
|
265
270
|
const fs = await import('node:fs/promises');
|
|
266
271
|
const pathMod = await import('node:path');
|
|
267
272
|
const outPath = pathMod.resolve(options.out);
|
|
273
|
+
await fs.mkdir(pathMod.dirname(outPath), { recursive: true });
|
|
268
274
|
await context.storageState({ path: outPath });
|
|
269
275
|
console.log(`\n[qulib] Saved storage state to ${outPath}`);
|
|
270
276
|
console.log('[qulib] To use it, pass to qulib like:');
|
|
@@ -273,6 +279,50 @@ authCmd
|
|
|
273
279
|
await browser.close();
|
|
274
280
|
process.exit(0);
|
|
275
281
|
});
|
|
282
|
+
authCmd
|
|
283
|
+
.command('login')
|
|
284
|
+
.description('Detect form-login on the URL, fill credentials, and save the storage state automatically (uses selectors from detect-auth)')
|
|
285
|
+
.requiredOption('--base-url <url>', 'The base URL of the app to log into')
|
|
286
|
+
.option('--auth-path <id>', 'Specific authOption id to use (e.g. "scholastic-sync") when multiple form-login paths exist')
|
|
287
|
+
.option('--credentials <json>', 'JSON object mapping field name → value, e.g. \'{"username":"a","password":"b","hidden.datasource":"NYC"}\'')
|
|
288
|
+
.option('--credentials-file <path>', 'Path to a JSON file with the credentials object (keeps secrets out of shell history)')
|
|
289
|
+
.option('--out <path>', 'Output file path for the storage state JSON', './qulib-storage-state.json')
|
|
290
|
+
.option('--success-url-contains <substring>', 'Substring that must appear in the URL after login (stronger success detection). If omitted, success is inferred from navigation or hidden password fields.')
|
|
291
|
+
.option('--timeout <ms>', 'Max time in ms to wait for navigation / success heuristics', '30000')
|
|
292
|
+
.option('--headed', 'Run Chromium headed for debugging', false)
|
|
293
|
+
.action(async (options) => {
|
|
294
|
+
assertExactlyOneCredentialSource(options.credentials, options.credentialsFile);
|
|
295
|
+
const fs = await import('node:fs/promises');
|
|
296
|
+
let credentials;
|
|
297
|
+
if (options.credentialsFile && options.credentialsFile.trim()) {
|
|
298
|
+
const p = resolve(options.credentialsFile.trim());
|
|
299
|
+
const raw = await fs.readFile(p, 'utf8');
|
|
300
|
+
credentials = parseCredentialsJsonString(raw);
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
credentials = parseCredentialsJsonString(options.credentials.trim());
|
|
304
|
+
}
|
|
305
|
+
const timeoutMs = parseInt(options.timeout, 10);
|
|
306
|
+
const detection = await detectAuth(options.baseUrl, timeoutMs);
|
|
307
|
+
const { path } = resolveAuthLoginConfig({
|
|
308
|
+
baseUrl: options.baseUrl,
|
|
309
|
+
authOptions: detection.authOptions,
|
|
310
|
+
credentials,
|
|
311
|
+
authPathId: options.authPath,
|
|
312
|
+
});
|
|
313
|
+
const loginUrl = detection.loginUrl ?? options.baseUrl;
|
|
314
|
+
await runAutomatedAuthLogin({
|
|
315
|
+
loginUrl,
|
|
316
|
+
path,
|
|
317
|
+
credentials,
|
|
318
|
+
outPath: options.out,
|
|
319
|
+
headed: Boolean(options.headed),
|
|
320
|
+
timeoutMs,
|
|
321
|
+
successUrlContains: options.successUrlContains,
|
|
322
|
+
baseUrlHint: options.baseUrl,
|
|
323
|
+
});
|
|
324
|
+
process.exit(0);
|
|
325
|
+
});
|
|
276
326
|
program.parseAsync().catch((error) => {
|
|
277
327
|
const message = error instanceof Error ? error.message : String(error);
|
|
278
328
|
console.error('[qulib] Failed:', message);
|
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import type { ZodSchema } from 'zod';
|
|
2
2
|
export declare function resolveScanStateBaseDir(outputDir?: string): string;
|
|
3
|
+
/**
|
|
4
|
+
* Where qulib writes `report.json` and `report.md` for non-ephemeral runs.
|
|
5
|
+
*
|
|
6
|
+
* When `outputDir` is set in HarnessConfig, both scan state and reports share that
|
|
7
|
+
* directory (state files and report files have non-overlapping names). When unset,
|
|
8
|
+
* reports default to `<cwd>/output` (the legacy default) while state defaults to
|
|
9
|
+
* `<cwd>/.scan-state` — separate so a casual `git add output/` doesn't accidentally
|
|
10
|
+
* commit scan state.
|
|
11
|
+
*/
|
|
12
|
+
export declare function resolveReportDir(outputDir?: string): string;
|
|
3
13
|
export declare class StateManager {
|
|
4
14
|
private readonly stateDir;
|
|
5
15
|
constructor(scanStateBaseDir?: string);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"state-manager.d.ts","sourceRoot":"","sources":["../../src/harness/state-manager.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,KAAK,CAAC;AAErC,wBAAgB,uBAAuB,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,MAAM,CAKlE;AAED,qBAAa,YAAY;IACvB,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;gBAEtB,gBAAgB,CAAC,EAAE,MAAM;IAI/B,SAAS,CAAC,CAAC,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC;IA8BhE,UAAU,CAAC,CAAC,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,EAAE,MAAM,EAAE,SAAS,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC;CAiBpF"}
|
|
1
|
+
{"version":3,"file":"state-manager.d.ts","sourceRoot":"","sources":["../../src/harness/state-manager.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,KAAK,CAAC;AAErC,wBAAgB,uBAAuB,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,MAAM,CAKlE;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,MAAM,CAK3D;AAED,qBAAa,YAAY;IACvB,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;gBAEtB,gBAAgB,CAAC,EAAE,MAAM;IAI/B,SAAS,CAAC,CAAC,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC;IA8BhE,UAAU,CAAC,CAAC,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,EAAE,MAAM,EAAE,SAAS,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC;CAiBpF"}
|
|
@@ -8,6 +8,21 @@ export function resolveScanStateBaseDir(outputDir) {
|
|
|
8
8
|
}
|
|
9
9
|
return isAbsolute(outputDir) ? resolve(outputDir) : resolve(process.cwd(), outputDir);
|
|
10
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* Where qulib writes `report.json` and `report.md` for non-ephemeral runs.
|
|
13
|
+
*
|
|
14
|
+
* When `outputDir` is set in HarnessConfig, both scan state and reports share that
|
|
15
|
+
* directory (state files and report files have non-overlapping names). When unset,
|
|
16
|
+
* reports default to `<cwd>/output` (the legacy default) while state defaults to
|
|
17
|
+
* `<cwd>/.scan-state` — separate so a casual `git add output/` doesn't accidentally
|
|
18
|
+
* commit scan state.
|
|
19
|
+
*/
|
|
20
|
+
export function resolveReportDir(outputDir) {
|
|
21
|
+
if (outputDir === undefined || outputDir === '') {
|
|
22
|
+
return join(process.cwd(), 'output');
|
|
23
|
+
}
|
|
24
|
+
return isAbsolute(outputDir) ? resolve(outputDir) : resolve(process.cwd(), outputDir);
|
|
25
|
+
}
|
|
11
26
|
export class StateManager {
|
|
12
27
|
stateDir;
|
|
13
28
|
constructor(scanStateBaseDir) {
|
package/dist/index.d.ts
CHANGED
|
@@ -6,11 +6,12 @@ export { scanRepo } from './tools/repo-scanner.js';
|
|
|
6
6
|
export { computeAutomationMaturity } from './tools/automation-maturity.js';
|
|
7
7
|
export { createProvider } from './llm/provider-registry.js';
|
|
8
8
|
export { resolveMaxOutputTokensPerLlmCall } from './schemas/config.schema.js';
|
|
9
|
-
export { resolveScanStateBaseDir } from './harness/state-manager.js';
|
|
9
|
+
export { resolveScanStateBaseDir, resolveReportDir } from './harness/state-manager.js';
|
|
10
10
|
export type { AnalyzeOptions, AnalyzeResult, AnalyzeStatus } from './analyze.js';
|
|
11
11
|
export type { AnalyzeProgressSink } from './harness/progress-log.js';
|
|
12
12
|
export type { TelemetrySink, TelemetryEvent, TelemetryEventKind, } from './telemetry/telemetry.interface.js';
|
|
13
13
|
export { NoopTelemetrySink } from './telemetry/telemetry.interface.js';
|
|
14
|
+
export { redactUrlForTelemetry } from './telemetry/emit.js';
|
|
14
15
|
export type { LlmCallResult, LlmProvider } from './llm/provider.interface.js';
|
|
15
16
|
export type { CallLlmConfigSlice } from './llm/provider.js';
|
|
16
17
|
export type { HarnessConfig, AuthConfig, RouteInventory, GapAnalysis, RepoAnalysis, DetectedAuth, AuthExploration, AuthPath, AuthPathRequirements, CostIntelligence, LlmUsageRecord, RepeatedAiPattern, DeterministicMaturity, PublicSurface, AutomationMaturity, AutomationMaturityDimension, FrameworkDetectionResult, DetectedFrameworkPrimary, } from './schemas/index.js';
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAC1C,OAAO,EAAE,UAAU,EAAE,MAAM,0BAA0B,CAAC;AACtD,OAAO,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAC;AACvD,OAAO,EAAE,eAAe,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,MAAM,2BAA2B,CAAC;AACnG,OAAO,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AACnD,OAAO,EAAE,yBAAyB,EAAE,MAAM,gCAAgC,CAAC;AAC3E,OAAO,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AAC5D,OAAO,EAAE,gCAAgC,EAAE,MAAM,4BAA4B,CAAC;AAC9E,OAAO,EAAE,uBAAuB,EAAE,MAAM,4BAA4B,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAC1C,OAAO,EAAE,UAAU,EAAE,MAAM,0BAA0B,CAAC;AACtD,OAAO,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAC;AACvD,OAAO,EAAE,eAAe,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,MAAM,2BAA2B,CAAC;AACnG,OAAO,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AACnD,OAAO,EAAE,yBAAyB,EAAE,MAAM,gCAAgC,CAAC;AAC3E,OAAO,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AAC5D,OAAO,EAAE,gCAAgC,EAAE,MAAM,4BAA4B,CAAC;AAC9E,OAAO,EAAE,uBAAuB,EAAE,gBAAgB,EAAE,MAAM,4BAA4B,CAAC;AACvF,YAAY,EAAE,cAAc,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AACjF,YAAY,EAAE,mBAAmB,EAAE,MAAM,2BAA2B,CAAC;AACrE,YAAY,EACV,aAAa,EACb,cAAc,EACd,kBAAkB,GACnB,MAAM,oCAAoC,CAAC;AAC5C,OAAO,EAAE,iBAAiB,EAAE,MAAM,oCAAoC,CAAC;AACvE,OAAO,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAC5D,YAAY,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,6BAA6B,CAAC;AAC9E,YAAY,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAC5D,YAAY,EACV,aAAa,EACb,UAAU,EACV,cAAc,EACd,WAAW,EACX,YAAY,EACZ,YAAY,EACZ,eAAe,EACf,QAAQ,EACR,oBAAoB,EACpB,gBAAgB,EAChB,cAAc,EACd,iBAAiB,EACjB,qBAAqB,EACrB,aAAa,EACb,kBAAkB,EAClB,2BAA2B,EAC3B,wBAAwB,EACxB,wBAAwB,GACzB,MAAM,oBAAoB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -6,5 +6,6 @@ export { scanRepo } from './tools/repo-scanner.js';
|
|
|
6
6
|
export { computeAutomationMaturity } from './tools/automation-maturity.js';
|
|
7
7
|
export { createProvider } from './llm/provider-registry.js';
|
|
8
8
|
export { resolveMaxOutputTokensPerLlmCall } from './schemas/config.schema.js';
|
|
9
|
-
export { resolveScanStateBaseDir } from './harness/state-manager.js';
|
|
9
|
+
export { resolveScanStateBaseDir, resolveReportDir } from './harness/state-manager.js';
|
|
10
10
|
export { NoopTelemetrySink } from './telemetry/telemetry.interface.js';
|
|
11
|
+
export { redactUrlForTelemetry } from './telemetry/emit.js';
|
package/dist/phases/act.js
CHANGED
|
@@ -3,10 +3,10 @@ import { writeJsonReport } from '../reporters/json-reporter.js';
|
|
|
3
3
|
import { writeMarkdownReport } from '../reporters/markdown-reporter.js';
|
|
4
4
|
import { logDecision } from '../harness/decision-logger.js';
|
|
5
5
|
import { emitTelemetry } from '../telemetry/emit.js';
|
|
6
|
-
import { resolveScanStateBaseDir } from '../harness/state-manager.js';
|
|
6
|
+
import { resolveReportDir, resolveScanStateBaseDir } from '../harness/state-manager.js';
|
|
7
7
|
export async function act(analysis, config, artifacts = { writeArtifacts: true }) {
|
|
8
8
|
const sessionId = artifacts.telemetrySessionId ?? 'none';
|
|
9
|
-
const reportDir =
|
|
9
|
+
const reportDir = resolveReportDir(config.outputDir);
|
|
10
10
|
const logOpts = {
|
|
11
11
|
persist: artifacts.writeArtifacts,
|
|
12
12
|
memory: artifacts.decisionMemory,
|
|
@@ -48,7 +48,7 @@ export async function act(analysis, config, artifacts = { writeArtifacts: true }
|
|
|
48
48
|
if (config.requireHumanReview) {
|
|
49
49
|
log('\n[qulib] Human review required before applying any generated output.');
|
|
50
50
|
if (artifacts.writeArtifacts) {
|
|
51
|
-
log(
|
|
51
|
+
log(` Reports: ${join(reportDir, 'report.json')} and ${join(reportDir, 'report.md')}`);
|
|
52
52
|
log(` Decisions: ${join(resolveScanStateBaseDir(config.outputDir), 'decision-log.json')}`);
|
|
53
53
|
}
|
|
54
54
|
else {
|
package/dist/phases/observe.js
CHANGED
|
@@ -4,7 +4,7 @@ import { createExplorer } from '../tools/explorer-factory.js';
|
|
|
4
4
|
import { scanRepo } from '../tools/repo-scanner.js';
|
|
5
5
|
import { StateManager } from '../harness/state-manager.js';
|
|
6
6
|
import { logDecision } from '../harness/decision-logger.js';
|
|
7
|
-
import { emitTelemetry } from '../telemetry/emit.js';
|
|
7
|
+
import { emitTelemetry, redactUrlForTelemetry } from '../telemetry/emit.js';
|
|
8
8
|
export async function observe(baseUrl, repoPath, config, artifacts = { writeArtifacts: true }) {
|
|
9
9
|
const sessionId = artifacts.telemetrySessionId ?? 'none';
|
|
10
10
|
const explorer = createExplorer(config.explorer);
|
|
@@ -15,7 +15,7 @@ export async function observe(baseUrl, repoPath, config, artifacts = { writeArti
|
|
|
15
15
|
outputDir: config.outputDir,
|
|
16
16
|
};
|
|
17
17
|
emitTelemetry(artifacts.telemetry, 'phase.observe.started', sessionId, {
|
|
18
|
-
baseUrl,
|
|
18
|
+
baseUrl: redactUrlForTelemetry(baseUrl),
|
|
19
19
|
hasRepoPath: Boolean(repoPath),
|
|
20
20
|
});
|
|
21
21
|
const rawRoutes = await explorer.explore(baseUrl, config, artifacts);
|
|
@@ -29,7 +29,7 @@ export async function observe(baseUrl, repoPath, config, artifacts = { writeArti
|
|
|
29
29
|
decision: 'exploration-complete',
|
|
30
30
|
reason: `Discovered ${routes.routes.length} routes; budgetExceeded=${routes.budgetExceeded}`,
|
|
31
31
|
metadata: {
|
|
32
|
-
baseUrl,
|
|
32
|
+
baseUrl: redactUrlForTelemetry(baseUrl),
|
|
33
33
|
scannedRoutes: routes.routes.length,
|
|
34
34
|
budgetExceeded: routes.budgetExceeded,
|
|
35
35
|
pagesSkipped: routes.pagesSkipped,
|
|
@@ -1,22 +1,48 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
export declare const AutomationMaturityApplicabilitySchema: z.ZodEnum<["applicable", "not_applicable", "unknown"]>;
|
|
3
|
+
/**
|
|
4
|
+
* Maturity dimension with explicit applicability so absent capabilities are not silently
|
|
5
|
+
* awarded partial credit.
|
|
6
|
+
*
|
|
7
|
+
* - `applicable` — Qulib has enough signal to compute a real `score`.
|
|
8
|
+
* - `not_applicable` — The capability does not apply to this repo (e.g. component-test-ratio
|
|
9
|
+
* with no Cypress detected, auth-test-coverage when no auth signal exists).
|
|
10
|
+
* `score` is reported but excluded from the overall calculation.
|
|
11
|
+
* - `unknown` — Qulib could not collect enough signal to score honestly (e.g. zero
|
|
12
|
+
* interactive elements scanned for test-id hygiene). Excluded from overall.
|
|
13
|
+
*
|
|
14
|
+
* Overall score formula (in `computeAutomationMaturity`):
|
|
15
|
+
* numerator = Σ score_i * weight_i for i ∈ applicable dimensions
|
|
16
|
+
* denominator = Σ weight_i for i ∈ applicable dimensions
|
|
17
|
+
* overallScore = round(numerator / denominator) when denominator > 0, else 0
|
|
18
|
+
*
|
|
19
|
+
* Schema fields stay backward compatible: both `applicability` and `reason` are optional.
|
|
20
|
+
* Existing consumers that don't read them keep working; honest reports populate them.
|
|
21
|
+
*/
|
|
2
22
|
export declare const AutomationMaturityDimensionSchema: z.ZodObject<{
|
|
3
23
|
dimension: z.ZodEnum<["test-coverage-breadth", "framework-adoption", "test-id-hygiene", "ci-integration", "auth-test-coverage", "component-test-ratio"]>;
|
|
4
24
|
score: z.ZodNumber;
|
|
5
25
|
weight: z.ZodNumber;
|
|
6
26
|
evidence: z.ZodArray<z.ZodString, "many">;
|
|
7
27
|
recommendations: z.ZodArray<z.ZodString, "many">;
|
|
28
|
+
applicability: z.ZodOptional<z.ZodEnum<["applicable", "not_applicable", "unknown"]>>;
|
|
29
|
+
reason: z.ZodOptional<z.ZodString>;
|
|
8
30
|
}, "strip", z.ZodTypeAny, {
|
|
9
31
|
recommendations: string[];
|
|
10
32
|
dimension: "test-coverage-breadth" | "framework-adoption" | "test-id-hygiene" | "ci-integration" | "auth-test-coverage" | "component-test-ratio";
|
|
11
33
|
score: number;
|
|
12
34
|
weight: number;
|
|
13
35
|
evidence: string[];
|
|
36
|
+
reason?: string | undefined;
|
|
37
|
+
applicability?: "unknown" | "applicable" | "not_applicable" | undefined;
|
|
14
38
|
}, {
|
|
15
39
|
recommendations: string[];
|
|
16
40
|
dimension: "test-coverage-breadth" | "framework-adoption" | "test-id-hygiene" | "ci-integration" | "auth-test-coverage" | "component-test-ratio";
|
|
17
41
|
score: number;
|
|
18
42
|
weight: number;
|
|
19
43
|
evidence: string[];
|
|
44
|
+
reason?: string | undefined;
|
|
45
|
+
applicability?: "unknown" | "applicable" | "not_applicable" | undefined;
|
|
20
46
|
}>;
|
|
21
47
|
export declare const AutomationMaturitySchema: z.ZodObject<{
|
|
22
48
|
computedAt: z.ZodString;
|
|
@@ -30,20 +56,27 @@ export declare const AutomationMaturitySchema: z.ZodObject<{
|
|
|
30
56
|
weight: z.ZodNumber;
|
|
31
57
|
evidence: z.ZodArray<z.ZodString, "many">;
|
|
32
58
|
recommendations: z.ZodArray<z.ZodString, "many">;
|
|
59
|
+
applicability: z.ZodOptional<z.ZodEnum<["applicable", "not_applicable", "unknown"]>>;
|
|
60
|
+
reason: z.ZodOptional<z.ZodString>;
|
|
33
61
|
}, "strip", z.ZodTypeAny, {
|
|
34
62
|
recommendations: string[];
|
|
35
63
|
dimension: "test-coverage-breadth" | "framework-adoption" | "test-id-hygiene" | "ci-integration" | "auth-test-coverage" | "component-test-ratio";
|
|
36
64
|
score: number;
|
|
37
65
|
weight: number;
|
|
38
66
|
evidence: string[];
|
|
67
|
+
reason?: string | undefined;
|
|
68
|
+
applicability?: "unknown" | "applicable" | "not_applicable" | undefined;
|
|
39
69
|
}, {
|
|
40
70
|
recommendations: string[];
|
|
41
71
|
dimension: "test-coverage-breadth" | "framework-adoption" | "test-id-hygiene" | "ci-integration" | "auth-test-coverage" | "component-test-ratio";
|
|
42
72
|
score: number;
|
|
43
73
|
weight: number;
|
|
44
74
|
evidence: string[];
|
|
75
|
+
reason?: string | undefined;
|
|
76
|
+
applicability?: "unknown" | "applicable" | "not_applicable" | undefined;
|
|
45
77
|
}>, "many">;
|
|
46
78
|
topRecommendations: z.ZodArray<z.ZodString, "many">;
|
|
79
|
+
scoreFormula: z.ZodOptional<z.ZodString>;
|
|
47
80
|
}, "strip", z.ZodTypeAny, {
|
|
48
81
|
label: string;
|
|
49
82
|
level: number;
|
|
@@ -56,8 +89,11 @@ export declare const AutomationMaturitySchema: z.ZodObject<{
|
|
|
56
89
|
score: number;
|
|
57
90
|
weight: number;
|
|
58
91
|
evidence: string[];
|
|
92
|
+
reason?: string | undefined;
|
|
93
|
+
applicability?: "unknown" | "applicable" | "not_applicable" | undefined;
|
|
59
94
|
}[];
|
|
60
95
|
topRecommendations: string[];
|
|
96
|
+
scoreFormula?: string | undefined;
|
|
61
97
|
}, {
|
|
62
98
|
label: string;
|
|
63
99
|
level: number;
|
|
@@ -70,9 +106,13 @@ export declare const AutomationMaturitySchema: z.ZodObject<{
|
|
|
70
106
|
score: number;
|
|
71
107
|
weight: number;
|
|
72
108
|
evidence: string[];
|
|
109
|
+
reason?: string | undefined;
|
|
110
|
+
applicability?: "unknown" | "applicable" | "not_applicable" | undefined;
|
|
73
111
|
}[];
|
|
74
112
|
topRecommendations: string[];
|
|
113
|
+
scoreFormula?: string | undefined;
|
|
75
114
|
}>;
|
|
115
|
+
export type AutomationMaturityApplicability = z.infer<typeof AutomationMaturityApplicabilitySchema>;
|
|
76
116
|
export type AutomationMaturityDimension = z.infer<typeof AutomationMaturityDimensionSchema>;
|
|
77
117
|
export type AutomationMaturity = z.infer<typeof AutomationMaturitySchema>;
|
|
78
118
|
//# sourceMappingURL=automation-maturity.schema.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"automation-maturity.schema.d.ts","sourceRoot":"","sources":["../../src/schemas/automation-maturity.schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,eAAO,MAAM,
|
|
1
|
+
{"version":3,"file":"automation-maturity.schema.d.ts","sourceRoot":"","sources":["../../src/schemas/automation-maturity.schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,eAAO,MAAM,qCAAqC,wDAIhD,CAAC;AAEH;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,iCAAiC;;;;;;;;;;;;;;;;;;;;;;;;EAe5C,CAAC;AAEH,eAAO,MAAM,wBAAwB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EASnC,CAAC;AAEH,MAAM,MAAM,+BAA+B,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,qCAAqC,CAAC,CAAC;AACpG,MAAM,MAAM,2BAA2B,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iCAAiC,CAAC,CAAC;AAC5F,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,wBAAwB,CAAC,CAAC"}
|
|
@@ -1,4 +1,28 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
export const AutomationMaturityApplicabilitySchema = z.enum([
|
|
3
|
+
'applicable',
|
|
4
|
+
'not_applicable',
|
|
5
|
+
'unknown',
|
|
6
|
+
]);
|
|
7
|
+
/**
|
|
8
|
+
* Maturity dimension with explicit applicability so absent capabilities are not silently
|
|
9
|
+
* awarded partial credit.
|
|
10
|
+
*
|
|
11
|
+
* - `applicable` — Qulib has enough signal to compute a real `score`.
|
|
12
|
+
* - `not_applicable` — The capability does not apply to this repo (e.g. component-test-ratio
|
|
13
|
+
* with no Cypress detected, auth-test-coverage when no auth signal exists).
|
|
14
|
+
* `score` is reported but excluded from the overall calculation.
|
|
15
|
+
* - `unknown` — Qulib could not collect enough signal to score honestly (e.g. zero
|
|
16
|
+
* interactive elements scanned for test-id hygiene). Excluded from overall.
|
|
17
|
+
*
|
|
18
|
+
* Overall score formula (in `computeAutomationMaturity`):
|
|
19
|
+
* numerator = Σ score_i * weight_i for i ∈ applicable dimensions
|
|
20
|
+
* denominator = Σ weight_i for i ∈ applicable dimensions
|
|
21
|
+
* overallScore = round(numerator / denominator) when denominator > 0, else 0
|
|
22
|
+
*
|
|
23
|
+
* Schema fields stay backward compatible: both `applicability` and `reason` are optional.
|
|
24
|
+
* Existing consumers that don't read them keep working; honest reports populate them.
|
|
25
|
+
*/
|
|
2
26
|
export const AutomationMaturityDimensionSchema = z.object({
|
|
3
27
|
dimension: z.enum([
|
|
4
28
|
'test-coverage-breadth',
|
|
@@ -12,6 +36,8 @@ export const AutomationMaturityDimensionSchema = z.object({
|
|
|
12
36
|
weight: z.number().min(0).max(1),
|
|
13
37
|
evidence: z.array(z.string()),
|
|
14
38
|
recommendations: z.array(z.string()),
|
|
39
|
+
applicability: AutomationMaturityApplicabilitySchema.optional(),
|
|
40
|
+
reason: z.string().optional(),
|
|
15
41
|
});
|
|
16
42
|
export const AutomationMaturitySchema = z.object({
|
|
17
43
|
computedAt: z.string().datetime(),
|
|
@@ -21,4 +47,5 @@ export const AutomationMaturitySchema = z.object({
|
|
|
21
47
|
label: z.string(),
|
|
22
48
|
dimensions: z.array(AutomationMaturityDimensionSchema),
|
|
23
49
|
topRecommendations: z.array(z.string()),
|
|
50
|
+
scoreFormula: z.string().optional(),
|
|
24
51
|
});
|