@qulib/core 0.8.2 → 0.10.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 (101) hide show
  1. package/README.md +38 -13
  2. package/bin/qulib.js +2 -3
  3. package/dist/__tests__/playwright-available.d.ts +32 -0
  4. package/dist/__tests__/playwright-available.d.ts.map +1 -0
  5. package/dist/__tests__/playwright-available.js +35 -0
  6. package/dist/adapters/ci-results-adapter.d.ts +67 -0
  7. package/dist/adapters/ci-results-adapter.d.ts.map +1 -0
  8. package/dist/adapters/ci-results-adapter.js +143 -0
  9. package/dist/adapters/cypress-e2e-adapter.d.ts.map +1 -1
  10. package/dist/adapters/cypress-e2e-adapter.js +25 -2
  11. package/dist/adapters/playwright-adapter.d.ts.map +1 -1
  12. package/dist/adapters/playwright-adapter.js +25 -2
  13. package/dist/adapters/pr-metadata-adapter.d.ts +75 -0
  14. package/dist/adapters/pr-metadata-adapter.d.ts.map +1 -0
  15. package/dist/adapters/pr-metadata-adapter.js +146 -0
  16. package/dist/adapters/validate-specs.d.ts +55 -0
  17. package/dist/adapters/validate-specs.d.ts.map +1 -0
  18. package/dist/adapters/validate-specs.js +67 -0
  19. package/dist/baseline/baseline.d.ts +54 -0
  20. package/dist/baseline/baseline.d.ts.map +1 -0
  21. package/dist/baseline/baseline.js +252 -0
  22. package/dist/baseline/baseline.schema.d.ts +233 -0
  23. package/dist/baseline/baseline.schema.d.ts.map +1 -0
  24. package/dist/baseline/baseline.schema.js +59 -0
  25. package/dist/cli/analyze-diff-run.d.ts +77 -0
  26. package/dist/cli/analyze-diff-run.d.ts.map +1 -0
  27. package/dist/cli/analyze-diff-run.js +266 -0
  28. package/dist/cli/baseline-run.d.ts +55 -0
  29. package/dist/cli/baseline-run.d.ts.map +1 -0
  30. package/dist/cli/baseline-run.js +259 -0
  31. package/dist/cli/confidence-run.d.ts +16 -0
  32. package/dist/cli/confidence-run.d.ts.map +1 -0
  33. package/dist/cli/confidence-run.js +162 -0
  34. package/dist/cli/index.d.ts +11 -1
  35. package/dist/cli/index.d.ts.map +1 -1
  36. package/dist/cli/index.js +84 -4
  37. package/dist/cli/scaffold-run.d.ts +86 -0
  38. package/dist/cli/scaffold-run.d.ts.map +1 -0
  39. package/dist/cli/scaffold-run.js +232 -0
  40. package/dist/cli/score-automation-run.d.ts +25 -0
  41. package/dist/cli/score-automation-run.d.ts.map +1 -0
  42. package/dist/cli/score-automation-run.js +127 -0
  43. package/dist/examples/notquality-dogfood/fixture.d.ts +166 -0
  44. package/dist/examples/notquality-dogfood/fixture.d.ts.map +1 -0
  45. package/dist/examples/notquality-dogfood/fixture.js +174 -0
  46. package/dist/examples/notquality-dogfood/run.d.ts +34 -0
  47. package/dist/examples/notquality-dogfood/run.d.ts.map +1 -0
  48. package/dist/examples/notquality-dogfood/run.js +139 -0
  49. package/dist/index.d.ts +18 -1
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +15 -0
  52. package/dist/recipes/a11y.d.ts +36 -0
  53. package/dist/recipes/a11y.d.ts.map +1 -0
  54. package/dist/recipes/a11y.js +118 -0
  55. package/dist/recipes/auth.d.ts +38 -0
  56. package/dist/recipes/auth.d.ts.map +1 -0
  57. package/dist/recipes/auth.js +156 -0
  58. package/dist/recipes/index.d.ts +26 -0
  59. package/dist/recipes/index.d.ts.map +1 -0
  60. package/dist/recipes/index.js +41 -0
  61. package/dist/recipes/nav.d.ts +34 -0
  62. package/dist/recipes/nav.d.ts.map +1 -0
  63. package/dist/recipes/nav.js +128 -0
  64. package/dist/recipes/seed.d.ts +34 -0
  65. package/dist/recipes/seed.d.ts.map +1 -0
  66. package/dist/recipes/seed.js +87 -0
  67. package/dist/reporters/heatmap.d.ts +55 -0
  68. package/dist/reporters/heatmap.d.ts.map +1 -0
  69. package/dist/reporters/heatmap.js +146 -0
  70. package/dist/reporters/markdown-reporter.d.ts.map +1 -1
  71. package/dist/reporters/markdown-reporter.js +4 -1
  72. package/dist/scaffold-tests.d.ts +21 -0
  73. package/dist/scaffold-tests.d.ts.map +1 -1
  74. package/dist/scaffold-tests.js +12 -2
  75. package/dist/schemas/confidence.schema.d.ts +526 -0
  76. package/dist/schemas/confidence.schema.d.ts.map +1 -0
  77. package/dist/schemas/confidence.schema.js +161 -0
  78. package/dist/schemas/config.schema.d.ts.map +1 -1
  79. package/dist/schemas/config.schema.js +6 -1
  80. package/dist/schemas/index.d.ts +3 -0
  81. package/dist/schemas/index.d.ts.map +1 -1
  82. package/dist/schemas/index.js +3 -0
  83. package/dist/schemas/recipe.schema.d.ts +66 -0
  84. package/dist/schemas/recipe.schema.d.ts.map +1 -0
  85. package/dist/schemas/recipe.schema.js +45 -0
  86. package/dist/schemas/views.schema.d.ts +234 -0
  87. package/dist/schemas/views.schema.d.ts.map +1 -0
  88. package/dist/schemas/views.schema.js +82 -0
  89. package/dist/tools/scoring/confidence-from-qulib.d.ts +34 -0
  90. package/dist/tools/scoring/confidence-from-qulib.d.ts.map +1 -0
  91. package/dist/tools/scoring/confidence-from-qulib.js +206 -0
  92. package/dist/tools/scoring/confidence-views.d.ts +40 -0
  93. package/dist/tools/scoring/confidence-views.d.ts.map +1 -0
  94. package/dist/tools/scoring/confidence-views.js +163 -0
  95. package/dist/tools/scoring/confidence.d.ts +32 -0
  96. package/dist/tools/scoring/confidence.d.ts.map +1 -0
  97. package/dist/tools/scoring/confidence.js +180 -0
  98. package/dist/tools/scoring/levels.d.ts +15 -0
  99. package/dist/tools/scoring/levels.d.ts.map +1 -0
  100. package/dist/tools/scoring/levels.js +21 -0
  101. package/package.json +18 -8
@@ -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,CAerE"}
@@ -0,0 +1,127 @@
1
+ /**
2
+ * `qulib score-automation` — score a local repo's test-automation maturity.
3
+ *
4
+ * Wraps `computeAutomationMaturity(repo)` (../tools/scoring/automation-maturity.js)
5
+ * as a first-class CLI surface. Until now the maturity score was only produced as a
6
+ * side-effect of `analyze --repo`; this exposes it standalone so an agent or CI can
7
+ * score a repo's automation directly, without a deployed URL to crawl.
8
+ *
9
+ * How a RepoAnalysis is obtained (smallest honest path, no duplicated logic):
10
+ * `scanRepo(repoPath)` (../tools/repo/scan.js) is a pure static scan — it infers
11
+ * routes, test files, test-id hygiene, CI presence and Cypress structure straight
12
+ * from the repo layout, with no browser/URL dependency. We reuse it (root CLAUDE.md:
13
+ * shared logic lives in core, never duplicate) and then call computeAutomationMaturity
14
+ * on its result so the printed report reflects a freshly-computed maturity object.
15
+ *
16
+ * Output honesty (root design principle — no false confidence):
17
+ * Per-dimension applicability (`applicable | not_applicable | unknown`) is surfaced
18
+ * verbatim. A `not_applicable` or `unknown` dimension reads as honest uncertainty
19
+ * with its reason/guidance — it is NEVER rendered as a "0/100" that looks like a
20
+ * real failing score. The overall score is normalized over applicable dimensions
21
+ * only (see computeAutomationMaturity), so absent capabilities don't drag it down.
22
+ *
23
+ * This file owns the `score-automation` subcommand end-to-end and is registered from
24
+ * cli/index.ts via `registerScoreAutomationCommand(program)`, so this command's build
25
+ * never edits index.ts (avoids collision with the parallel scaffold command).
26
+ */
27
+ import { resolve } from 'node:path';
28
+ import { existsSync, statSync } from 'node:fs';
29
+ import { scanRepo } from '../tools/repo/scan.js';
30
+ import { computeAutomationMaturity } from '../tools/scoring/automation-maturity.js';
31
+ /**
32
+ * Resolve `--repo` to an absolute path and assert it is an existing directory.
33
+ * Fails fast with a clear, actionable message rather than letting glob silently
34
+ * scan nothing and report a falsely-confident "everything is uncovered" score.
35
+ */
36
+ export function resolveRepoPath(repoOption, cwd = process.cwd()) {
37
+ if (!repoOption || !repoOption.trim()) {
38
+ throw new Error('score-automation requires --repo <path> pointing at a local repository to score.');
39
+ }
40
+ const abs = resolve(cwd, repoOption.trim());
41
+ if (!existsSync(abs)) {
42
+ throw new Error(`--repo path does not exist: ${abs}`);
43
+ }
44
+ if (!statSync(abs).isDirectory()) {
45
+ throw new Error(`--repo path is not a directory: ${abs}`);
46
+ }
47
+ return abs;
48
+ }
49
+ /** Single-letter glyph + word for a dimension's applicability, for the human report. */
50
+ function applicabilityTag(dim) {
51
+ const applicability = dim.applicability ?? 'applicable';
52
+ switch (applicability) {
53
+ case 'not_applicable':
54
+ return 'n/a';
55
+ case 'unknown':
56
+ return 'unknown';
57
+ default:
58
+ return 'applicable';
59
+ }
60
+ }
61
+ /**
62
+ * Render one dimension line. For applicable dimensions we show the score; for
63
+ * not_applicable / unknown we show the word and the reason — NOT a misleading "0".
64
+ */
65
+ function formatDimensionLine(dim) {
66
+ const applicability = dim.applicability ?? 'applicable';
67
+ const weightPct = `${Math.round(dim.weight * 100)}%`;
68
+ const head = ` - ${dim.dimension} [w=${weightPct}]`;
69
+ if (applicability === 'applicable') {
70
+ return `${head}: ${dim.score}/100`;
71
+ }
72
+ // Honest-uncertainty rendering: surface the label + reason, never a bare 0.
73
+ const reason = dim.reason ? ` — ${dim.reason}` : '';
74
+ return `${head}: ${applicabilityTag(dim)} (excluded from overall)${reason}`;
75
+ }
76
+ /** Build the human-readable report as a single string (kept pure so tests can assert on it). */
77
+ export function formatHumanReport(maturity) {
78
+ const lines = [];
79
+ lines.push(`[qulib] Automation maturity for ${maturity.repoPath}`);
80
+ lines.push(` overall: ${maturity.overallScore}/100 — ${maturity.label} (level ${maturity.level})`);
81
+ lines.push(' dimensions:');
82
+ for (const dim of maturity.dimensions) {
83
+ lines.push(formatDimensionLine(dim));
84
+ }
85
+ if (maturity.topRecommendations.length > 0) {
86
+ lines.push(' top recommendations:');
87
+ for (const rec of maturity.topRecommendations) {
88
+ lines.push(` • ${rec}`);
89
+ }
90
+ }
91
+ else {
92
+ lines.push(' top recommendations: none — applicable dimensions are at/above target.');
93
+ }
94
+ return lines.join('\n');
95
+ }
96
+ /**
97
+ * Core of the command, factored out of the action handler so node:test can drive it
98
+ * directly against a fixture repo without spawning a process.
99
+ *
100
+ * Reuses scanRepo (static repo intelligence) then computes maturity explicitly.
101
+ */
102
+ export async function runScoreAutomation(options, out = (line) => console.log(line)) {
103
+ const repoPath = resolveRepoPath(options.repo);
104
+ const repo = await scanRepo(repoPath);
105
+ const maturity = computeAutomationMaturity(repo);
106
+ if (options.json) {
107
+ out(JSON.stringify(maturity, null, 2));
108
+ }
109
+ else {
110
+ out(formatHumanReport(maturity));
111
+ }
112
+ return maturity;
113
+ }
114
+ export function registerScoreAutomationCommand(program) {
115
+ // Canonical name kept for backwards compatibility. Alias: automation-score
116
+ // (confidence-family naming — shorter and consistent with the qulib_ MCP convention).
117
+ program
118
+ .command('score-automation')
119
+ .alias('automation-score')
120
+ .description("Score a local repo's test-automation maturity (overall + per-dimension, with honest applicability). " +
121
+ '(Alias: automation-score)')
122
+ .requiredOption('--repo <path>', 'Path to the local repository to score')
123
+ .option('--json', 'Emit the full AutomationMaturity object as JSON to stdout', false)
124
+ .action(async (options) => {
125
+ await runScoreAutomation({ repo: options.repo, json: Boolean(options.json) });
126
+ });
127
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * notquality DOGFOOD — real delivery signals fixture.
3
+ *
4
+ * P5 — qulib ingests notquality's own delivery signals → Release Confidence.
5
+ *
6
+ * PROVENANCE (all signals gathered 2026-06-04 via `gh` CLI against TapeshN/notquality):
7
+ *
8
+ * E2E run: gh run view 26931370208 -R TapeshN/notquality
9
+ * branch: notquality-app/prod-migrate
10
+ * sha: 5732ed5a37ba46c503d1319da83c5c6f4c8e5cb6
11
+ * workflow: E2E (playwright job, job ID 79451559555)
12
+ * completed: 2026-06-04T04:46:20Z (duration: ~6m4s)
13
+ * conclusion: success
14
+ * test files: 29 spec files on origin/main (git ls-tree -r origin/main)
15
+ * test cases: 194 total test() calls across 29 spec files
16
+ * fixme/skip: 26 test.fixme / test.skip markers
17
+ * active (non-fixme): 194 - 26 = 168 runnable tests
18
+ * all passed (CI conclusion: success)
19
+ *
20
+ * CI run: gh run view 26931370215 -R TapeshN/notquality
21
+ * branch: notquality-app/prod-migrate
22
+ * sha: 5732ed5a37ba46c503d1319da83c5c6f4c8e5cb6
23
+ * workflow: CI (validate job, job ID 79451559554)
24
+ * completed: 2026-06-04T04:46:20Z (duration: ~1m13s)
25
+ * conclusion: success
26
+ * steps: typecheck, lint, validate:bugs, prisma generate, build — all green
27
+ *
28
+ * PR: gh pr view 52 -R TapeshN/notquality --json number,url,reviewDecision,mergeable,statusCheckRollup
29
+ * title: "Run prisma migrate deploy before build on Vercel"
30
+ * branch: notquality-app/prod-migrate
31
+ * number: 52
32
+ * url: https://github.com/TapeshN/notquality/pull/52
33
+ * reviewDecision: "" (no review assigned — open PR, no blocking change requests)
34
+ * mergeable: MERGEABLE
35
+ * status checks: CI validate → SUCCESS, E2E playwright → SUCCESS, Vercel → SUCCESS
36
+ *
37
+ * Repo inventory (gh ls-tree -r origin/main, 2026-06-04):
38
+ * 29 spec files, 77 source .ts/.tsx files (excl. e2e), 19 route pages
39
+ * playwright.config.ts present (.github/workflows/e2e.yml present)
40
+ * CI: .github/workflows/ci.yml present (typecheck + lint + validate:bugs + build)
41
+ * auth: dual auth (iron-session playground + NextAuth platform)
42
+ * test-id hygiene: data-testid used in app/ + e2e/ (29 spec files use getByTestId)
43
+ * challenges: 1 seeded (legacy-bug-hunt-1); 16-card static list page (P5 truth-fix target)
44
+ *
45
+ * COLLECTION TIMESTAMP: 2026-06-04T09:00:00.000Z
46
+ *
47
+ * This fixture is intentionally FROZEN at this point in time. When real delivery
48
+ * signals are updated, create a NEW fixture version (fixture-v2.ts, etc.) and
49
+ * update the integration tests to use the latest. Never silently mutate this file —
50
+ * the provenance citation is load-bearing for the eval/audit trail.
51
+ */
52
+ export declare const FIXTURE_COLLECTION_TS = "2026-06-04T09:00:00.000Z";
53
+ /**
54
+ * Real CI run data from notquality E2E workflow (run #26931370208).
55
+ * Source: gh run view 26931370208 -R TapeshN/notquality
56
+ */
57
+ export declare const NOTQUALITY_E2E_RUN: {
58
+ /** ISO-8601 timestamp of run completion. */
59
+ readonly completedAt: "2026-06-04T04:46:20.000Z";
60
+ /** CI build step succeeded (typecheck, lint, validate:bugs, prisma generate, build). */
61
+ readonly buildPassed: true;
62
+ /**
63
+ * 168 runnable tests (194 total test() calls − 26 test.fixme/test.skip markers).
64
+ * All 168 passed in this run. Source: spec-file grep + run conclusion=success.
65
+ */
66
+ readonly testsPassed: 168;
67
+ readonly testsFailed: 0;
68
+ readonly testsErrored: 0;
69
+ /**
70
+ * 26 tests marked test.fixme or test.skip across 16 spec files.
71
+ * These are known quarantined defects (color-contrast a11y, label regression,
72
+ * EVT-001, duplicate challenge-title) — intentional, not infra failures.
73
+ */
74
+ readonly testsFlaky: 0;
75
+ readonly runUrl: "https://github.com/TapeshN/notquality/actions/runs/26931370208";
76
+ readonly workflowName: "E2E (playwright)";
77
+ };
78
+ /**
79
+ * Real CI validate-job data from notquality CI workflow (run #26931370215).
80
+ * Source: gh run view 26931370215 -R TapeshN/notquality
81
+ */
82
+ export declare const NOTQUALITY_CI_RUN: {
83
+ readonly completedAt: "2026-06-04T04:46:20.000Z";
84
+ readonly buildPassed: true;
85
+ readonly testsPassed: 0;
86
+ readonly testsFailed: 0;
87
+ readonly testsErrored: 0;
88
+ readonly runUrl: "https://github.com/TapeshN/notquality/actions/runs/26931370215";
89
+ readonly workflowName: "CI (validate)";
90
+ };
91
+ /**
92
+ * Real PR #52 metadata from notquality.
93
+ * Source: gh pr view 52 -R TapeshN/notquality --json number,url,reviewDecision,mergeable,statusCheckRollup
94
+ */
95
+ export declare const NOTQUALITY_PR_52: {
96
+ readonly number: 52;
97
+ readonly url: "https://github.com/TapeshN/notquality/pull/52";
98
+ readonly reviewDecision: null;
99
+ readonly mergeable: "MERGEABLE";
100
+ readonly statusCheckRollup: readonly [{
101
+ readonly state: "SUCCESS";
102
+ readonly name: "validate";
103
+ readonly targetUrl: "https://github.com/TapeshN/notquality/actions/runs/26931370215/job/79451559554";
104
+ }, {
105
+ readonly state: "SUCCESS";
106
+ readonly name: "playwright";
107
+ readonly targetUrl: "https://github.com/TapeshN/notquality/actions/runs/26931370208/job/79451559555";
108
+ }, {
109
+ readonly state: "SUCCESS";
110
+ readonly name: "Vercel";
111
+ readonly targetUrl: "https://vercel.com/tapeshnagarwal-7364s-projects/notquality-app/5mSLRhKKEdwvnqMoTY4XWxSePFYB";
112
+ }, {
113
+ readonly state: "SUCCESS";
114
+ readonly name: "Vercel Preview Comments";
115
+ readonly targetUrl: "https://vercel.com/github";
116
+ }];
117
+ readonly noPr: false;
118
+ };
119
+ /**
120
+ * Repo-level automation maturity facts (from static scan of origin/main, 2026-06-04).
121
+ * These facts drive the test-automation EvidenceItem used when qulib_score_automation
122
+ * is held (no live local scan in this fixture — see HELD note below).
123
+ *
124
+ * HELD: The live qulib_score_automation(repoPath) call requires the notquality
125
+ * repo to be available at an absolute path on the build machine and needs the full
126
+ * qulib CLI. The integration test uses these pre-scored facts instead of a live scan.
127
+ * The live scan is operator-gated (run `qulib score-automation <path>` locally).
128
+ */
129
+ export declare const NOTQUALITY_AUTOMATION_MATURITY: {
130
+ /**
131
+ * Estimated overall automation maturity score (0–100).
132
+ * Basis: Playwright present (framework-adoption ✓), 29 spec files covering 19 routes
133
+ * (test-coverage-breadth ~72%), e2e.yml + ci.yml present (ci-integration ✓),
134
+ * auth-test-coverage present (iron-session + NextAuth both tested ✓),
135
+ * data-testid used in spec files (test-id-hygiene present but not perfect — duplicate
136
+ * challenge-title test-id is a known defect, P5 truth-fix target), no component/unit
137
+ * tests yet (component-test-ratio = 0). Estimated L3 (60–79 range).
138
+ * Conservative estimate: 65/100.
139
+ */
140
+ readonly overallScore: 65;
141
+ readonly level: 3;
142
+ readonly label: "L3 — building maturity";
143
+ /**
144
+ * Key facts cited (no live scan was run; these are statically-derived):
145
+ * - framework: Playwright (playwright.config.ts present on origin/main)
146
+ * - specFiles: 29 (git ls-tree origin/main | grep "e2e/" | grep "spec.ts")
147
+ * - routePages: 19 (git ls-tree origin/main | grep "app/" | grep "page.tsx")
148
+ * - ciWorkflows: 2 (.github/workflows/ci.yml + e2e.yml)
149
+ * - authTests: yes (e2e/specs/auth/ — 3 spec files, 18 test() calls)
150
+ * - testIdHygiene: partial (data-testid used; duplicate challenge-title known defect)
151
+ * - componentTests: 0 (no vitest/jest unit tests in repo)
152
+ */
153
+ readonly topRecommendations: readonly ["Add vitest/jest unit tests for lib/ (scoring, API helpers) to raise component-test-ratio.", "Fix duplicate data-testid=\"challenge-title\" (breadcrumb vs h1) for unambiguous E2E selectors.", "Add dedicated challenges list + attempt E2E specs once fake list page is replaced with DB truth."];
154
+ readonly computedAt: "2026-06-04T09:00:00.000Z";
155
+ readonly repoPath: "TapeshN/notquality@5732ed5a (origin/main, 2026-06-04)";
156
+ };
157
+ /**
158
+ * Repository context for the ConfidenceSubject.
159
+ * ref: the commit SHA + branch context; tenantId: 'notquality' (multi-tenant from day one).
160
+ */
161
+ export declare const NOTQUALITY_SUBJECT: {
162
+ readonly kind: "release";
163
+ readonly ref: "TapeshN/notquality@5732ed5a (notquality-app/prod-migrate)";
164
+ readonly tenantId: "notquality";
165
+ };
166
+ //# sourceMappingURL=fixture.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fixture.d.ts","sourceRoot":"","sources":["../../../src/examples/notquality-dogfood/fixture.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkDG;AAEH,eAAO,MAAM,qBAAqB,6BAA6B,CAAC;AAEhE;;;GAGG;AACH,eAAO,MAAM,kBAAkB;IAC7B,4CAA4C;;IAE5C,wFAAwF;;IAExF;;;OAGG;;;;IAIH;;;;OAIG;;;;CAIK,CAAC;AAEX;;;GAGG;AACH,eAAO,MAAM,iBAAiB;;;;;;;;CAQpB,CAAC;AAEX;;;GAGG;AACH,eAAO,MAAM,gBAAgB;;;6BAGH,IAAI;;;;;;;;;;;;;;;;;;;;CAyBpB,CAAC;AAEX;;;;;;;;;GASG;AACH,eAAO,MAAM,8BAA8B;IACzC;;;;;;;;;OASG;;;;IAIH;;;;;;;;;OASG;;;;CAQK,CAAC;AAEX;;;GAGG;AACH,eAAO,MAAM,kBAAkB;;;;CAIrB,CAAC"}