@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.
Files changed (42) hide show
  1. package/README.md +22 -0
  2. package/dist/analyze.js +2 -2
  3. package/dist/cli/auth-login-resolve.d.ts +14 -0
  4. package/dist/cli/auth-login-resolve.d.ts.map +1 -0
  5. package/dist/cli/auth-login-resolve.js +68 -0
  6. package/dist/cli/auth-login-run.d.ts +13 -0
  7. package/dist/cli/auth-login-run.d.ts.map +1 -0
  8. package/dist/cli/auth-login-run.js +128 -0
  9. package/dist/cli/index.js +51 -1
  10. package/dist/harness/state-manager.d.ts +10 -0
  11. package/dist/harness/state-manager.d.ts.map +1 -1
  12. package/dist/harness/state-manager.js +15 -0
  13. package/dist/index.d.ts +2 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +2 -1
  16. package/dist/phases/act.js +3 -3
  17. package/dist/phases/observe.js +3 -3
  18. package/dist/schemas/automation-maturity.schema.d.ts +40 -0
  19. package/dist/schemas/automation-maturity.schema.d.ts.map +1 -1
  20. package/dist/schemas/automation-maturity.schema.js +27 -0
  21. package/dist/schemas/config.schema.d.ts +229 -73
  22. package/dist/schemas/config.schema.d.ts.map +1 -1
  23. package/dist/schemas/config.schema.js +19 -18
  24. package/dist/schemas/index.d.ts +1 -1
  25. package/dist/schemas/index.d.ts.map +1 -1
  26. package/dist/schemas/index.js +1 -1
  27. package/dist/schemas/repo-analysis.schema.d.ts +22 -0
  28. package/dist/schemas/repo-analysis.schema.d.ts.map +1 -1
  29. package/dist/schemas/repo-analysis.schema.js +1 -0
  30. package/dist/telemetry/emit.d.ts +22 -0
  31. package/dist/telemetry/emit.d.ts.map +1 -1
  32. package/dist/telemetry/emit.js +37 -0
  33. package/dist/tools/auth-detector.d.ts +18 -0
  34. package/dist/tools/auth-detector.d.ts.map +1 -1
  35. package/dist/tools/auth-detector.js +287 -28
  36. package/dist/tools/auth-surface-analyzer.d.ts.map +1 -1
  37. package/dist/tools/auth-surface-analyzer.js +26 -10
  38. package/dist/tools/automation-maturity.d.ts.map +1 -1
  39. package/dist/tools/automation-maturity.js +76 -20
  40. package/dist/tools/repo-scanner.d.ts.map +1 -1
  41. package/dist/tools/repo-scanner.js +7 -2
  42. 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('0.1.0');
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';
@@ -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;AACrE,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,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"}
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';
@@ -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 = join(process.cwd(), 'output');
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(' Reports: output/report.json and output/report.md');
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 {
@@ -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,iCAAiC;;;;;;;;;;;;;;;;;;EAa5C,CAAC;AAEH,eAAO,MAAM,wBAAwB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAQnC,CAAC;AAEH,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
+ {"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
  });