@qulib/core 0.4.1 → 0.4.3

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 (135) hide show
  1. package/README.md +56 -8
  2. package/dist/analyze.d.ts.map +1 -1
  3. package/dist/analyze.js +86 -7
  4. package/dist/cli/auth-login-resolve.d.ts +14 -0
  5. package/dist/cli/auth-login-resolve.d.ts.map +1 -0
  6. package/dist/cli/auth-login-resolve.js +68 -0
  7. package/dist/cli/auth-login-run.d.ts +13 -0
  8. package/dist/cli/auth-login-run.d.ts.map +1 -0
  9. package/dist/cli/auth-login-run.js +152 -0
  10. package/dist/cli/index.js +60 -7
  11. package/dist/harness/state-manager.d.ts +10 -0
  12. package/dist/harness/state-manager.d.ts.map +1 -1
  13. package/dist/harness/state-manager.js +15 -0
  14. package/dist/index.d.ts +8 -6
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +7 -6
  17. package/dist/phases/act.js +3 -3
  18. package/dist/phases/observe.js +5 -5
  19. package/dist/phases/think.js +1 -1
  20. package/dist/schemas/automation-maturity.schema.d.ts +40 -0
  21. package/dist/schemas/automation-maturity.schema.d.ts.map +1 -1
  22. package/dist/schemas/automation-maturity.schema.js +27 -0
  23. package/dist/schemas/index.d.ts +1 -1
  24. package/dist/schemas/index.d.ts.map +1 -1
  25. package/dist/schemas/index.js +1 -1
  26. package/dist/schemas/repo-analysis.schema.d.ts +22 -0
  27. package/dist/schemas/repo-analysis.schema.d.ts.map +1 -1
  28. package/dist/schemas/repo-analysis.schema.js +1 -0
  29. package/dist/telemetry/emit.d.ts +22 -0
  30. package/dist/telemetry/emit.d.ts.map +1 -1
  31. package/dist/telemetry/emit.js +37 -0
  32. package/dist/telemetry/telemetry.interface.d.ts +1 -1
  33. package/dist/telemetry/telemetry.interface.d.ts.map +1 -1
  34. package/dist/tools/apply-auth.d.ts +4 -0
  35. package/dist/tools/apply-auth.d.ts.map +1 -0
  36. package/dist/tools/apply-auth.js +35 -0
  37. package/dist/tools/auth/apply.d.ts +4 -0
  38. package/dist/tools/auth/apply.d.ts.map +1 -0
  39. package/dist/tools/auth/apply.js +35 -0
  40. package/dist/tools/auth/block-gap.d.ts +9 -0
  41. package/dist/tools/auth/block-gap.d.ts.map +1 -0
  42. package/dist/tools/auth/block-gap.js +52 -0
  43. package/dist/tools/auth/custom-providers.d.ts +15 -0
  44. package/dist/tools/auth/custom-providers.d.ts.map +1 -0
  45. package/dist/tools/auth/custom-providers.js +62 -0
  46. package/dist/tools/auth/detect.d.ts +23 -0
  47. package/dist/tools/auth/detect.d.ts.map +1 -0
  48. package/dist/tools/auth/detect.js +526 -0
  49. package/dist/tools/auth/detector.d.ts +23 -0
  50. package/dist/tools/auth/detector.d.ts.map +1 -0
  51. package/dist/tools/auth/detector.js +526 -0
  52. package/dist/tools/auth/explore.d.ts +4 -0
  53. package/dist/tools/auth/explore.d.ts.map +1 -0
  54. package/dist/tools/auth/explore.js +346 -0
  55. package/dist/tools/auth/explorer.d.ts +4 -0
  56. package/dist/tools/auth/explorer.d.ts.map +1 -0
  57. package/dist/tools/auth/explorer.js +346 -0
  58. package/dist/tools/auth/gaps.d.ts +9 -0
  59. package/dist/tools/auth/gaps.d.ts.map +1 -0
  60. package/dist/tools/auth/gaps.js +52 -0
  61. package/dist/tools/auth/oauth-providers.d.ts +7 -0
  62. package/dist/tools/auth/oauth-providers.d.ts.map +1 -0
  63. package/dist/tools/auth/oauth-providers.js +21 -0
  64. package/dist/tools/auth/providers.d.ts +7 -0
  65. package/dist/tools/auth/providers.d.ts.map +1 -0
  66. package/dist/tools/auth/providers.js +21 -0
  67. package/dist/tools/auth/surface-analyzer.d.ts +4 -0
  68. package/dist/tools/auth/surface-analyzer.d.ts.map +1 -0
  69. package/dist/tools/auth/surface-analyzer.js +170 -0
  70. package/dist/tools/auth/surface.d.ts +4 -0
  71. package/dist/tools/auth/surface.d.ts.map +1 -0
  72. package/dist/tools/auth/surface.js +170 -0
  73. package/dist/tools/auth/user-providers.d.ts +15 -0
  74. package/dist/tools/auth/user-providers.d.ts.map +1 -0
  75. package/dist/tools/auth/user-providers.js +62 -0
  76. package/dist/tools/auth-block-gap.d.ts +6 -0
  77. package/dist/tools/auth-block-gap.d.ts.map +1 -1
  78. package/dist/tools/auth-block-gap.js +42 -9
  79. package/dist/tools/auth-detector.d.ts +19 -0
  80. package/dist/tools/auth-detector.d.ts.map +1 -1
  81. package/dist/tools/auth-detector.js +186 -8
  82. package/dist/tools/automation-maturity.d.ts.map +1 -1
  83. package/dist/tools/automation-maturity.js +76 -20
  84. package/dist/tools/explorers/browser.d.ts +3 -0
  85. package/dist/tools/explorers/browser.d.ts.map +1 -0
  86. package/dist/tools/explorers/browser.js +13 -0
  87. package/dist/tools/explorers/cypress-explorer.d.ts +8 -0
  88. package/dist/tools/explorers/cypress-explorer.d.ts.map +1 -0
  89. package/dist/tools/explorers/cypress-explorer.js +5 -0
  90. package/dist/tools/explorers/cypress.d.ts +8 -0
  91. package/dist/tools/explorers/cypress.d.ts.map +1 -0
  92. package/dist/tools/explorers/cypress.js +5 -0
  93. package/dist/tools/explorers/explorer.interface.d.ts +7 -0
  94. package/dist/tools/explorers/explorer.interface.d.ts.map +1 -0
  95. package/dist/tools/explorers/explorer.interface.js +1 -0
  96. package/dist/tools/explorers/factory.d.ts +4 -0
  97. package/dist/tools/explorers/factory.d.ts.map +1 -0
  98. package/dist/tools/explorers/factory.js +12 -0
  99. package/dist/tools/explorers/playwright-explorer.d.ts +8 -0
  100. package/dist/tools/explorers/playwright-explorer.d.ts.map +1 -0
  101. package/dist/tools/explorers/playwright-explorer.js +172 -0
  102. package/dist/tools/explorers/playwright.d.ts +8 -0
  103. package/dist/tools/explorers/playwright.d.ts.map +1 -0
  104. package/dist/tools/explorers/playwright.js +172 -0
  105. package/dist/tools/explorers/types.d.ts +7 -0
  106. package/dist/tools/explorers/types.d.ts.map +1 -0
  107. package/dist/tools/explorers/types.js +1 -0
  108. package/dist/tools/playwright-explorer.js +1 -1
  109. package/dist/tools/repo/detect-framework.d.ts +15 -0
  110. package/dist/tools/repo/detect-framework.d.ts.map +1 -0
  111. package/dist/tools/repo/detect-framework.js +153 -0
  112. package/dist/tools/repo/framework-detector.d.ts +15 -0
  113. package/dist/tools/repo/framework-detector.d.ts.map +1 -0
  114. package/dist/tools/repo/framework-detector.js +153 -0
  115. package/dist/tools/repo/scan.d.ts +19 -0
  116. package/dist/tools/repo/scan.d.ts.map +1 -0
  117. package/dist/tools/repo/scan.js +181 -0
  118. package/dist/tools/repo/scanner.d.ts +19 -0
  119. package/dist/tools/repo/scanner.d.ts.map +1 -0
  120. package/dist/tools/repo/scanner.js +181 -0
  121. package/dist/tools/repo-scanner.d.ts.map +1 -1
  122. package/dist/tools/repo-scanner.js +7 -2
  123. package/dist/tools/scoring/automation-maturity.d.ts +4 -0
  124. package/dist/tools/scoring/automation-maturity.d.ts.map +1 -0
  125. package/dist/tools/scoring/automation-maturity.js +219 -0
  126. package/dist/tools/scoring/gap-engine.d.ts +8 -0
  127. package/dist/tools/scoring/gap-engine.d.ts.map +1 -0
  128. package/dist/tools/scoring/gap-engine.js +138 -0
  129. package/dist/tools/scoring/gaps.d.ts +8 -0
  130. package/dist/tools/scoring/gaps.d.ts.map +1 -0
  131. package/dist/tools/scoring/gaps.js +138 -0
  132. package/dist/tools/scoring/public-surface.d.ts +5 -0
  133. package/dist/tools/scoring/public-surface.d.ts.map +1 -0
  134. package/dist/tools/scoring/public-surface.js +13 -0
  135. package/package.json +3 -3
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
@@ -55,6 +77,24 @@ qulib analyze --url https://app.example.com --auth-storage-state ./qulib-storage
55
77
 
56
78
  The storage state is just a JSON file of cookies and localStorage — keep it private, treat it like a credential.
57
79
 
80
+ #### Storage state is validated before crawl
81
+
82
+ Qulib now validates the provided storage state before doing any work. If the file is missing, unreadable, empty, on the wrong origin, or carries a session that is already expired, Qulib stops with an honest `blocked` result (no fake `releaseConfidence`) and a structured gap explaining how to recover. The validator reports one of these stable reason codes:
83
+
84
+ | Reason code | Meaning |
85
+ | ------------------------- | ----------------------------------------------------------------------- |
86
+ | `missing-file` | Path passed to `--auth-storage-state` does not exist. |
87
+ | `unreadable-file` | File exists but the process can't read it (permissions). |
88
+ | `invalid-json` | File is present and readable but not valid JSON. |
89
+ | `no-auth-cookies` | File parses, but has zero cookies and zero localStorage entries. |
90
+ | `wrong-origin` | Session redirects to a different origin (host/port/scheme mismatch). |
91
+ | `expired-or-unauthorized` | Loaded session shows the login form again, or the app returns 401/403. |
92
+ | `unknown` | Validation could not be completed for an unexpected reason. |
93
+
94
+ Origin matching is strict — `https://app.example` and `https://www.app.example` are different origins, as are `http://localhost:3000` and `http://localhost:4000`. Re-run `qulib auth login` against the same origin you plan to `analyze`.
95
+
96
+ Relatedly, `qulib auth login` will now refuse to save a storage state if the browser ends the flow on a different origin than `--base-url` (a federated/SSO redirect that never returned to the app). This prevents Qulib from quietly persisting an IdP-domain session that would later produce false-confidence scans.
97
+
58
98
  ### Multi-path auth exploration (`explore-auth`)
59
99
 
60
100
  For unfamiliar apps (especially enterprise SSO with several buttons), run **`qulib explore-auth --url <url>`** before `analyze`. The JSON lists every detected path (built-in OAuth names like Google/Clever, **heuristic** unknown buttons such as tenant-specific SSO labels, password forms, and magic-link copy) plus **`suggestedAgentBehavior`** for the agent.
@@ -172,16 +212,24 @@ TypeScript (strict, NodeNext), Commander, Zod, Playwright, @axe-core/playwright,
172
212
  ```text
173
213
  src/
174
214
  adapters/ # test rendering adapters
175
- analyze.ts # programmatic API (also used by @qulib/mcp)
176
- cli/ # CLI entry
177
- harness/ # state + decision logging
178
- llm/ # LLM contracts
179
- phases/ # observe / think / act
180
- reporters/ # JSON + Markdown reports
181
- schemas/ # Zod schemas
182
- tools/ # explorers, auth, gap engine, repo scanner
215
+ analyze.ts # programmatic API (also used by @qulib/mcp)
216
+ cli/ # CLI entry
217
+ harness/ # state + decision logging
218
+ llm/ # LLM contracts
219
+ phases/ # observe / think / act
220
+ reporters/ # JSON + Markdown reports
221
+ schemas/ # Zod schemas
222
+ telemetry/ # event sink + URL redaction
223
+ tools/
224
+ auth/ # detection, exploration, validation, providers, gap builders
225
+ explorers/ # browser launch, Playwright/Cypress crawlers, factory
226
+ repo/ # repo scanner, framework detection
227
+ scoring/ # gap engine, automation maturity, public surface
228
+ __tests__/ # integration and wiring tests live in __tests__/ in each folder
183
229
  ```
184
230
 
231
+ A contributor map of which folder to touch for each kind of change lives at [`docs/source-map.md`](../../docs/source-map.md).
232
+
185
233
  Repo rules: see [`CLAUDE.md`](../../CLAUDE.md).
186
234
 
187
235
  ## Configuration
@@ -1 +1 @@
1
- {"version":3,"file":"analyze.d.ts","sourceRoot":"","sources":["../src/analyze.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,aAAa,EAAE,KAAK,YAAY,EAAE,MAAM,4BAA4B,CAAC;AACnF,OAAO,KAAK,EAAE,GAAG,EAAE,WAAW,EAAE,MAAM,kCAAkC,CAAC;AACzE,OAAO,EAAwB,KAAK,cAAc,EAAE,MAAM,qCAAqC,CAAC;AAChG,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mCAAmC,CAAC;AACtE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,kCAAkC,CAAC;AACzE,OAAO,EAAuB,KAAK,aAAa,EAAE,MAAM,oCAAoC,CAAC;AAU7F,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,2BAA2B,CAAC;AACrE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,oCAAoC,CAAC;AAGxE,MAAM,MAAM,aAAa,GAAG,UAAU,GAAG,SAAS,GAAG,SAAS,CAAC;AAE/D,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,aAAa,CAAC;IACtB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,WAAW,CAAC,EAAE,mBAAmB,CAAC;IAClC,SAAS,CAAC,EAAE,aAAa,CAAC;CAC3B;AAED,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,aAAa,CAAC;IACtB,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,0GAA0G;IAC1G,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,sFAAsF;IACtF,IAAI,EAAE,GAAG,EAAE,CAAC;IACZ,WAAW,EAAE,WAAW,CAAC;IACzB,8GAA8G;IAC9G,cAAc,EAAE,cAAc,CAAC;IAC/B,aAAa,EAAE,YAAY,GAAG,IAAI,CAAC;IACnC,WAAW,EAAE,gBAAgB,EAAE,CAAC;IAChC,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,0HAA0H;IAC1H,aAAa,EAAE,aAAa,GAAG,IAAI,CAAC;CACrC;AAcD,wBAAsB,UAAU,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,aAAa,CAAC,CA8JhF"}
1
+ {"version":3,"file":"analyze.d.ts","sourceRoot":"","sources":["../src/analyze.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,aAAa,EAAE,KAAK,YAAY,EAAE,MAAM,4BAA4B,CAAC;AACnF,OAAO,KAAK,EAAE,GAAG,EAAE,WAAW,EAAE,MAAM,kCAAkC,CAAC;AACzE,OAAO,EAAwB,KAAK,cAAc,EAAE,MAAM,qCAAqC,CAAC;AAChG,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mCAAmC,CAAC;AACtE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,kCAAkC,CAAC;AACzE,OAAO,EAAuB,KAAK,aAAa,EAAE,MAAM,oCAAoC,CAAC;AAU7F,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,2BAA2B,CAAC;AACrE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,oCAAoC,CAAC;AAGxE,MAAM,MAAM,aAAa,GAAG,UAAU,GAAG,SAAS,GAAG,SAAS,CAAC;AAE/D,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,aAAa,CAAC;IACtB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,WAAW,CAAC,EAAE,mBAAmB,CAAC;IAClC,SAAS,CAAC,EAAE,aAAa,CAAC;CAC3B;AAED,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,aAAa,CAAC;IACtB,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,0GAA0G;IAC1G,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,sFAAsF;IACtF,IAAI,EAAE,GAAG,EAAE,CAAC;IACZ,WAAW,EAAE,WAAW,CAAC;IACzB,8GAA8G;IAC9G,cAAc,EAAE,cAAc,CAAC;IAC/B,aAAa,EAAE,YAAY,GAAG,IAAI,CAAC;IACnC,WAAW,EAAE,gBAAgB,EAAE,CAAC;IAChC,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,0HAA0H;IAC1H,aAAa,EAAE,aAAa,GAAG,IAAI,CAAC;CACrC;AAcD,wBAAsB,UAAU,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,aAAa,CAAC,CAqQhF"}
package/dist/analyze.js CHANGED
@@ -4,13 +4,13 @@ import { PublicSurfaceSchema } from './schemas/public-surface.schema.js';
4
4
  import { observe } from './phases/observe.js';
5
5
  import { think } from './phases/think.js';
6
6
  import { act } from './phases/act.js';
7
- import { detectAuth } from './tools/auth-detector.js';
8
- import { analyzeGaps, computeCoverageScore, computeQualityScoreFromGaps } from './tools/gap-engine.js';
9
- import { analyzeAuthSurfaceGaps } from './tools/auth-surface-analyzer.js';
10
- import { buildPublicSurface } from './tools/public-surface.js';
11
- import { buildAuthBlockGap } from './tools/auth-block-gap.js';
7
+ import { detectAuth, validateStorageState } from './tools/auth/detect.js';
8
+ import { analyzeGaps, computeCoverageScore, computeQualityScoreFromGaps } from './tools/scoring/gaps.js';
9
+ import { analyzeAuthSurfaceGaps } from './tools/auth/surface.js';
10
+ import { buildPublicSurface } from './tools/scoring/public-surface.js';
11
+ import { buildAuthBlockGap, buildStorageStateInvalidGap } from './tools/auth/gaps.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,11 +35,90 @@ 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
  });
42
42
  progress?.info(`Starting scan → ${options.url} maxPagesToScan=${options.config.maxPagesToScan}`);
43
+ if (options.config.auth?.type === 'storage-state') {
44
+ progress?.info('Validating provided storage state before crawl…');
45
+ const validation = await validateStorageState(options.url, options.config.auth.path, options.config.timeoutMs);
46
+ let targetOriginForTelemetry;
47
+ try {
48
+ targetOriginForTelemetry = new URL(options.url).origin;
49
+ }
50
+ catch {
51
+ targetOriginForTelemetry = '[unparseable-target-url]';
52
+ }
53
+ emitTelemetry(options.telemetry, 'auth.storage-state.validated', sessionId, {
54
+ targetOrigin: targetOriginForTelemetry,
55
+ valid: validation.valid,
56
+ reasonCode: validation.reasonCode,
57
+ storageStateProvided: true,
58
+ });
59
+ if (!validation.valid) {
60
+ progress?.warn(`Storage state rejected (${validation.reasonCode}): ${validation.reason}. Skipping crawl.`);
61
+ decisionLog.push({
62
+ timestamp: new Date().toISOString(),
63
+ phase: 'observe',
64
+ decision: 'storage-state-invalid',
65
+ reason: `${validation.reasonCode}: ${validation.reason}`,
66
+ metadata: {
67
+ reasonCode: validation.reasonCode,
68
+ targetOrigin: targetOriginForTelemetry,
69
+ },
70
+ });
71
+ const invalidGap = buildStorageStateInvalidGap({
72
+ url: options.url,
73
+ reasonCode: validation.reasonCode === 'ok' ? 'unknown' : validation.reasonCode,
74
+ reason: validation.reason,
75
+ });
76
+ const draft = {
77
+ analyzedAt: new Date().toISOString(),
78
+ mode: 'auth-required',
79
+ releaseConfidence: 0,
80
+ coveragePagesScanned: 0,
81
+ coverageBudgetExceeded: false,
82
+ coverageWarning: 'auth-required',
83
+ gaps: [invalidGap],
84
+ };
85
+ const costContext = {
86
+ mode: 'auth-required',
87
+ coveragePagesScanned: 0,
88
+ releaseConfidence: 0,
89
+ gaps: [invalidGap],
90
+ };
91
+ const gapAnalysis = await finalizeGapAnalysisFromDraft(draft, options.config, artifacts, costContext);
92
+ const emptyAuthRoutes = RouteInventorySchema.parse({
93
+ scannedAt: new Date().toISOString(),
94
+ baseUrl: options.url,
95
+ routes: [],
96
+ pagesSkipped: 0,
97
+ budgetExceeded: false,
98
+ });
99
+ await act(gapAnalysis, options.config, artifacts);
100
+ const blockedResult = {
101
+ status: 'blocked',
102
+ coverageScore: null,
103
+ releaseConfidence: 0,
104
+ gaps: gapAnalysis.gaps,
105
+ gapAnalysis,
106
+ routeInventory: emptyAuthRoutes,
107
+ repoInventory: null,
108
+ decisionLog,
109
+ publicSurface: null,
110
+ };
111
+ logScanEnd(progress, blockedResult);
112
+ emitTelemetry(options.telemetry, 'scan.blocked', sessionId, {
113
+ status: blockedResult.status,
114
+ coverageScore: blockedResult.coverageScore,
115
+ releaseConfidence: blockedResult.releaseConfidence,
116
+ gapCount: blockedResult.gaps.length,
117
+ reasonCode: validation.reasonCode,
118
+ });
119
+ return blockedResult;
120
+ }
121
+ }
43
122
  let detectedAuth;
44
123
  let authWall = false;
45
124
  if (!options.config.auth && !options.skipAuthDetection) {
@@ -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;AAsB5D,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,CAoIhB"}
@@ -0,0 +1,152 @@
1
+ import { BUILT_IN_OAUTH_PROVIDERS } from '../tools/auth/providers.js';
2
+ import { waitForReturnToOrigin } from '../tools/auth/detect.js';
3
+ const builtInOAuthIds = new Set(BUILT_IN_OAUTH_PROVIDERS.map((p) => p.id));
4
+ function escapeRegExp(s) {
5
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
6
+ }
7
+ async function waitNetworkIdleBestEffort(page) {
8
+ try {
9
+ await page.waitForLoadState('networkidle', { timeout: 5000 });
10
+ }
11
+ catch {
12
+ /* best-effort */
13
+ }
14
+ }
15
+ function sleep(ms) {
16
+ return new Promise((r) => setTimeout(r, ms));
17
+ }
18
+ export function authPathNeedsClickReveal(path) {
19
+ return path.type === 'form-login' && path.source === 'heuristic' && !builtInOAuthIds.has(path.id);
20
+ }
21
+ export async function runAutomatedAuthLogin(params) {
22
+ const { chromium } = await import('@playwright/test');
23
+ const browser = await chromium.launch({ headless: !params.headed });
24
+ const context = await browser.newContext();
25
+ const page = await context.newPage();
26
+ let confirmed = false;
27
+ try {
28
+ await page.goto(params.loginUrl, { waitUntil: 'domcontentloaded', timeout: params.timeoutMs });
29
+ await waitNetworkIdleBestEffort(page);
30
+ if (authPathNeedsClickReveal(params.path)) {
31
+ try {
32
+ await page.getByRole('button', { name: params.path.label, exact: true }).first().click({ timeout: 2000 });
33
+ }
34
+ catch {
35
+ await page
36
+ .locator('button')
37
+ .filter({ hasText: new RegExp(`^\\s*${escapeRegExp(params.path.label)}\\s*$`, 'i') })
38
+ .first()
39
+ .click({ timeout: 2000 });
40
+ }
41
+ await page.locator('input[type="password"]').first().waitFor({ state: 'visible', timeout: 2000 });
42
+ }
43
+ if (params.path.requirements.method !== 'credentials') {
44
+ throw new Error('Internal error: expected credentials method on form-login path.');
45
+ }
46
+ for (const field of params.path.requirements.fields) {
47
+ const val = params.credentials[field.name];
48
+ const nameJson = JSON.stringify(field.name);
49
+ const inputByName = `input[name=${nameJson}]`;
50
+ const selectByName = `select[name=${nameJson}]`;
51
+ try {
52
+ if (field.type === 'select') {
53
+ const sel = page.locator(selectByName).first();
54
+ try {
55
+ await sel.selectOption(val, { timeout: 8000 });
56
+ }
57
+ catch {
58
+ await sel.selectOption({ label: val }, { timeout: 8000 });
59
+ }
60
+ }
61
+ else if (field.type === 'checkbox') {
62
+ const loc = page.locator(`input[type="checkbox"][name=${nameJson}]`).first();
63
+ if (val === 'true' || val === '1' || val === 'on' || val === 'yes') {
64
+ await loc.check({ timeout: 8000 });
65
+ }
66
+ else {
67
+ await loc.uncheck({ timeout: 8000 });
68
+ }
69
+ }
70
+ else {
71
+ await page.locator(inputByName).first().fill(val, { timeout: 8000 });
72
+ }
73
+ }
74
+ catch (e) {
75
+ const msg = e instanceof Error ? e.message : String(e);
76
+ throw new Error(`Failed to fill field "${field.name}" (${field.label}): ${msg}`);
77
+ }
78
+ }
79
+ const preSubmit = page.url();
80
+ try {
81
+ await page.locator('button[type="submit"]').first().click({ timeout: 8000 });
82
+ }
83
+ catch {
84
+ await page.locator('input[type="password"]').first().press('Enter');
85
+ }
86
+ if (params.successUrlContains && params.successUrlContains.trim().length > 0) {
87
+ const frag = params.successUrlContains.trim();
88
+ try {
89
+ await page.waitForURL((u) => u.toString().includes(frag), { timeout: params.timeoutMs });
90
+ confirmed = true;
91
+ }
92
+ catch {
93
+ confirmed = false;
94
+ }
95
+ }
96
+ else {
97
+ const t0 = Date.now();
98
+ while (Date.now() - t0 < params.timeoutMs) {
99
+ if (page.url() !== preSubmit) {
100
+ confirmed = true;
101
+ break;
102
+ }
103
+ if (Date.now() - t0 >= 5000) {
104
+ const vis = await page.locator('input[type="password"]:visible').count();
105
+ if (vis === 0) {
106
+ confirmed = true;
107
+ break;
108
+ }
109
+ }
110
+ await sleep(250);
111
+ }
112
+ }
113
+ const originReturn = await waitForReturnToOrigin(page, params.baseUrlHint, params.timeoutMs);
114
+ if (!originReturn.returned) {
115
+ let targetOrigin = '<unknown>';
116
+ let finalOrigin = '<unknown>';
117
+ try {
118
+ targetOrigin = new URL(params.baseUrlHint).origin;
119
+ }
120
+ catch {
121
+ /* targetOrigin stays <unknown> */
122
+ }
123
+ try {
124
+ finalOrigin = new URL(originReturn.finalUrl).origin;
125
+ }
126
+ catch {
127
+ /* finalOrigin stays <unknown> */
128
+ }
129
+ throw new Error(`Login flow did not return to the app origin (expected ${targetOrigin}, final ${finalOrigin}). ` +
130
+ `Refusing to save the storage state — it would belong to the wrong domain and produce ` +
131
+ `false-confidence scans. Retry the login (the federated provider may need a redirect tweak) ` +
132
+ `or capture the session manually with \`qulib auth init --base-url ${params.baseUrlHint}\`.`);
133
+ }
134
+ if (!confirmed) {
135
+ console.error('[qulib] Could not confirm login success heuristically, but the browser ended on the app origin. ' +
136
+ 'Storage state will be saved; verify the session before relying on it (run `qulib analyze` ' +
137
+ 'and check that releaseConfidence is not null).');
138
+ }
139
+ const fs = await import('node:fs/promises');
140
+ const pathMod = await import('node:path');
141
+ const outAbs = pathMod.resolve(params.outPath);
142
+ await fs.mkdir(pathMod.dirname(outAbs), { recursive: true });
143
+ await context.storageState({ path: outAbs });
144
+ console.log(`\n[qulib] Saved storage state to ${outAbs}`);
145
+ console.log('[qulib] To use it, pass to qulib like:');
146
+ console.log(` qulib analyze --url ${params.baseUrlHint} --auth-storage-state ${outAbs}`);
147
+ console.log(`[qulib] Or in MCP, pass auth: { type: 'storage-state', path: '${outAbs}' }`);
148
+ }
149
+ finally {
150
+ await browser.close();
151
+ }
152
+ }
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
- import { detectAuth } from '../tools/auth-detector.js';
9
- import { exploreAuth } from '../tools/auth-explorer.js';
11
+ import { detectAuth } from '../tools/auth/detect.js';
12
+ import { exploreAuth } from '../tools/auth/explore.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({
@@ -28,11 +33,14 @@ function redactConfigForLog(config) {
28
33
  base.auth = {
29
34
  ...config.auth,
30
35
  credentials: {
31
- username: config.auth.credentials.username,
36
+ username: '***',
32
37
  password: '***',
33
38
  },
34
39
  };
35
40
  }
41
+ if (config.auth?.type === 'storage-state') {
42
+ base.auth = { type: 'storage-state', path: '<provided>' };
43
+ }
36
44
  return base;
37
45
  }
38
46
  function mergeAuthFromCli(config, options) {
@@ -109,7 +117,7 @@ async function runAnalyze(options) {
109
117
  program
110
118
  .name('qulib')
111
119
  .description('Qulib — QA harness')
112
- .version('0.1.0');
120
+ .version(pkg.version);
113
121
  program
114
122
  .command('clean')
115
123
  .description('Remove all generated reports and scan state')
@@ -207,7 +215,7 @@ providersCmd
207
215
  .command('list')
208
216
  .description('List user-local providers registered on this machine')
209
217
  .action(async () => {
210
- const { listUserProviders } = await import('../tools/user-providers.js');
218
+ const { listUserProviders } = await import('../tools/auth/custom-providers.js');
211
219
  const providers = listUserProviders();
212
220
  console.log(JSON.stringify(providers, null, 2));
213
221
  });
@@ -224,7 +232,7 @@ providersCmd
224
232
  catch {
225
233
  throw new Error(`Invalid regex pattern: ${opts.pattern}`);
226
234
  }
227
- const { addUserProvider } = await import('../tools/user-providers.js');
235
+ const { addUserProvider } = await import('../tools/auth/custom-providers.js');
228
236
  addUserProvider({ id: opts.id, label: opts.label, pattern: opts.pattern });
229
237
  console.log(`[qulib] Added provider "${opts.label}" (id: ${opts.id}) to ~/.qulib/providers.json`);
230
238
  });
@@ -233,7 +241,7 @@ providersCmd
233
241
  .description('Remove a user-local provider by id')
234
242
  .requiredOption('--id <id>', 'Provider id to remove')
235
243
  .action(async (opts) => {
236
- const { removeUserProvider } = await import('../tools/user-providers.js');
244
+ const { removeUserProvider } = await import('../tools/auth/custom-providers.js');
237
245
  const removed = removeUserProvider(opts.id);
238
246
  console.log(removed ? `[qulib] Removed "${opts.id}"` : `[qulib] No provider with id "${opts.id}" found`);
239
247
  });
@@ -265,6 +273,7 @@ authCmd
265
273
  const fs = await import('node:fs/promises');
266
274
  const pathMod = await import('node:path');
267
275
  const outPath = pathMod.resolve(options.out);
276
+ await fs.mkdir(pathMod.dirname(outPath), { recursive: true });
268
277
  await context.storageState({ path: outPath });
269
278
  console.log(`\n[qulib] Saved storage state to ${outPath}`);
270
279
  console.log('[qulib] To use it, pass to qulib like:');
@@ -273,6 +282,50 @@ authCmd
273
282
  await browser.close();
274
283
  process.exit(0);
275
284
  });
285
+ authCmd
286
+ .command('login')
287
+ .description('Detect form-login on the URL, fill credentials, and save the storage state automatically (uses selectors from detect-auth)')
288
+ .requiredOption('--base-url <url>', 'The base URL of the app to log into')
289
+ .option('--auth-path <id>', 'Specific authOption id to use (e.g. "scholastic-sync") when multiple form-login paths exist')
290
+ .option('--credentials <json>', 'JSON object mapping field name → value, e.g. \'{"username":"a","password":"b","hidden.datasource":"NYC"}\'')
291
+ .option('--credentials-file <path>', 'Path to a JSON file with the credentials object (keeps secrets out of shell history)')
292
+ .option('--out <path>', 'Output file path for the storage state JSON', './qulib-storage-state.json')
293
+ .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.')
294
+ .option('--timeout <ms>', 'Max time in ms to wait for navigation / success heuristics', '30000')
295
+ .option('--headed', 'Run Chromium headed for debugging', false)
296
+ .action(async (options) => {
297
+ assertExactlyOneCredentialSource(options.credentials, options.credentialsFile);
298
+ const fs = await import('node:fs/promises');
299
+ let credentials;
300
+ if (options.credentialsFile && options.credentialsFile.trim()) {
301
+ const p = resolve(options.credentialsFile.trim());
302
+ const raw = await fs.readFile(p, 'utf8');
303
+ credentials = parseCredentialsJsonString(raw);
304
+ }
305
+ else {
306
+ credentials = parseCredentialsJsonString(options.credentials.trim());
307
+ }
308
+ const timeoutMs = parseInt(options.timeout, 10);
309
+ const detection = await detectAuth(options.baseUrl, timeoutMs);
310
+ const { path } = resolveAuthLoginConfig({
311
+ baseUrl: options.baseUrl,
312
+ authOptions: detection.authOptions,
313
+ credentials,
314
+ authPathId: options.authPath,
315
+ });
316
+ const loginUrl = detection.loginUrl ?? options.baseUrl;
317
+ await runAutomatedAuthLogin({
318
+ loginUrl,
319
+ path,
320
+ credentials,
321
+ outPath: options.out,
322
+ headed: Boolean(options.headed),
323
+ timeoutMs,
324
+ successUrlContains: options.successUrlContains,
325
+ baseUrlHint: options.baseUrl,
326
+ });
327
+ process.exit(0);
328
+ });
276
329
  program.parseAsync().catch((error) => {
277
330
  const message = error instanceof Error ? error.message : String(error);
278
331
  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) {