@qulib/core 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/README.md +30 -5
  2. package/bin/qulib.js +2 -3
  3. package/dist/__tests__/fixtures/api-fixture-repo/app/api/orders/route.d.ts +7 -0
  4. package/dist/__tests__/fixtures/api-fixture-repo/app/api/orders/route.d.ts.map +1 -0
  5. package/dist/__tests__/fixtures/api-fixture-repo/app/api/orders/route.js +7 -0
  6. package/dist/__tests__/fixtures/api-fixture-repo/app/api/users/route.d.ts +10 -0
  7. package/dist/__tests__/fixtures/api-fixture-repo/app/api/users/route.d.ts.map +1 -0
  8. package/dist/__tests__/fixtures/api-fixture-repo/app/api/users/route.js +9 -0
  9. package/dist/__tests__/fixtures/api-fixture-repo/pages/api/health.d.ts +9 -0
  10. package/dist/__tests__/fixtures/api-fixture-repo/pages/api/health.d.ts.map +1 -0
  11. package/dist/__tests__/fixtures/api-fixture-repo/pages/api/health.js +10 -0
  12. package/dist/__tests__/playwright-available.d.ts +32 -0
  13. package/dist/__tests__/playwright-available.d.ts.map +1 -0
  14. package/dist/__tests__/playwright-available.js +35 -0
  15. package/dist/adapters/api-adapter.d.ts +26 -0
  16. package/dist/adapters/api-adapter.d.ts.map +1 -1
  17. package/dist/adapters/api-adapter.js +156 -2
  18. package/dist/adapters/ci-results-adapter.d.ts +67 -0
  19. package/dist/adapters/ci-results-adapter.d.ts.map +1 -0
  20. package/dist/adapters/ci-results-adapter.js +143 -0
  21. package/dist/adapters/cypress-e2e-adapter.d.ts.map +1 -1
  22. package/dist/adapters/cypress-e2e-adapter.js +25 -2
  23. package/dist/adapters/playwright-adapter.d.ts.map +1 -1
  24. package/dist/adapters/playwright-adapter.js +94 -2
  25. package/dist/adapters/pr-metadata-adapter.d.ts +75 -0
  26. package/dist/adapters/pr-metadata-adapter.d.ts.map +1 -0
  27. package/dist/adapters/pr-metadata-adapter.js +146 -0
  28. package/dist/adapters/validate-specs.d.ts +55 -0
  29. package/dist/adapters/validate-specs.d.ts.map +1 -0
  30. package/dist/adapters/validate-specs.js +67 -0
  31. package/dist/baseline/baseline.d.ts +54 -0
  32. package/dist/baseline/baseline.d.ts.map +1 -0
  33. package/dist/baseline/baseline.js +252 -0
  34. package/dist/baseline/baseline.schema.d.ts +233 -0
  35. package/dist/baseline/baseline.schema.d.ts.map +1 -0
  36. package/dist/baseline/baseline.schema.js +59 -0
  37. package/dist/cli/confidence-run.d.ts +16 -0
  38. package/dist/cli/confidence-run.d.ts.map +1 -0
  39. package/dist/cli/confidence-run.js +158 -0
  40. package/dist/cli/index.d.ts +11 -1
  41. package/dist/cli/index.d.ts.map +1 -1
  42. package/dist/cli/index.js +80 -4
  43. package/dist/cli/scaffold-run.d.ts +86 -0
  44. package/dist/cli/scaffold-run.d.ts.map +1 -0
  45. package/dist/cli/scaffold-run.js +232 -0
  46. package/dist/cli/score-automation-run.d.ts +25 -0
  47. package/dist/cli/score-automation-run.d.ts.map +1 -0
  48. package/dist/cli/score-automation-run.js +123 -0
  49. package/dist/examples/notquality-dogfood/fixture.d.ts +166 -0
  50. package/dist/examples/notquality-dogfood/fixture.d.ts.map +1 -0
  51. package/dist/examples/notquality-dogfood/fixture.js +174 -0
  52. package/dist/examples/notquality-dogfood/run.d.ts +34 -0
  53. package/dist/examples/notquality-dogfood/run.d.ts.map +1 -0
  54. package/dist/examples/notquality-dogfood/run.js +139 -0
  55. package/dist/index.d.ts +18 -1
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +13 -0
  58. package/dist/recipes/a11y.d.ts +36 -0
  59. package/dist/recipes/a11y.d.ts.map +1 -0
  60. package/dist/recipes/a11y.js +118 -0
  61. package/dist/recipes/auth.d.ts +38 -0
  62. package/dist/recipes/auth.d.ts.map +1 -0
  63. package/dist/recipes/auth.js +156 -0
  64. package/dist/recipes/index.d.ts +26 -0
  65. package/dist/recipes/index.d.ts.map +1 -0
  66. package/dist/recipes/index.js +41 -0
  67. package/dist/recipes/nav.d.ts +34 -0
  68. package/dist/recipes/nav.d.ts.map +1 -0
  69. package/dist/recipes/nav.js +128 -0
  70. package/dist/recipes/seed.d.ts +34 -0
  71. package/dist/recipes/seed.d.ts.map +1 -0
  72. package/dist/recipes/seed.js +87 -0
  73. package/dist/scaffold-tests.d.ts +21 -0
  74. package/dist/scaffold-tests.d.ts.map +1 -1
  75. package/dist/scaffold-tests.js +12 -2
  76. package/dist/schemas/automation-maturity.schema.d.ts +8 -8
  77. package/dist/schemas/automation-maturity.schema.d.ts.map +1 -1
  78. package/dist/schemas/automation-maturity.schema.js +1 -0
  79. package/dist/schemas/confidence.schema.d.ts +526 -0
  80. package/dist/schemas/confidence.schema.d.ts.map +1 -0
  81. package/dist/schemas/confidence.schema.js +161 -0
  82. package/dist/schemas/gap-analysis.schema.d.ts +8 -8
  83. package/dist/schemas/gap-analysis.schema.js +1 -1
  84. package/dist/schemas/index.d.ts +3 -0
  85. package/dist/schemas/index.d.ts.map +1 -1
  86. package/dist/schemas/index.js +3 -0
  87. package/dist/schemas/public-surface.schema.d.ts +5 -5
  88. package/dist/schemas/recipe.schema.d.ts +66 -0
  89. package/dist/schemas/recipe.schema.d.ts.map +1 -0
  90. package/dist/schemas/recipe.schema.js +45 -0
  91. package/dist/schemas/repo-analysis.schema.d.ts +7 -7
  92. package/dist/schemas/views.schema.d.ts +234 -0
  93. package/dist/schemas/views.schema.d.ts.map +1 -0
  94. package/dist/schemas/views.schema.js +82 -0
  95. package/dist/tools/repo/api-surface.d.ts +59 -0
  96. package/dist/tools/repo/api-surface.d.ts.map +1 -0
  97. package/dist/tools/repo/api-surface.js +414 -0
  98. package/dist/tools/scoring/api-coverage.d.ts +74 -0
  99. package/dist/tools/scoring/api-coverage.d.ts.map +1 -0
  100. package/dist/tools/scoring/api-coverage.js +158 -0
  101. package/dist/tools/scoring/automation-maturity.d.ts +11 -1
  102. package/dist/tools/scoring/automation-maturity.d.ts.map +1 -1
  103. package/dist/tools/scoring/automation-maturity.js +43 -9
  104. package/dist/tools/scoring/confidence-from-qulib.d.ts +34 -0
  105. package/dist/tools/scoring/confidence-from-qulib.d.ts.map +1 -0
  106. package/dist/tools/scoring/confidence-from-qulib.js +206 -0
  107. package/dist/tools/scoring/confidence-views.d.ts +40 -0
  108. package/dist/tools/scoring/confidence-views.d.ts.map +1 -0
  109. package/dist/tools/scoring/confidence-views.js +163 -0
  110. package/dist/tools/scoring/confidence.d.ts +32 -0
  111. package/dist/tools/scoring/confidence.d.ts.map +1 -0
  112. package/dist/tools/scoring/confidence.js +180 -0
  113. package/dist/tools/scoring/levels.d.ts +15 -0
  114. package/dist/tools/scoring/levels.d.ts.map +1 -0
  115. package/dist/tools/scoring/levels.js +21 -0
  116. package/package.json +15 -7
@@ -0,0 +1,158 @@
1
+ /**
2
+ * `qulib confidence` — compute a fused Release Confidence verdict from qulib's own collectors.
3
+ *
4
+ * P3 — qulib Confidence Layer v1.
5
+ *
6
+ * Composes analyze_app (when --url is given) + computeAutomationMaturity + computeApiCoverage
7
+ * (when --repo is given) through buildConfidenceInputFromQulib → computeReleaseConfidence.
8
+ *
9
+ * The --json flag emits the full ReleaseConfidence object as JSON to stdout (for CI gates and
10
+ * orchestrators). Without --json, a human-readable report is printed.
11
+ *
12
+ * Mirrors the idiom established by score-automation-run.ts: one file owns the command end-to-end
13
+ * and is registered from cli/index.ts via registerConfidenceCommand(program).
14
+ */
15
+ import { resolve } from 'node:path';
16
+ import { existsSync, statSync } from 'node:fs';
17
+ import { analyzeApp } from '../analyze.js';
18
+ import { scanRepo } from '../tools/repo/scan.js';
19
+ import { computeAutomationMaturity } from '../tools/scoring/automation-maturity.js';
20
+ import { discoverApiSurfaceWithRepo } from '../tools/repo/api-surface.js';
21
+ import { computeApiCoverage } from '../tools/scoring/api-coverage.js';
22
+ import { buildConfidenceInputFromQulib } from '../tools/scoring/confidence-from-qulib.js';
23
+ import { computeReleaseConfidence } from '../tools/scoring/confidence.js';
24
+ /**
25
+ * Resolve and validate an optional --repo path. Returns null if none was provided.
26
+ */
27
+ function maybeResolveRepoPath(repoOption, cwd = process.cwd()) {
28
+ if (!repoOption || !repoOption.trim())
29
+ return null;
30
+ const abs = resolve(cwd, repoOption.trim());
31
+ if (!existsSync(abs)) {
32
+ throw new Error(`--repo path does not exist: ${abs}`);
33
+ }
34
+ if (!statSync(abs).isDirectory()) {
35
+ throw new Error(`--repo path is not a directory: ${abs}`);
36
+ }
37
+ return abs;
38
+ }
39
+ /** Render the human-friendly report for a ReleaseConfidence result. */
40
+ export function formatConfidenceReport(rc, subjectRef) {
41
+ const lines = [];
42
+ const scoreStr = rc.confidenceScore !== null ? `${rc.confidenceScore}/100` : 'null (nothing evaluable)';
43
+ lines.push(`[qulib] Release confidence for ${subjectRef}`);
44
+ lines.push(` verdict: ${rc.verdict} — ${rc.label} (score ${scoreStr}, level ${rc.level}/5)`);
45
+ if (rc.blockers.length > 0) {
46
+ lines.push(' blockers:');
47
+ for (const b of rc.blockers)
48
+ lines.push(` • ${b}`);
49
+ }
50
+ lines.push(' contributions:');
51
+ for (const c of rc.contributions) {
52
+ const scoreLabel = c.score !== null ? `${c.score}/100` : 'n/a';
53
+ const ewLabel = c.effectiveWeight > 0
54
+ ? `ew=${(c.effectiveWeight * 100).toFixed(1)}%`
55
+ : 'excluded';
56
+ const blockingTag = c.blocking ? ' [BLOCKER]' : '';
57
+ lines.push(` - ${c.source} [${c.applicability}]${blockingTag}: ${scoreLabel} ${ewLabel}`);
58
+ }
59
+ if (rc.topRisks.length > 0) {
60
+ lines.push(' top risks:');
61
+ for (const r of rc.topRisks)
62
+ lines.push(` • ${r}`);
63
+ }
64
+ if (rc.recommendedNextChecks.length > 0) {
65
+ lines.push(' recommended next checks:');
66
+ for (const r of rc.recommendedNextChecks)
67
+ lines.push(` • ${r}`);
68
+ }
69
+ if (rc.honestyNotes.length > 0) {
70
+ lines.push(' honesty notes:');
71
+ for (const n of rc.honestyNotes)
72
+ lines.push(` • ${n}`);
73
+ }
74
+ return lines.join('\n');
75
+ }
76
+ /**
77
+ * Core of the command, factored out of the action handler so node:test can drive it
78
+ * without spawning a subprocess.
79
+ */
80
+ export async function runConfidence(options, out = (line) => console.log(line)) {
81
+ if (!options.url && !options.repo) {
82
+ throw new Error('qulib confidence requires at least one of --url or --repo.');
83
+ }
84
+ const repoPath = maybeResolveRepoPath(options.repo);
85
+ const subjectRef = options.url ?? repoPath ?? 'unknown';
86
+ const subjectKind = options.url && repoPath ? 'release' : options.url ? 'app' : 'repo';
87
+ const subject = { kind: subjectKind, ref: subjectRef, tenantId: 'default' };
88
+ let analyzeResult;
89
+ let maturityResult;
90
+ let apiCoverageResult;
91
+ if (options.url) {
92
+ if (!options.json) {
93
+ out(`[qulib] Analyzing ${options.url} (analyze_app)…`);
94
+ }
95
+ const harnessConfig = {
96
+ maxPagesToScan: 10,
97
+ maxDepth: 3,
98
+ minPagesForConfidence: 3,
99
+ timeoutMs: 30000,
100
+ retryCount: 0,
101
+ llmTokenBudget: 4096,
102
+ testGenerationLimit: 5,
103
+ enableLlmScenarios: false,
104
+ readOnlyMode: true,
105
+ requireHumanReview: false,
106
+ failOnConsoleError: false,
107
+ explorer: 'playwright',
108
+ defaultAdapter: 'playwright',
109
+ adapters: ['playwright'],
110
+ };
111
+ analyzeResult = await analyzeApp({
112
+ url: options.url,
113
+ writeArtifacts: false,
114
+ config: harnessConfig,
115
+ });
116
+ }
117
+ if (repoPath) {
118
+ if (!options.json) {
119
+ out(`[qulib] Scoring automation maturity + API coverage for ${repoPath}…`);
120
+ }
121
+ const repo = await scanRepo(repoPath);
122
+ maturityResult = computeAutomationMaturity(repo);
123
+ const apiSurface = await discoverApiSurfaceWithRepo(repoPath, repo, { enableTier3: false });
124
+ apiCoverageResult = computeApiCoverage(repo, apiSurface);
125
+ }
126
+ const confidenceInput = buildConfidenceInputFromQulib({
127
+ analyze: analyzeResult,
128
+ maturity: maturityResult,
129
+ apiCoverage: apiCoverageResult,
130
+ subject,
131
+ });
132
+ const rc = computeReleaseConfidence(confidenceInput);
133
+ if (options.json) {
134
+ out(JSON.stringify(rc, null, 2));
135
+ }
136
+ else {
137
+ out(formatConfidenceReport(rc, subjectRef));
138
+ }
139
+ return rc;
140
+ }
141
+ export function registerConfidenceCommand(program) {
142
+ program
143
+ .command('confidence')
144
+ .description('Compute a fused Release Confidence verdict from qulib evidence collectors. ' +
145
+ 'Pass --url to include live-app quality + a11y + coverage evidence. ' +
146
+ 'Pass --repo to include test-automation maturity + API coverage. ' +
147
+ 'Both may be combined.')
148
+ .option('--url <url>', 'URL of the deployed app to analyze')
149
+ .option('--repo <path>', 'Path to the local repository to score')
150
+ .option('--json', 'Emit the full ReleaseConfidence object as JSON to stdout', false)
151
+ .action(async (options) => {
152
+ await runConfidence({
153
+ url: options.url,
154
+ repo: options.repo,
155
+ json: Boolean(options.json),
156
+ });
157
+ });
158
+ }
@@ -1,3 +1,13 @@
1
1
  #!/usr/bin/env node
2
- export {};
2
+ import { type HarnessConfig } from '../schemas/config.schema.js';
3
+ /**
4
+ * Built-in fallback used when no --config is passed AND the default qulib.config.ts
5
+ * is absent from the working directory. Mirrors the values in packages/core/qulib.config.ts
6
+ * so `npx @qulib/core analyze --url ... --ephemeral` works in any directory.
7
+ *
8
+ * An EXPLICIT --config pointing at a missing file is always a hard error.
9
+ * This constant lives in the CLI layer — schemas remain additive-only and required
10
+ * fields are NOT converted to defaulted ones.
11
+ */
12
+ export declare const DEFAULT_HARNESS_CONFIG: HarnessConfig;
3
13
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/cli/index.ts"],"names":[],"mappings":""}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/cli/index.ts"],"names":[],"mappings":";AASA,OAAO,EAAuB,KAAK,aAAa,EAAE,MAAM,6BAA6B,CAAC;AAEtF;;;;;;;;GAQG;AACH,eAAO,MAAM,sBAAsB,EAAE,aAepC,CAAC"}
package/dist/cli/index.js CHANGED
@@ -7,12 +7,40 @@ import { z } from 'zod';
7
7
  const requirePkg = createRequire(import.meta.url);
8
8
  const pkg = requirePkg('../../package.json');
9
9
  import { HarnessConfigSchema } from '../schemas/config.schema.js';
10
+ /**
11
+ * Built-in fallback used when no --config is passed AND the default qulib.config.ts
12
+ * is absent from the working directory. Mirrors the values in packages/core/qulib.config.ts
13
+ * so `npx @qulib/core analyze --url ... --ephemeral` works in any directory.
14
+ *
15
+ * An EXPLICIT --config pointing at a missing file is always a hard error.
16
+ * This constant lives in the CLI layer — schemas remain additive-only and required
17
+ * fields are NOT converted to defaulted ones.
18
+ */
19
+ export const DEFAULT_HARNESS_CONFIG = {
20
+ maxPagesToScan: 20,
21
+ maxDepth: 3,
22
+ minPagesForConfidence: 3,
23
+ timeoutMs: 30000,
24
+ retryCount: 2,
25
+ llmTokenBudget: 4000,
26
+ testGenerationLimit: 10,
27
+ enableLlmScenarios: true,
28
+ readOnlyMode: true,
29
+ requireHumanReview: true,
30
+ failOnConsoleError: false,
31
+ explorer: 'playwright',
32
+ defaultAdapter: 'playwright',
33
+ adapters: ['playwright'],
34
+ };
10
35
  import { analyzeApp } from '../analyze.js';
11
36
  import { toAgentSummary } from '../agent-summary.js';
12
37
  import { detectAuth } from '../tools/auth/detect.js';
13
38
  import { exploreAuth } from '../tools/auth/explore.js';
14
39
  import { assertExactlyOneCredentialSource, parseCredentialsJsonString, resolveAuthLoginConfig, } from './auth-login-resolve.js';
15
40
  import { runAutomatedAuthLogin } from './auth-login-run.js';
41
+ import { registerScaffoldCommand } from './scaffold-run.js';
42
+ import { registerScoreAutomationCommand } from './score-automation-run.js';
43
+ import { registerConfidenceCommand } from './confidence-run.js';
16
44
  const program = new Command();
17
45
  const AnalyzeUrlSchema = z.string().url();
18
46
  const FormLoginCliSchema = z.object({
@@ -23,11 +51,50 @@ const FormLoginCliSchema = z.object({
23
51
  passwordSelector: z.string().min(1),
24
52
  submitSelector: z.string().min(1),
25
53
  });
26
- async function loadConfigFile(relativePath) {
54
+ /**
55
+ * Load a harness config from a file path.
56
+ *
57
+ * When `explicit` is false (the user did not pass --config) and the file does
58
+ * not exist, this returns DEFAULT_HARNESS_CONFIG silently rather than crashing.
59
+ * An explicit --config that points at a missing file is always a hard error.
60
+ */
61
+ async function loadConfigFile(relativePath, explicit) {
27
62
  const configPath = resolve(process.cwd(), relativePath);
28
- const configModule = await import(pathToFileURL(configPath).href);
63
+ // Implicit default + file absent → use built-in fallback, no crash.
64
+ if (!explicit) {
65
+ const { existsSync } = await import('node:fs');
66
+ if (!existsSync(configPath)) {
67
+ console.error('[qulib] No qulib.config.ts found in the working directory; using built-in default config. ' +
68
+ 'Pass --config <file> to supply your own.');
69
+ return DEFAULT_HARNESS_CONFIG;
70
+ }
71
+ }
72
+ const href = pathToFileURL(configPath).href;
73
+ let configModule;
74
+ try {
75
+ configModule = await import(href);
76
+ }
77
+ catch (err) {
78
+ // Plain node (the published CLI) cannot import a .ts config directly.
79
+ if (!configPath.endsWith('.ts') ||
80
+ err.code !== 'ERR_UNKNOWN_FILE_EXTENSION') {
81
+ throw err;
82
+ }
83
+ configModule = await importTsConfigViaTsx(configPath, href);
84
+ }
29
85
  return HarnessConfigSchema.parse(configModule.default);
30
86
  }
87
+ async function importTsConfigViaTsx(configPath, href) {
88
+ let tsImport;
89
+ try {
90
+ ({ tsImport } = await import('tsx/esm/api'));
91
+ }
92
+ catch {
93
+ throw new Error(`"${configPath}" is a TypeScript config, which this Node runtime cannot import directly. ` +
94
+ 'Install tsx (npm i -D tsx) or point --config at a .js or .mjs config file.');
95
+ }
96
+ return tsImport(href, import.meta.url);
97
+ }
31
98
  function redactConfigForLog(config) {
32
99
  const base = { ...config };
33
100
  if (config.auth?.type === 'form-login') {
@@ -86,7 +153,10 @@ async function runAnalyze(options) {
86
153
  }
87
154
  const validatedUrl = AnalyzeUrlSchema.parse(options.url);
88
155
  const mode = options.repo ? 'url-repo' : 'url-only';
89
- const baseConfig = await loadConfigFile(options.configFile ?? 'qulib.config.ts');
156
+ // configFile is undefined when --config was not explicitly passed (Commander
157
+ // no longer sets a default on the flag so we can distinguish the two cases).
158
+ const configFileExplicit = options.configFile !== undefined;
159
+ const baseConfig = await loadConfigFile(options.configFile ?? 'qulib.config.ts', configFileExplicit);
90
160
  const config = mergeAuthFromCli(baseConfig, options);
91
161
  const ephemeral = options.ephemeral ?? false;
92
162
  const agentSummary = options.agentSummary ?? false;
@@ -131,6 +201,12 @@ program
131
201
  .name('qulib')
132
202
  .description('Qulib — QA harness')
133
203
  .version(pkg.version);
204
+ // Q2 (CLIs + evals): each new CLI surface owns its own file and self-registers
205
+ // here, so the scaffold and score-automation commands never edit index.ts
206
+ // concurrently. Implemented in scaffold-run.ts / score-automation-run.ts.
207
+ registerScaffoldCommand(program);
208
+ registerScoreAutomationCommand(program);
209
+ registerConfidenceCommand(program);
134
210
  program
135
211
  .command('clean')
136
212
  .description('Remove all generated reports and scan state')
@@ -167,7 +243,7 @@ program
167
243
  .requiredOption('--url <url>', 'Base URL of the app to analyze')
168
244
  .option('--repo <path>', 'Path to the app repo')
169
245
  .option('--prd <path>', 'Path to a PRD markdown file')
170
- .option('--config <file>', 'Path to config file (relative to cwd)', 'qulib.config.ts')
246
+ .option('--config <file>', 'Path to config file (relative to cwd; defaults to qulib.config.ts if present, otherwise built-in defaults are used)')
171
247
  .option('--adapter <type>', 'Override default test adapter (playwright, cypress-e2e, cypress-component, api)', 'playwright')
172
248
  .option('--ephemeral', 'Do not write to disk — return full report as JSON on stdout (use for MCP/CI)', false)
173
249
  .option('--agent-summary', 'Do not write to disk — return the compact agent-summary gate JSON on stdout (pass/warn/fail with honesty notes). Mutually exclusive with --ephemeral.', false)
@@ -0,0 +1,86 @@
1
+ /**
2
+ * `qulib scaffold` — Q2 (scaffold-cli subtask).
3
+ *
4
+ * Wraps `scaffoldTests(url, options)` from ../scaffold-tests.js as a first-class
5
+ * CLI surface (scaffold was previously only reachable programmatically / via MCP).
6
+ *
7
+ * This file owns the `scaffold` subcommand end-to-end and is registered from
8
+ * cli/index.ts via `registerScaffoldCommand(program)` so this build agent never
9
+ * edits index.ts itself (avoids collision with score-automation). It mirrors the
10
+ * dynamic-import command style used by `cost` and the output-mode conventions of
11
+ * `analyze` (write-to-disk by default, `--json` for a stdout-only run).
12
+ *
13
+ * Output modes (one stdout shape, mutually exclusive with disk writes):
14
+ * default → write projectConfig + generated specs under --out (./qulib-scaffold)
15
+ * --json → no disk writes; print the full ScaffoldResult-shaped JSON on stdout
16
+ *
17
+ * Honesty rules (root design principle: never emit false confidence):
18
+ * - If analyze produced ZERO scenarios, we do NOT write an empty-but-confident
19
+ * scaffold. Write mode exits non-zero with a clear message; --json emits an
20
+ * explicit `{ empty: true, ... }` payload so a caller/agent can branch on it.
21
+ * - `--framework playwright` currently maps to a not-implemented adapter
22
+ * (PlaywrightAdapter.renderAll throws). Rather than surfacing a raw stack, we
23
+ * translate it into an actionable error pointing at the supported framework.
24
+ */
25
+ import type { Command } from 'commander';
26
+ import { type ScaffoldResult } from '../scaffold-tests.js';
27
+ import type { SpecValidationReport } from '../adapters/validate-specs.js';
28
+ import { type RecipeId } from '../schemas/recipe.schema.js';
29
+ /** Frameworks `scaffoldTests` accepts. Mirrors its `ScaffoldOptions['framework']`. */
30
+ declare const FRAMEWORKS: readonly ["cypress-e2e", "playwright"];
31
+ type ScaffoldFramework = (typeof FRAMEWORKS)[number];
32
+ /** A single file the scaffold wants on disk, with its repo-relative path. */
33
+ interface ScaffoldFile {
34
+ /** Path relative to the --out root. */
35
+ relativePath: string;
36
+ contents: string;
37
+ }
38
+ interface ScaffoldRunOptions {
39
+ url: string;
40
+ framework: ScaffoldFramework;
41
+ maxPages?: number;
42
+ out: string;
43
+ json: boolean;
44
+ recipes?: RecipeId[];
45
+ /**
46
+ * When true, fail the command (non-zero exit) if any generated spec does not
47
+ * parse/compile. The dry-run validation always runs; this flag controls
48
+ * whether a validation failure is fatal vs merely reported.
49
+ */
50
+ validateSpecs?: boolean;
51
+ }
52
+ /**
53
+ * Raised when `--validate-specs` is set and at least one generated spec fails
54
+ * the dry-run. Carries a non-zero `exitCode` so the CLI surfaces a hard failure
55
+ * instead of writing a known-broken scaffold and exiting green.
56
+ */
57
+ export declare class SpecValidationError extends Error {
58
+ readonly exitCode = 1;
59
+ constructor(message: string);
60
+ }
61
+ /**
62
+ * Flatten a ScaffoldResult into the concrete files a scaffold project needs:
63
+ * the framework config file, any support files, and one spec per generated test
64
+ * (each at its own `outputPath`, which the adapter already namespaces e.g.
65
+ * `cypress/e2e/<slug>.cy.ts`). Pure + side-effect-free so tests can assert on it.
66
+ */
67
+ export declare function collectScaffoldFiles(result: ScaffoldResult): ScaffoldFile[];
68
+ /**
69
+ * Apply the dry-run validation gate.
70
+ *
71
+ * Pure + side-effect-free (returns the warning text instead of logging) so it
72
+ * is unit-testable in isolation: feed it an `ok: false` report and assert it
73
+ * throws; feed it an `ok: true` report and assert it returns null. This is the
74
+ * discrimination witness for the fatal path — `runScaffold` cannot produce a
75
+ * broken spec on demand (the adapters always render valid code), so the gate's
76
+ * reject-vs-pass behavior is proven here directly against a report.
77
+ *
78
+ * @returns a non-null warning string when validation failed but `validateSpecs`
79
+ * was not set (caller should log it); null when all specs parsed.
80
+ * @throws SpecValidationError when validation failed AND `validateSpecs` is set.
81
+ */
82
+ export declare function enforceSpecValidation(validation: SpecValidationReport, validateSpecs: boolean): string | null;
83
+ export declare function runScaffold(options: ScaffoldRunOptions): Promise<void>;
84
+ export declare function registerScaffoldCommand(program: Command): void;
85
+ export {};
86
+ //# sourceMappingURL=scaffold-run.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scaffold-run.d.ts","sourceRoot":"","sources":["../../src/cli/scaffold-run.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAEzC,OAAO,EAAiB,KAAK,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAC1E,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,+BAA+B,CAAC;AAC1E,OAAO,EAAkB,KAAK,QAAQ,EAAE,MAAM,6BAA6B,CAAC;AAI5E,sFAAsF;AACtF,QAAA,MAAM,UAAU,wCAAyC,CAAC;AAC1D,KAAK,iBAAiB,GAAG,CAAC,OAAO,UAAU,CAAC,CAAC,MAAM,CAAC,CAAC;AAKrD,6EAA6E;AAC7E,UAAU,YAAY;IACpB,uCAAuC;IACvC,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,UAAU,kBAAkB;IAC1B,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,EAAE,iBAAiB,CAAC;IAC7B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,OAAO,CAAC;IACd,OAAO,CAAC,EAAE,QAAQ,EAAE,CAAC;IACrB;;;;OAIG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB;AAED;;;;GAIG;AACH,qBAAa,mBAAoB,SAAQ,KAAK;IAC5C,QAAQ,CAAC,QAAQ,KAAK;gBACV,OAAO,EAAE,MAAM;CAI5B;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,cAAc,GAAG,YAAY,EAAE,CAgC3E;AAOD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,qBAAqB,CACnC,UAAU,EAAE,oBAAoB,EAChC,aAAa,EAAE,OAAO,GACrB,MAAM,GAAG,IAAI,CAef;AA+BD,wBAAsB,WAAW,CAAC,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CAiG5E;AAED,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAkE9D"}
@@ -0,0 +1,232 @@
1
+ import { z } from 'zod';
2
+ import { scaffoldTests } from '../scaffold-tests.js';
3
+ import { RecipeIdSchema } from '../schemas/recipe.schema.js';
4
+ const ScaffoldUrlSchema = z.string().url();
5
+ /** Frameworks `scaffoldTests` accepts. Mirrors its `ScaffoldOptions['framework']`. */
6
+ const FRAMEWORKS = ['cypress-e2e', 'playwright'];
7
+ const FrameworkSchema = z.enum(FRAMEWORKS);
8
+ const DEFAULT_OUT_DIR = 'qulib-scaffold';
9
+ /**
10
+ * Raised when `--validate-specs` is set and at least one generated spec fails
11
+ * the dry-run. Carries a non-zero `exitCode` so the CLI surfaces a hard failure
12
+ * instead of writing a known-broken scaffold and exiting green.
13
+ */
14
+ export class SpecValidationError extends Error {
15
+ exitCode = 1;
16
+ constructor(message) {
17
+ super(message);
18
+ this.name = 'SpecValidationError';
19
+ }
20
+ }
21
+ /**
22
+ * Flatten a ScaffoldResult into the concrete files a scaffold project needs:
23
+ * the framework config file, any support files, and one spec per generated test
24
+ * (each at its own `outputPath`, which the adapter already namespaces e.g.
25
+ * `cypress/e2e/<slug>.cy.ts`). Pure + side-effect-free so tests can assert on it.
26
+ */
27
+ export function collectScaffoldFiles(result) {
28
+ const files = [];
29
+ const { projectConfig, generatedTests } = result;
30
+ files.push({
31
+ relativePath: projectConfig.configFile.filename,
32
+ contents: projectConfig.configFile.code,
33
+ });
34
+ for (const support of projectConfig.supportFiles) {
35
+ files.push({ relativePath: support.filename, contents: support.code });
36
+ }
37
+ for (const spec of generatedTests) {
38
+ files.push({ relativePath: spec.outputPath, contents: spec.code });
39
+ }
40
+ // A package.json fragment is informational (devDeps + scripts) — surface it as
41
+ // a file so the scaffold is runnable without the caller re-deriving it.
42
+ files.push({
43
+ relativePath: 'package.json',
44
+ contents: JSON.stringify({
45
+ name: 'qulib-scaffolded-suite',
46
+ private: true,
47
+ scripts: projectConfig.packageJson.scripts,
48
+ devDependencies: projectConfig.packageJson.devDependencies,
49
+ }, null, 2) + '\n',
50
+ });
51
+ return files;
52
+ }
53
+ /** True when scaffold produced nothing actionable (no scenarios → no specs). */
54
+ function isEmptyScaffold(result) {
55
+ return result.scenarios.length === 0 || result.generatedTests.length === 0;
56
+ }
57
+ /**
58
+ * Apply the dry-run validation gate.
59
+ *
60
+ * Pure + side-effect-free (returns the warning text instead of logging) so it
61
+ * is unit-testable in isolation: feed it an `ok: false` report and assert it
62
+ * throws; feed it an `ok: true` report and assert it returns null. This is the
63
+ * discrimination witness for the fatal path — `runScaffold` cannot produce a
64
+ * broken spec on demand (the adapters always render valid code), so the gate's
65
+ * reject-vs-pass behavior is proven here directly against a report.
66
+ *
67
+ * @returns a non-null warning string when validation failed but `validateSpecs`
68
+ * was not set (caller should log it); null when all specs parsed.
69
+ * @throws SpecValidationError when validation failed AND `validateSpecs` is set.
70
+ */
71
+ export function enforceSpecValidation(validation, validateSpecs) {
72
+ if (validation.ok)
73
+ return null;
74
+ const failed = validation.results.filter((r) => !r.valid);
75
+ const detail = failed
76
+ .map((r) => ` ✗ ${r.outputPath}\n ${r.errors.join('\n ')}`)
77
+ .join('\n');
78
+ const summary = `${validation.invalidCount} of ${validation.total} generated spec(s) failed dry-run validation ` +
79
+ `(they do not parse/compile):\n${detail}`;
80
+ if (validateSpecs) {
81
+ throw new SpecValidationError(summary);
82
+ }
83
+ return summary;
84
+ }
85
+ /**
86
+ * Translate the known not-implemented-adapter failure into an actionable message.
87
+ * Re-throws anything else unchanged so real failures stay loud.
88
+ */
89
+ function rethrowScaffoldError(error, framework) {
90
+ const message = error instanceof Error ? error.message : String(error);
91
+ if (/not implemented/i.test(message)) {
92
+ throw new Error(`The "${framework}" test adapter is not implemented yet, so qulib cannot render specs for it. ` +
93
+ `Re-run with --framework cypress-e2e (the supported scaffolder) until the ${framework} adapter ships.`);
94
+ }
95
+ throw error instanceof Error ? error : new Error(message);
96
+ }
97
+ async function writeScaffoldToDisk(files, outDir) {
98
+ const fs = await import('node:fs/promises');
99
+ const path = await import('node:path');
100
+ const outRoot = path.resolve(process.cwd(), outDir);
101
+ const written = [];
102
+ for (const file of files) {
103
+ const dest = path.join(outRoot, file.relativePath);
104
+ await fs.mkdir(path.dirname(dest), { recursive: true });
105
+ await fs.writeFile(dest, file.contents, 'utf8');
106
+ written.push(dest);
107
+ }
108
+ return written;
109
+ }
110
+ export async function runScaffold(options) {
111
+ const url = ScaffoldUrlSchema.parse(options.url);
112
+ const framework = FrameworkSchema.parse(options.framework);
113
+ if (options.json) {
114
+ console.error('[qulib] Scaffold JSON mode: no disk writes; full result JSON on stdout');
115
+ }
116
+ else {
117
+ console.error(`[qulib] Scaffolding ${framework} tests for ${url}`);
118
+ console.error('[qulib] Analyzing the deployed surface to derive scenarios — this may take a moment...');
119
+ }
120
+ let result;
121
+ try {
122
+ result = await scaffoldTests(url, {
123
+ framework,
124
+ ...(options.maxPages !== undefined && { maxPagesToScan: options.maxPages }),
125
+ ...(options.recipes && options.recipes.length > 0 && { recipes: options.recipes }),
126
+ });
127
+ }
128
+ catch (error) {
129
+ rethrowScaffoldError(error, framework);
130
+ }
131
+ // Honest empty handling — no false-confidence scaffolds.
132
+ if (isEmptyScaffold(result)) {
133
+ if (options.json) {
134
+ console.log(JSON.stringify({
135
+ url: result.url,
136
+ framework: result.framework,
137
+ empty: true,
138
+ scenarioCount: 0,
139
+ generatedTestCount: 0,
140
+ note: 'Analysis surfaced no scenarios for this URL, so no tests were generated. ' +
141
+ 'This is honest output, not a failure: there was nothing concrete to scaffold. ' +
142
+ 'Try a different URL, raise --max-pages, or provide authenticated access if the app is behind a login.',
143
+ }, null, 2));
144
+ return;
145
+ }
146
+ throw new Error('No scenarios were derived for this URL, so qulib generated no tests. ' +
147
+ 'Refusing to write an empty scaffold (no false confidence). ' +
148
+ 'Try a different URL, raise --max-pages, or supply auth if the app is behind a login.');
149
+ }
150
+ // Dry-run gate: the scaffold already validated every generated spec through
151
+ // the TS compiler. Surface a failure here so a broken generator output never
152
+ // silently lands on disk. With --validate-specs this is fatal (non-zero exit
153
+ // via SpecValidationError); without it we still warn so the signal is never
154
+ // hidden. The gate logic lives in enforceSpecValidation (unit-tested).
155
+ const validation = result.specValidation;
156
+ const warning = enforceSpecValidation(validation, Boolean(options.validateSpecs));
157
+ if (warning) {
158
+ console.error(`[qulib] WARNING — ${warning}`);
159
+ console.error('[qulib] Re-run with --validate-specs to make this a hard (non-zero) failure.');
160
+ }
161
+ if (options.json) {
162
+ console.log(JSON.stringify({
163
+ url: result.url,
164
+ framework: result.framework,
165
+ empty: false,
166
+ scenarioCount: result.scenarios.length,
167
+ generatedTestCount: result.generatedTests.length,
168
+ scenarios: result.scenarios,
169
+ generatedTests: result.generatedTests,
170
+ projectConfig: result.projectConfig,
171
+ specValidation: result.specValidation,
172
+ }, null, 2));
173
+ return;
174
+ }
175
+ const files = collectScaffoldFiles(result);
176
+ const written = await writeScaffoldToDisk(files, options.out);
177
+ const path = await import('node:path');
178
+ const outRoot = path.resolve(process.cwd(), options.out);
179
+ console.error(`\n[qulib] Scaffold complete — ${framework}`);
180
+ console.error(` Scenarios derived: ${result.scenarios.length}`);
181
+ console.error(` Specs generated: ${result.generatedTests.length}`);
182
+ console.error(` Specs validated: ${validation.total - validation.invalidCount}/${validation.total} parse cleanly`);
183
+ console.error(` Files written: ${written.length}`);
184
+ console.error(` Output directory: ${outRoot}`);
185
+ console.error(` Config: ${result.projectConfig.configFile.filename}`);
186
+ console.error('\n[qulib] Next: cd into the output dir, `npm install`, then run the test script.');
187
+ }
188
+ export function registerScaffoldCommand(program) {
189
+ program
190
+ .command('scaffold')
191
+ .description('Generate a runnable test suite (config + specs) for a deployed app by analyzing its surface')
192
+ .requiredOption('--url <url>', 'Base URL of the app to scaffold tests for')
193
+ .option('--framework <framework>', `Test framework: ${FRAMEWORKS.join(' | ')}`, 'cypress-e2e')
194
+ .option('--max-pages <n>', 'Maximum number of pages to scan while deriving scenarios')
195
+ .option('--out <dir>', 'Directory to write the scaffolded project into', DEFAULT_OUT_DIR)
196
+ .option('--json', 'Do not write to disk — print the full scaffold result as JSON on stdout (use for MCP/CI)', false)
197
+ .option('--recipes <ids>', 'Comma-separated recipe ids to append proven test patterns: auth,a11y,nav,seed (e.g. --recipes auth,a11y)')
198
+ .option('--validate-specs', 'Fail (non-zero exit) if any generated spec does not parse/compile. Validation always runs; this makes a failure fatal.', false)
199
+ .action(async (options) => {
200
+ const parsedFramework = FrameworkSchema.safeParse(options.framework);
201
+ if (!parsedFramework.success) {
202
+ throw new Error(`Invalid --framework "${options.framework}". Supported: ${FRAMEWORKS.join(', ')}.`);
203
+ }
204
+ let maxPages;
205
+ if (options.maxPages !== undefined) {
206
+ const n = Number(options.maxPages);
207
+ if (!Number.isInteger(n) || n <= 0) {
208
+ throw new Error(`--max-pages must be a positive integer, got "${options.maxPages}".`);
209
+ }
210
+ maxPages = n;
211
+ }
212
+ let recipes;
213
+ if (options.recipes) {
214
+ const ids = options.recipes.split(',').map((s) => s.trim()).filter(Boolean);
215
+ const parsed = ids.map((id) => RecipeIdSchema.safeParse(id));
216
+ const invalid = parsed.map((p, i) => (!p.success ? ids[i] : null)).filter(Boolean);
217
+ if (invalid.length > 0) {
218
+ throw new Error(`Invalid --recipes value(s): ${invalid.join(', ')}. Supported: auth, a11y, nav, seed.`);
219
+ }
220
+ recipes = parsed.map((p) => (p.success ? p.data : null)).filter(Boolean);
221
+ }
222
+ await runScaffold({
223
+ url: options.url,
224
+ framework: parsedFramework.data,
225
+ maxPages,
226
+ out: options.out,
227
+ json: Boolean(options.json),
228
+ recipes,
229
+ validateSpecs: Boolean(options.validateSpecs),
230
+ });
231
+ });
232
+ }
@@ -0,0 +1,25 @@
1
+ import type { Command } from 'commander';
2
+ import type { AutomationMaturity } from '../schemas/automation-maturity.schema.js';
3
+ export interface ScoreAutomationOptions {
4
+ /** Path to the local repo to score (required). */
5
+ repo: string;
6
+ /** Emit the full AutomationMaturity object as JSON to stdout instead of the human report. */
7
+ json?: boolean;
8
+ }
9
+ /**
10
+ * Resolve `--repo` to an absolute path and assert it is an existing directory.
11
+ * Fails fast with a clear, actionable message rather than letting glob silently
12
+ * scan nothing and report a falsely-confident "everything is uncovered" score.
13
+ */
14
+ export declare function resolveRepoPath(repoOption: string | undefined, cwd?: string): string;
15
+ /** Build the human-readable report as a single string (kept pure so tests can assert on it). */
16
+ export declare function formatHumanReport(maturity: AutomationMaturity): string;
17
+ /**
18
+ * Core of the command, factored out of the action handler so node:test can drive it
19
+ * directly against a fixture repo without spawning a process.
20
+ *
21
+ * Reuses scanRepo (static repo intelligence) then computes maturity explicitly.
22
+ */
23
+ export declare function runScoreAutomation(options: ScoreAutomationOptions, out?: (line: string) => void): Promise<AutomationMaturity>;
24
+ export declare function registerScoreAutomationCommand(program: Command): void;
25
+ //# sourceMappingURL=score-automation-run.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"score-automation-run.d.ts","sourceRoot":"","sources":["../../src/cli/score-automation-run.ts"],"names":[],"mappings":"AA4BA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EACV,kBAAkB,EAEnB,MAAM,0CAA0C,CAAC;AAIlD,MAAM,WAAW,sBAAsB;IACrC,kDAAkD;IAClD,IAAI,EAAE,MAAM,CAAC;IACb,6FAA6F;IAC7F,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,UAAU,EAAE,MAAM,GAAG,SAAS,EAAE,GAAG,GAAE,MAAsB,GAAG,MAAM,CAYnG;AA+BD,gGAAgG;AAChG,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,kBAAkB,GAAG,MAAM,CAiBtE;AAED;;;;;GAKG;AACH,wBAAsB,kBAAkB,CACtC,OAAO,EAAE,sBAAsB,EAC/B,GAAG,GAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAkC,GACxD,OAAO,CAAC,kBAAkB,CAAC,CAW7B;AAED,wBAAgB,8BAA8B,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAWrE"}