@qatonic_innovations/qaios 0.1.1 → 0.2.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.
package/README.md
CHANGED
|
@@ -10,6 +10,18 @@ QAIOS acts like a QA engineer on your team. Point it at a feature description or
|
|
|
10
10
|
|
|
11
11
|
**Mental model: Claude Code, but for QA work instead of feature coding.**
|
|
12
12
|
|
|
13
|
+
> **Status: early alpha (v0.1).** Core flows — `init`, `doctor`, test design
|
|
14
|
+
> & generation, Playwright execution, self-healing, accessibility, and the
|
|
15
|
+
> audit log — work today. Expect rough edges and please
|
|
16
|
+
> [report issues](https://github.com/qatonic/qaios/issues). After installing,
|
|
17
|
+
> a quick smoke test confirms everything resolves:
|
|
18
|
+
>
|
|
19
|
+
> ```bash
|
|
20
|
+
> qaios --version
|
|
21
|
+
> qaios --help
|
|
22
|
+
> qaios doctor # checks Node, API key, Playwright, config
|
|
23
|
+
> ```
|
|
24
|
+
|
|
13
25
|
---
|
|
14
26
|
|
|
15
27
|
## Install
|
|
@@ -23,18 +35,39 @@ The published package is `@qatonic_innovations/qaios`; the installed **command i
|
|
|
23
35
|
|
|
24
36
|
### Requirements
|
|
25
37
|
|
|
26
|
-
- **Node.js 20 LTS
|
|
27
|
-
-
|
|
38
|
+
- **Node.js 20 LTS recommended.** QAIOS bundles a native SQLite module
|
|
39
|
+
(better-sqlite3) for its local audit log; Node 20 LTS has the widest
|
|
40
|
+
prebuilt-binary coverage. Newer Node usually works but may need to compile
|
|
41
|
+
the binary (build tools + network access).
|
|
42
|
+
- An **Anthropic API key** — get one at
|
|
43
|
+
[console.anthropic.com](https://console.anthropic.com/settings/keys), then
|
|
44
|
+
put it in your environment:
|
|
28
45
|
```bash
|
|
29
46
|
export ANTHROPIC_API_KEY=sk-ant-... # macOS/Linux
|
|
30
|
-
setx ANTHROPIC_API_KEY "sk-ant-..." # Windows (
|
|
47
|
+
setx ANTHROPIC_API_KEY "sk-ant-..." # Windows (open a NEW shell after)
|
|
31
48
|
```
|
|
32
|
-
|
|
49
|
+
The key is read from the environment and **never written to disk** by QAIOS.
|
|
50
|
+
- **Playwright** in your project, for `qaios run` / `snapshot` / `explore` / `a11y`:
|
|
33
51
|
```bash
|
|
34
52
|
npm i -D @playwright/test && npx playwright install
|
|
35
53
|
```
|
|
54
|
+
(`@playwright/test` is for `run`; `explore`/`a11y` use the `playwright`
|
|
55
|
+
package, which it pulls in.)
|
|
36
56
|
- For `qaios a11y`, also: `npm i -D @axe-core/playwright`
|
|
37
57
|
|
|
58
|
+
### Install troubleshooting
|
|
59
|
+
|
|
60
|
+
If `qaios init` fails with a SQLite/native-binding error
|
|
61
|
+
(`Could not load the native SQLite module`):
|
|
62
|
+
|
|
63
|
+
- Confirm your Node version: `node -v` (prefer 20 LTS).
|
|
64
|
+
- Rebuild the binary: `npm rebuild better-sqlite3` — or reinstall qaios.
|
|
65
|
+
- Behind a proxy/firewall? The prebuilt binary is fetched from GitHub
|
|
66
|
+
release assets; allow that host, or install C/C++ build tools so it can
|
|
67
|
+
compile locally.
|
|
68
|
+
|
|
69
|
+
`qaios doctor` will tell you exactly which check failed and what to run.
|
|
70
|
+
|
|
38
71
|
---
|
|
39
72
|
|
|
40
73
|
## 60-second quick start
|
|
@@ -126,8 +159,7 @@ mode: LITE # LITE | FULL | TRUST
|
|
|
126
159
|
app:
|
|
127
160
|
baseUrl: https://staging.myapp.com
|
|
128
161
|
llm:
|
|
129
|
-
provider: anthropic
|
|
130
|
-
apiKeyEnv: ANTHROPIC_API_KEY # key is read from env, never stored
|
|
162
|
+
provider: anthropic # anthropic | openai
|
|
131
163
|
maxLlmCallsPerWorkflow: 15
|
|
132
164
|
costAlertThresholdUsdCents: 50
|
|
133
165
|
testing:
|
|
@@ -143,6 +175,35 @@ defects:
|
|
|
143
175
|
|
|
144
176
|
`qaios config show` prints the resolved config; `qaios config set <key> <value>` validates against the schema before writing. **API keys are never written to config** — they come from environment variables.
|
|
145
177
|
|
|
178
|
+
### Choose your LLM provider
|
|
179
|
+
|
|
180
|
+
QAIOS works with **Anthropic** (default) or **OpenAI**. Set the provider in
|
|
181
|
+
`.qaios/config.yaml` and export that provider's key:
|
|
182
|
+
|
|
183
|
+
```yaml
|
|
184
|
+
# Anthropic (default)
|
|
185
|
+
llm:
|
|
186
|
+
provider: anthropic # reads ANTHROPIC_API_KEY
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
```yaml
|
|
190
|
+
# OpenAI
|
|
191
|
+
llm:
|
|
192
|
+
provider: openai # reads OPENAI_API_KEY
|
|
193
|
+
model: gpt-4o # optional; default gpt-4o (try gpt-4o-mini for lower cost)
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
export OPENAI_API_KEY=sk-...
|
|
198
|
+
qaios doctor # confirms the configured provider's key is reachable
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
The rest of QAIOS is provider-blind — every command (`test`, `run`, `fix`,
|
|
202
|
+
`explore`, `a11y`, …) works identically on either. Structured output uses each
|
|
203
|
+
provider's native guaranteed-schema mode (Anthropic forced tool-use / OpenAI
|
|
204
|
+
strict function calling), so generated artifacts stay schema-valid. You can
|
|
205
|
+
override the key's env-var name with `llm.apiKeyEnv` if needed.
|
|
206
|
+
|
|
146
207
|
### Operating modes
|
|
147
208
|
|
|
148
209
|
- **LITE** (default) — HIGH/CRITICAL risk pauses for review; routine work flows through.
|
|
@@ -157,8 +218,27 @@ qaios config set mode TRUST
|
|
|
157
218
|
|
|
158
219
|
## Cost & privacy
|
|
159
220
|
|
|
160
|
-
-
|
|
161
|
-
- **Local-first.** No telemetry, no phone-home. The only outbound traffic is (a) LLM calls to Anthropic with the prompts QAIOS builds, and (b) any MCP servers you configure. You bring your own API key; QAIOS does not proxy your requests.
|
|
221
|
+
- Skills run on Claude Sonnet (Anthropic) or gpt-4o (OpenAI) per your `llm.provider`. A typical `qaios test` costs ~**$0.04–0.10**. Each workflow is capped (default `min(15 calls, $0.50)`, configurable) and aborts if exceeded.
|
|
222
|
+
- **Local-first.** No telemetry, no phone-home. The only outbound traffic is (a) LLM calls to your configured provider (Anthropic or OpenAI) with the prompts QAIOS builds, and (b) any MCP servers you configure. You bring your own API key; QAIOS does not proxy your requests. See [SECURITY.md](https://github.com/qatonic/qaios/blob/main/SECURITY.md) for exactly what's sent.
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
## Exit codes (for CI)
|
|
227
|
+
|
|
228
|
+
Every command exits with a stable, documented code so you can gate pipelines:
|
|
229
|
+
|
|
230
|
+
| Code | Meaning | Example |
|
|
231
|
+
| ----- | ------------------------------- | ----------------------------------------------------------------------- |
|
|
232
|
+
| `0` | Success | tests generated; `doctor` all-green; `a11y` clean |
|
|
233
|
+
| `1` | User error / actionable failure | bad flag; `a11y` found real violations; `run` had failing tests |
|
|
234
|
+
| `2` | Gate blocked (informational) | a workflow paused for human review — run `qaios review` or pass `--yes` |
|
|
235
|
+
| `3` | Tool/dependency error | Playwright or axe not installed; SQLite binding missing |
|
|
236
|
+
| `4` | LLM error | rate limit, timeout, or invalid API key |
|
|
237
|
+
| `5` | Internal error | unexpected crash (re-run with `--debug` for a stack trace) |
|
|
238
|
+
| `130` | Cancelled | you pressed Ctrl+C |
|
|
239
|
+
|
|
240
|
+
`qaios doctor --json` and `--json` on most commands emit machine-readable
|
|
241
|
+
output for CI consumption.
|
|
162
242
|
|
|
163
243
|
---
|
|
164
244
|
|
|
@@ -19,6 +19,26 @@
|
|
|
19
19
|
// 0 = success (even when violations were found)
|
|
20
20
|
|
|
21
21
|
import { writeFileSync } from 'node:fs';
|
|
22
|
+
import { createRequire } from 'node:module';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { pathToFileURL } from 'node:url';
|
|
25
|
+
|
|
26
|
+
// Resolve a package from the USER's project (process.cwd()), not from
|
|
27
|
+
// where this script physically lives (inside QAIOS's install dir). With a
|
|
28
|
+
// bare `import('playwright')`, Node resolves relative to THIS file — so a
|
|
29
|
+
// globally-installed QAIOS can't see the user's locally-installed
|
|
30
|
+
// playwright. Resolving via a require rooted at the project's package.json
|
|
31
|
+
// (falling back to cwd) and importing the resolved file URL fixes that.
|
|
32
|
+
const projectRequire = createRequire(path.join(process.cwd(), 'package.json'));
|
|
33
|
+
// Resolve from the user's project, but import the BARE specifier so Node
|
|
34
|
+
// honors the package's `exports` map (giving the proper ESM shape). We only
|
|
35
|
+
// use require.resolve to (a) verify the package exists in the project and
|
|
36
|
+
// (b) make the bare import resolvable, by importing the resolved entry and
|
|
37
|
+
// reading a named property off it. Returns the live module namespace.
|
|
38
|
+
const importFromProject = async (specifier) => {
|
|
39
|
+
const resolved = projectRequire.resolve(specifier); // throws if not installed
|
|
40
|
+
return import(pathToFileURL(resolved).href);
|
|
41
|
+
};
|
|
22
42
|
|
|
23
43
|
const url = process.env.QAIOS_AXE_URL;
|
|
24
44
|
const out = process.env.QAIOS_AXE_OUTPUT;
|
|
@@ -36,8 +56,14 @@ if (!url || !out) {
|
|
|
36
56
|
|
|
37
57
|
let chromium, AxeBuilder;
|
|
38
58
|
try {
|
|
39
|
-
|
|
40
|
-
|
|
59
|
+
// Importing a CJS entry via file URL nests the package's exports under
|
|
60
|
+
// `default`; importing an ESM entry exposes them as named. Read from
|
|
61
|
+
// whichever is present so both layouts work.
|
|
62
|
+
const pw = await importFromProject('playwright');
|
|
63
|
+
chromium = pw.chromium ?? pw.default?.chromium;
|
|
64
|
+
const axe = await importFromProject('@axe-core/playwright');
|
|
65
|
+
AxeBuilder = axe.default ?? axe.AxeBuilder;
|
|
66
|
+
if (!chromium || !AxeBuilder) throw new Error('resolved module missing expected export');
|
|
41
67
|
} catch (err) {
|
|
42
68
|
process.stderr.write(
|
|
43
69
|
`axe-runner: failed to load 'playwright' and '@axe-core/playwright' from the project — ` +
|
|
@@ -12,6 +12,20 @@
|
|
|
12
12
|
// via truncateHtmlSafe so the script stays decoupled from the budget.
|
|
13
13
|
|
|
14
14
|
import { writeFileSync } from 'node:fs';
|
|
15
|
+
import { createRequire } from 'node:module';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { pathToFileURL } from 'node:url';
|
|
18
|
+
|
|
19
|
+
// Resolve `playwright` from the USER's project (process.cwd()), not from
|
|
20
|
+
// where this script lives inside QAIOS's install dir. A bare
|
|
21
|
+
// `import('playwright')` resolves relative to THIS file, so a globally
|
|
22
|
+
// installed QAIOS can't see the user's local playwright. See axe-runner.mjs
|
|
23
|
+
// for the full rationale.
|
|
24
|
+
const projectRequire = createRequire(path.join(process.cwd(), 'package.json'));
|
|
25
|
+
const importFromProject = async (specifier) => {
|
|
26
|
+
const resolved = projectRequire.resolve(specifier); // throws if not installed
|
|
27
|
+
return import(pathToFileURL(resolved).href);
|
|
28
|
+
};
|
|
15
29
|
|
|
16
30
|
const url = process.env.QAIOS_CAPTURE_URL;
|
|
17
31
|
const out = process.env.QAIOS_CAPTURE_OUTPUT;
|
|
@@ -27,7 +41,9 @@ if (!url || !out) {
|
|
|
27
41
|
|
|
28
42
|
let chromium;
|
|
29
43
|
try {
|
|
30
|
-
|
|
44
|
+
const pw = await importFromProject('playwright');
|
|
45
|
+
chromium = pw.chromium ?? pw.default?.chromium;
|
|
46
|
+
if (!chromium) throw new Error('playwright resolved but has no chromium export');
|
|
31
47
|
} catch (err) {
|
|
32
48
|
process.stderr.write(
|
|
33
49
|
`capture-page: failed to load 'playwright' from project — is it installed? ${err?.message ?? err}\n`,
|
package/dist/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { useApp, useInput, Box, Text } from 'ink';
|
|
3
3
|
import { useState } from 'react';
|
|
4
4
|
import { jsx, jsxs } from 'react/jsx-runtime';
|
|
5
|
-
import { readFileSync, existsSync, rmSync, mkdirSync, writeFileSync, statSync, mkdtempSync, copyFileSync, readdirSync } from 'fs';
|
|
5
|
+
import { realpathSync, readFileSync, existsSync, rmSync, mkdirSync, writeFileSync, statSync, mkdtempSync, copyFileSync, readdirSync } from 'fs';
|
|
6
6
|
import path12 from 'path';
|
|
7
7
|
import { fileURLToPath } from 'url';
|
|
8
8
|
import { Command, InvalidArgumentError } from 'commander';
|
|
@@ -11,6 +11,7 @@ import { createHash } from 'crypto';
|
|
|
11
11
|
import { z, ZodError } from 'zod';
|
|
12
12
|
import { monotonicFactory } from 'ulid';
|
|
13
13
|
import Anthropic from '@anthropic-ai/sdk';
|
|
14
|
+
import OpenAI from 'openai';
|
|
14
15
|
import { zodToJsonSchema } from 'zod-to-json-schema';
|
|
15
16
|
import { spawn, spawnSync } from 'child_process';
|
|
16
17
|
import { tmpdir } from 'os';
|
|
@@ -843,14 +844,23 @@ var QaiosConfig = z.object({
|
|
|
843
844
|
version: z.literal(1),
|
|
844
845
|
mode: Mode.default("LITE"),
|
|
845
846
|
llm: z.object({
|
|
846
|
-
provider
|
|
847
|
-
|
|
848
|
-
//
|
|
849
|
-
//
|
|
850
|
-
|
|
851
|
-
//
|
|
852
|
-
//
|
|
853
|
-
|
|
847
|
+
// Which LLM provider backs every skill. Default stays anthropic so
|
|
848
|
+
// existing projects are unchanged. Set `openai` to use OpenAI instead;
|
|
849
|
+
// the provider's own key env var (ANTHROPIC_API_KEY / OPENAI_API_KEY)
|
|
850
|
+
// is read automatically — see runtime/config/env.ts + llm/factory.ts.
|
|
851
|
+
provider: z.enum(["anthropic", "openai"]).default("anthropic"),
|
|
852
|
+
// Optional override of the env var that holds the API key. When unset,
|
|
853
|
+
// the factory picks the provider's conventional var.
|
|
854
|
+
apiKeyEnv: z.string().optional(),
|
|
855
|
+
// Optional explicit model id. When set, it overrides the per-tier
|
|
856
|
+
// models below for ALL tiers (single-model v0.1 behavior). When unset,
|
|
857
|
+
// the factory resolves the provider's default (Sonnet for anthropic,
|
|
858
|
+
// gpt-4o for openai).
|
|
859
|
+
model: z.string().optional(),
|
|
860
|
+
// v0.1: every tier maps to one model (claude-sonnet-4-6 for anthropic).
|
|
861
|
+
// There is no real tier routing yet. The skill-declared tier (1/2/3) is
|
|
862
|
+
// recorded in the audit log so v0.5 can light up real per-tier routing
|
|
863
|
+
// without re-tagging skills. Do not branch on tier in skill code.
|
|
854
864
|
models: z.object({
|
|
855
865
|
tier1: z.string().default("claude-sonnet-4-6"),
|
|
856
866
|
// v0.5 → opus
|
|
@@ -2033,18 +2043,29 @@ function computeEntryHash(entry) {
|
|
|
2033
2043
|
const { hash: _ignored, ...rest } = entry;
|
|
2034
2044
|
return sha256Hex2(canonicalize(rest));
|
|
2035
2045
|
}
|
|
2036
|
-
function
|
|
2037
|
-
const v = process.env[
|
|
2046
|
+
function readKey(name) {
|
|
2047
|
+
const v = process.env[name];
|
|
2038
2048
|
if (typeof v !== "string") return void 0;
|
|
2039
2049
|
return v.trim().length > 0 ? v : void 0;
|
|
2040
2050
|
}
|
|
2051
|
+
function readAnthropicApiKey() {
|
|
2052
|
+
return readKey("ANTHROPIC_API_KEY");
|
|
2053
|
+
}
|
|
2054
|
+
function readOpenAiApiKey() {
|
|
2055
|
+
return readKey("OPENAI_API_KEY");
|
|
2056
|
+
}
|
|
2057
|
+
function readProviderApiKey(provider, apiKeyEnvOverride) {
|
|
2058
|
+
if (apiKeyEnvOverride !== void 0 && apiKeyEnvOverride.trim().length > 0) {
|
|
2059
|
+
return readKey(apiKeyEnvOverride);
|
|
2060
|
+
}
|
|
2061
|
+
return provider === "openai" ? readOpenAiApiKey() : readAnthropicApiKey();
|
|
2062
|
+
}
|
|
2041
2063
|
function snapshotEnv() {
|
|
2042
|
-
const
|
|
2064
|
+
const a = readAnthropicApiKey();
|
|
2065
|
+
const o = readOpenAiApiKey();
|
|
2043
2066
|
return {
|
|
2044
|
-
anthropicApiKey: {
|
|
2045
|
-
|
|
2046
|
-
length: k?.length ?? 0
|
|
2047
|
-
}
|
|
2067
|
+
anthropicApiKey: { present: a !== void 0, length: a?.length ?? 0 },
|
|
2068
|
+
openaiApiKey: { present: o !== void 0, length: o?.length ?? 0 }
|
|
2048
2069
|
};
|
|
2049
2070
|
}
|
|
2050
2071
|
var PRICING = {
|
|
@@ -2079,16 +2100,52 @@ var PRICING = {
|
|
|
2079
2100
|
outputPerMTok: 4,
|
|
2080
2101
|
cacheReadPerMTok: 0.08,
|
|
2081
2102
|
cacheWritePerMTok: 1
|
|
2103
|
+
},
|
|
2104
|
+
// ── OpenAI (provider: openai) ──────────────────────────────────────────
|
|
2105
|
+
// USD per 1M tokens. OpenAI bills cached input at ~0.5× the input rate;
|
|
2106
|
+
// there is no separate cache-write charge, so cacheWritePerMTok is 0.
|
|
2107
|
+
"gpt-4o": {
|
|
2108
|
+
inputPerMTok: 2.5,
|
|
2109
|
+
outputPerMTok: 10,
|
|
2110
|
+
cacheReadPerMTok: 1.25,
|
|
2111
|
+
cacheWritePerMTok: 0
|
|
2112
|
+
},
|
|
2113
|
+
"gpt-4o-mini": {
|
|
2114
|
+
inputPerMTok: 0.15,
|
|
2115
|
+
outputPerMTok: 0.6,
|
|
2116
|
+
cacheReadPerMTok: 0.075,
|
|
2117
|
+
cacheWritePerMTok: 0
|
|
2118
|
+
},
|
|
2119
|
+
"gpt-4.1": {
|
|
2120
|
+
inputPerMTok: 2,
|
|
2121
|
+
outputPerMTok: 8,
|
|
2122
|
+
cacheReadPerMTok: 0.5,
|
|
2123
|
+
cacheWritePerMTok: 0
|
|
2124
|
+
},
|
|
2125
|
+
"gpt-4.1-mini": {
|
|
2126
|
+
inputPerMTok: 0.4,
|
|
2127
|
+
outputPerMTok: 1.6,
|
|
2128
|
+
cacheReadPerMTok: 0.1,
|
|
2129
|
+
cacheWritePerMTok: 0
|
|
2082
2130
|
}
|
|
2083
2131
|
};
|
|
2084
2132
|
var DEFAULT_PRICING = PRICING["claude-sonnet-4-6"];
|
|
2133
|
+
var _unknownModelWarned = /* @__PURE__ */ new Set();
|
|
2085
2134
|
function computeCostUsdCents(model, usage) {
|
|
2086
|
-
const p = PRICING[model]
|
|
2135
|
+
const p = PRICING[model];
|
|
2136
|
+
if (p === void 0 && !_unknownModelWarned.has(model)) {
|
|
2137
|
+
_unknownModelWarned.add(model);
|
|
2138
|
+
process.stderr.write(
|
|
2139
|
+
`(qaios) warning: no pricing for model "${model}" \u2014 billing at the default rate. Costs in the audit log are approximate for this model.
|
|
2140
|
+
`
|
|
2141
|
+
);
|
|
2142
|
+
}
|
|
2143
|
+
const pricing = p ?? DEFAULT_PRICING;
|
|
2087
2144
|
const tok = (n) => typeof n === "number" && Number.isFinite(n) && n > 0 ? n : 0;
|
|
2088
|
-
const inputUsd = tok(usage.inputTokens) / 1e6 *
|
|
2089
|
-
const outputUsd = tok(usage.outputTokens) / 1e6 *
|
|
2090
|
-
const cacheReadUsd = tok(usage.cacheReadTokens) / 1e6 *
|
|
2091
|
-
const cacheWriteUsd = tok(usage.cacheWriteTokens) / 1e6 *
|
|
2145
|
+
const inputUsd = tok(usage.inputTokens) / 1e6 * pricing.inputPerMTok;
|
|
2146
|
+
const outputUsd = tok(usage.outputTokens) / 1e6 * pricing.outputPerMTok;
|
|
2147
|
+
const cacheReadUsd = tok(usage.cacheReadTokens) / 1e6 * pricing.cacheReadPerMTok;
|
|
2148
|
+
const cacheWriteUsd = tok(usage.cacheWriteTokens) / 1e6 * pricing.cacheWritePerMTok;
|
|
2092
2149
|
const totalCents = (inputUsd + outputUsd + cacheReadUsd + cacheWriteUsd) * 100;
|
|
2093
2150
|
return Math.ceil(totalCents);
|
|
2094
2151
|
}
|
|
@@ -2177,6 +2234,260 @@ function mapResponse(response, latencyMs) {
|
|
|
2177
2234
|
stopReason: response.stop_reason
|
|
2178
2235
|
};
|
|
2179
2236
|
}
|
|
2237
|
+
var UNSUPPORTED_KEYWORDS = /* @__PURE__ */ new Set([
|
|
2238
|
+
"minLength",
|
|
2239
|
+
"maxLength",
|
|
2240
|
+
"pattern",
|
|
2241
|
+
"format",
|
|
2242
|
+
"minimum",
|
|
2243
|
+
"maximum",
|
|
2244
|
+
"exclusiveMinimum",
|
|
2245
|
+
"exclusiveMaximum",
|
|
2246
|
+
"multipleOf",
|
|
2247
|
+
"minItems",
|
|
2248
|
+
"maxItems",
|
|
2249
|
+
"uniqueItems",
|
|
2250
|
+
"minProperties",
|
|
2251
|
+
"maxProperties",
|
|
2252
|
+
"default",
|
|
2253
|
+
"$schema",
|
|
2254
|
+
"patternProperties"
|
|
2255
|
+
]);
|
|
2256
|
+
function isPlainObject(v) {
|
|
2257
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
2258
|
+
}
|
|
2259
|
+
function makeNullable(node) {
|
|
2260
|
+
const t = node["type"];
|
|
2261
|
+
if (typeof t === "string") {
|
|
2262
|
+
if (t === "null") return node;
|
|
2263
|
+
return { ...node, type: [t, "null"] };
|
|
2264
|
+
}
|
|
2265
|
+
if (Array.isArray(t)) {
|
|
2266
|
+
return t.includes("null") ? node : { ...node, type: [...t, "null"] };
|
|
2267
|
+
}
|
|
2268
|
+
return { anyOf: [node, { type: "null" }] };
|
|
2269
|
+
}
|
|
2270
|
+
function resolveRef(ref, root) {
|
|
2271
|
+
if (!ref.startsWith("#/")) return void 0;
|
|
2272
|
+
const parts = ref.slice(2).split("/").map((p) => p.replace(/~1/g, "/").replace(/~0/g, "~"));
|
|
2273
|
+
let cur = root;
|
|
2274
|
+
for (const part of parts) {
|
|
2275
|
+
if (!isPlainObject(cur) && !Array.isArray(cur)) return void 0;
|
|
2276
|
+
cur = cur[part];
|
|
2277
|
+
}
|
|
2278
|
+
return isPlainObject(cur) ? cur : void 0;
|
|
2279
|
+
}
|
|
2280
|
+
function inlineRefs(node, root, seen) {
|
|
2281
|
+
if (Array.isArray(node)) return node.map((n) => inlineRefs(n, root, seen));
|
|
2282
|
+
if (!isPlainObject(node)) return node;
|
|
2283
|
+
const ref = node["$ref"];
|
|
2284
|
+
if (typeof ref === "string") {
|
|
2285
|
+
if (seen.has(ref)) return { ...node };
|
|
2286
|
+
const target = resolveRef(ref, root);
|
|
2287
|
+
if (target !== void 0) {
|
|
2288
|
+
const next = new Set(seen).add(ref);
|
|
2289
|
+
const { $ref: _drop, ...siblings } = node;
|
|
2290
|
+
const inlined = inlineRefs(target, root, next);
|
|
2291
|
+
return { ...inlined, ...siblings };
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2294
|
+
const out = {};
|
|
2295
|
+
for (const [k, v] of Object.entries(node)) out[k] = inlineRefs(v, root, seen);
|
|
2296
|
+
return out;
|
|
2297
|
+
}
|
|
2298
|
+
function openaiStrictify(schema) {
|
|
2299
|
+
if (!isPlainObject(schema)) return schema;
|
|
2300
|
+
return strictifyNode(inlineRefs(schema, schema, /* @__PURE__ */ new Set()));
|
|
2301
|
+
}
|
|
2302
|
+
function strictifyNode(schema) {
|
|
2303
|
+
if (!isPlainObject(schema)) return schema;
|
|
2304
|
+
const out = {};
|
|
2305
|
+
for (const [key, value] of Object.entries(schema)) {
|
|
2306
|
+
if (UNSUPPORTED_KEYWORDS.has(key)) continue;
|
|
2307
|
+
if (key === "properties" && isPlainObject(value)) {
|
|
2308
|
+
const props = {};
|
|
2309
|
+
for (const [propName, propSchema] of Object.entries(value)) {
|
|
2310
|
+
props[propName] = strictifyNode(propSchema);
|
|
2311
|
+
}
|
|
2312
|
+
out["properties"] = props;
|
|
2313
|
+
continue;
|
|
2314
|
+
}
|
|
2315
|
+
if (key === "items") {
|
|
2316
|
+
out["items"] = Array.isArray(value) ? value.map((v) => strictifyNode(v)) : strictifyNode(value);
|
|
2317
|
+
continue;
|
|
2318
|
+
}
|
|
2319
|
+
if ((key === "anyOf" || key === "oneOf" || key === "allOf") && Array.isArray(value)) {
|
|
2320
|
+
out[key] = value.map((v) => strictifyNode(v));
|
|
2321
|
+
continue;
|
|
2322
|
+
}
|
|
2323
|
+
out[key] = value;
|
|
2324
|
+
}
|
|
2325
|
+
if (out["type"] === "object" && isPlainObject(out["properties"])) {
|
|
2326
|
+
const props = out["properties"];
|
|
2327
|
+
const originalRequired = new Set(
|
|
2328
|
+
Array.isArray(schema["required"]) ? schema["required"] : []
|
|
2329
|
+
);
|
|
2330
|
+
const allKeys = Object.keys(props);
|
|
2331
|
+
for (const k of allKeys) {
|
|
2332
|
+
if (!originalRequired.has(k)) {
|
|
2333
|
+
props[k] = makeNullable(props[k]);
|
|
2334
|
+
}
|
|
2335
|
+
}
|
|
2336
|
+
out["required"] = allKeys;
|
|
2337
|
+
out["additionalProperties"] = false;
|
|
2338
|
+
}
|
|
2339
|
+
return out;
|
|
2340
|
+
}
|
|
2341
|
+
var DEFAULT_OPENAI_MODEL = "gpt-4o";
|
|
2342
|
+
var DEFAULT_MAX_TOKENS2 = 4096;
|
|
2343
|
+
var OpenAiClient = class {
|
|
2344
|
+
client;
|
|
2345
|
+
explicitApiKey;
|
|
2346
|
+
defaultModel;
|
|
2347
|
+
defaultMaxTokens;
|
|
2348
|
+
constructor(opts = {}) {
|
|
2349
|
+
this.client = opts.client ?? null;
|
|
2350
|
+
this.explicitApiKey = opts.apiKey;
|
|
2351
|
+
this.defaultModel = opts.defaultModel ?? DEFAULT_OPENAI_MODEL;
|
|
2352
|
+
this.defaultMaxTokens = opts.defaultMaxTokens ?? DEFAULT_MAX_TOKENS2;
|
|
2353
|
+
}
|
|
2354
|
+
resolveClient() {
|
|
2355
|
+
if (this.client !== null) return this.client;
|
|
2356
|
+
const apiKey = this.explicitApiKey ?? readOpenAiApiKey();
|
|
2357
|
+
if (apiKey === void 0 || apiKey.trim().length === 0) {
|
|
2358
|
+
throw new LlmError({
|
|
2359
|
+
code: "qaios.llm.api_key_missing",
|
|
2360
|
+
message: 'OPENAI_API_KEY is not set (llm.provider is "openai").\nGet one at https://platform.openai.com/api-keys, then:\n export OPENAI_API_KEY=sk-\u2026\n`qaios doctor` will confirm the key is reachable.'
|
|
2361
|
+
});
|
|
2362
|
+
}
|
|
2363
|
+
const sdk = new OpenAI({ apiKey });
|
|
2364
|
+
this.client = sdk;
|
|
2365
|
+
return this.client;
|
|
2366
|
+
}
|
|
2367
|
+
async call(opts) {
|
|
2368
|
+
const client = this.resolveClient();
|
|
2369
|
+
const model = opts.model ?? this.defaultModel;
|
|
2370
|
+
const params = {
|
|
2371
|
+
model,
|
|
2372
|
+
max_tokens: opts.maxTokens ?? this.defaultMaxTokens,
|
|
2373
|
+
// OpenAI carries the system prompt as a leading system-role message.
|
|
2374
|
+
messages: [
|
|
2375
|
+
{ role: "system", content: opts.systemPrompt },
|
|
2376
|
+
{ role: "user", content: opts.userPrompt }
|
|
2377
|
+
]
|
|
2378
|
+
};
|
|
2379
|
+
if (opts.temperature !== void 0) params["temperature"] = opts.temperature;
|
|
2380
|
+
if (opts.tools && opts.tools.length > 0) {
|
|
2381
|
+
params["tools"] = opts.tools.map((t) => ({
|
|
2382
|
+
type: "function",
|
|
2383
|
+
function: {
|
|
2384
|
+
name: t.name,
|
|
2385
|
+
description: t.description,
|
|
2386
|
+
parameters: openaiStrictify(t.input_schema),
|
|
2387
|
+
strict: true
|
|
2388
|
+
}
|
|
2389
|
+
}));
|
|
2390
|
+
}
|
|
2391
|
+
if (opts.toolChoice !== void 0) {
|
|
2392
|
+
params["tool_choice"] = mapToolChoice(opts.toolChoice);
|
|
2393
|
+
}
|
|
2394
|
+
const reqOpts = {};
|
|
2395
|
+
if (opts.signal !== void 0) reqOpts.signal = opts.signal;
|
|
2396
|
+
const start = Date.now();
|
|
2397
|
+
const response = await client.chat.completions.create(params, reqOpts);
|
|
2398
|
+
const latencyMs = Date.now() - start;
|
|
2399
|
+
return mapResponse2(response, latencyMs);
|
|
2400
|
+
}
|
|
2401
|
+
};
|
|
2402
|
+
function mapToolChoice(choice) {
|
|
2403
|
+
switch (choice.type) {
|
|
2404
|
+
case "auto":
|
|
2405
|
+
return "auto";
|
|
2406
|
+
case "any":
|
|
2407
|
+
return "required";
|
|
2408
|
+
case "tool":
|
|
2409
|
+
return { type: "function", function: { name: choice.name } };
|
|
2410
|
+
}
|
|
2411
|
+
}
|
|
2412
|
+
function mapFinishReason(reason) {
|
|
2413
|
+
switch (reason) {
|
|
2414
|
+
case "stop":
|
|
2415
|
+
return "end_turn";
|
|
2416
|
+
case "tool_calls":
|
|
2417
|
+
case "function_call":
|
|
2418
|
+
return "tool_use";
|
|
2419
|
+
case "length":
|
|
2420
|
+
return "max_tokens";
|
|
2421
|
+
default:
|
|
2422
|
+
return reason;
|
|
2423
|
+
}
|
|
2424
|
+
}
|
|
2425
|
+
function mapResponse2(response, latencyMs) {
|
|
2426
|
+
const choice = response.choices[0];
|
|
2427
|
+
const message = choice?.message;
|
|
2428
|
+
const output = message?.content ?? "";
|
|
2429
|
+
const toolCalls = (message?.tool_calls ?? []).map((tc) => ({
|
|
2430
|
+
id: tc.id,
|
|
2431
|
+
name: tc.function.name,
|
|
2432
|
+
// OpenAI returns function arguments as a JSON STRING — parse to match the
|
|
2433
|
+
// parsed-object shape Anthropic's tool_use blocks give us. A malformed
|
|
2434
|
+
// payload surfaces as an LlmError rather than a silent {}.
|
|
2435
|
+
input: parseArguments(tc.function.arguments, tc.function.name)
|
|
2436
|
+
}));
|
|
2437
|
+
const promptTokens = response.usage?.prompt_tokens ?? 0;
|
|
2438
|
+
const cachedTokens = response.usage?.prompt_tokens_details?.cached_tokens ?? 0;
|
|
2439
|
+
const usage = {
|
|
2440
|
+
// OpenAI's prompt_tokens INCLUDES cached tokens; bill the uncached portion
|
|
2441
|
+
// at the input rate and the cached portion at the cache-read rate.
|
|
2442
|
+
inputTokens: Math.max(0, promptTokens - cachedTokens),
|
|
2443
|
+
outputTokens: response.usage?.completion_tokens ?? 0
|
|
2444
|
+
};
|
|
2445
|
+
if (cachedTokens > 0) usage.cacheReadTokens = cachedTokens;
|
|
2446
|
+
return {
|
|
2447
|
+
output,
|
|
2448
|
+
toolCalls,
|
|
2449
|
+
usage,
|
|
2450
|
+
model: response.model,
|
|
2451
|
+
latencyMs,
|
|
2452
|
+
costUsdCents: computeCostUsdCents(response.model, usage),
|
|
2453
|
+
stopReason: mapFinishReason(choice?.finish_reason ?? null)
|
|
2454
|
+
};
|
|
2455
|
+
}
|
|
2456
|
+
function parseArguments(raw, toolName) {
|
|
2457
|
+
let parsed;
|
|
2458
|
+
try {
|
|
2459
|
+
parsed = JSON.parse(raw);
|
|
2460
|
+
} catch (err) {
|
|
2461
|
+
throw new LlmError({
|
|
2462
|
+
code: "qaios.llm.malformed_tool_arguments",
|
|
2463
|
+
message: `OpenAI returned non-JSON arguments for tool "${toolName}".`,
|
|
2464
|
+
cause: err
|
|
2465
|
+
});
|
|
2466
|
+
}
|
|
2467
|
+
return stripNulls(parsed);
|
|
2468
|
+
}
|
|
2469
|
+
function stripNulls(value) {
|
|
2470
|
+
if (Array.isArray(value)) return value.map(stripNulls);
|
|
2471
|
+
if (value !== null && typeof value === "object") {
|
|
2472
|
+
const out = {};
|
|
2473
|
+
for (const [k, v] of Object.entries(value)) {
|
|
2474
|
+
if (v === null) continue;
|
|
2475
|
+
out[k] = stripNulls(v);
|
|
2476
|
+
}
|
|
2477
|
+
return out;
|
|
2478
|
+
}
|
|
2479
|
+
return value;
|
|
2480
|
+
}
|
|
2481
|
+
function defaultModelFor(provider) {
|
|
2482
|
+
return provider === "openai" ? DEFAULT_OPENAI_MODEL : DEFAULT_LLM_MODEL;
|
|
2483
|
+
}
|
|
2484
|
+
function createLlmClient(opts = {}) {
|
|
2485
|
+
if (opts.client !== void 0) return opts.client;
|
|
2486
|
+
const provider = opts.provider ?? "anthropic";
|
|
2487
|
+
const defaultModel = opts.model ?? defaultModelFor(provider);
|
|
2488
|
+
const clientOpts = opts.apiKey !== void 0 ? { apiKey: opts.apiKey, defaultModel } : { defaultModel };
|
|
2489
|
+
return provider === "openai" ? new OpenAiClient(clientOpts) : new LlmClient(clientOpts);
|
|
2490
|
+
}
|
|
2180
2491
|
var SkillError = class extends Error {
|
|
2181
2492
|
code;
|
|
2182
2493
|
skillId;
|
|
@@ -2708,13 +3019,14 @@ function resolveSpawn(bin, args) {
|
|
|
2708
3019
|
}
|
|
2709
3020
|
return { bin: resolved, args };
|
|
2710
3021
|
}
|
|
3022
|
+
var SUPPRESSED_WARNING_CODES = /* @__PURE__ */ new Set(["DEP0190", "DEP0040"]);
|
|
2711
3023
|
var _filterInstalled = false;
|
|
2712
3024
|
function installDeprecationWarningFilter() {
|
|
2713
3025
|
if (_filterInstalled) return;
|
|
2714
3026
|
_filterInstalled = true;
|
|
2715
3027
|
process.removeAllListeners("warning");
|
|
2716
3028
|
process.on("warning", (w) => {
|
|
2717
|
-
if (w.code
|
|
3029
|
+
if (w.code !== void 0 && SUPPRESSED_WARNING_CODES.has(w.code)) return;
|
|
2718
3030
|
process.stderr.write(`(node:${process.pid}) ${w.name}: ${w.message}
|
|
2719
3031
|
`);
|
|
2720
3032
|
if (w.stack !== void 0) process.stderr.write(`${w.stack}
|
|
@@ -6020,7 +6332,7 @@ function parseOpenApi(specPath) {
|
|
|
6020
6332
|
function isRecord(v) {
|
|
6021
6333
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
6022
6334
|
}
|
|
6023
|
-
function
|
|
6335
|
+
function resolveRef2(refValue, doc, depth = 0) {
|
|
6024
6336
|
if (depth > 4) return { $ref: refValue };
|
|
6025
6337
|
if (!refValue.startsWith("#/")) return { $ref: refValue };
|
|
6026
6338
|
const segments = refValue.slice(2).split("/").map((seg) => seg.replace(/~1/g, "/").replace(/~0/g, "~"));
|
|
@@ -6031,7 +6343,7 @@ function resolveRef(refValue, doc, depth = 0) {
|
|
|
6031
6343
|
if (cursor === void 0) return { $ref: refValue };
|
|
6032
6344
|
}
|
|
6033
6345
|
if (isRecord(cursor) && typeof cursor["$ref"] === "string") {
|
|
6034
|
-
return
|
|
6346
|
+
return resolveRef2(cursor["$ref"], doc, depth + 1);
|
|
6035
6347
|
}
|
|
6036
6348
|
return cursor;
|
|
6037
6349
|
}
|
|
@@ -6040,7 +6352,7 @@ function resolveRefsDeep(value, doc, depth = 0) {
|
|
|
6040
6352
|
if (Array.isArray(value)) return value.map((v) => resolveRefsDeep(v, doc, depth + 1));
|
|
6041
6353
|
if (!isRecord(value)) return value;
|
|
6042
6354
|
if (typeof value["$ref"] === "string") {
|
|
6043
|
-
const resolved =
|
|
6355
|
+
const resolved = resolveRef2(value["$ref"], doc);
|
|
6044
6356
|
return resolveRefsDeep(resolved, doc, depth + 1);
|
|
6045
6357
|
}
|
|
6046
6358
|
const out = {};
|
|
@@ -6498,7 +6810,7 @@ function runDoctor(opts = {}) {
|
|
|
6498
6810
|
const cwd = path12.resolve(opts.cwd ?? process.cwd());
|
|
6499
6811
|
const checks = [];
|
|
6500
6812
|
checks.push(checkNode());
|
|
6501
|
-
const apiKeyCheck =
|
|
6813
|
+
const apiKeyCheck = checkProviderApiKey(cwd);
|
|
6502
6814
|
checks.push(apiKeyCheck);
|
|
6503
6815
|
const qaiosDir = path12.join(cwd, ".qaios");
|
|
6504
6816
|
const qaiosExists = existsSync(qaiosDir) && safeIsDir(qaiosDir);
|
|
@@ -6567,23 +6879,42 @@ function checkNode() {
|
|
|
6567
6879
|
detail: `Node ${version} is below the required v${MIN_NODE_MAJOR}.`
|
|
6568
6880
|
};
|
|
6569
6881
|
}
|
|
6570
|
-
function
|
|
6571
|
-
const
|
|
6882
|
+
function resolveProviderFromConfig(cwd) {
|
|
6883
|
+
const candidate = path12.join(cwd, ".qaios", "config.yaml");
|
|
6884
|
+
if (!existsSync(candidate)) return { provider: "anthropic" };
|
|
6885
|
+
try {
|
|
6886
|
+
const raw = parse(readFileSync(candidate, "utf-8"));
|
|
6887
|
+
const parsed = QaiosConfig.safeParse(raw ?? { version: 1 });
|
|
6888
|
+
if (parsed.success) {
|
|
6889
|
+
const out = {
|
|
6890
|
+
provider: parsed.data.llm.provider
|
|
6891
|
+
};
|
|
6892
|
+
if (parsed.data.llm.apiKeyEnv !== void 0) out.apiKeyEnv = parsed.data.llm.apiKeyEnv;
|
|
6893
|
+
return out;
|
|
6894
|
+
}
|
|
6895
|
+
} catch {
|
|
6896
|
+
}
|
|
6897
|
+
return { provider: "anthropic" };
|
|
6898
|
+
}
|
|
6899
|
+
function checkProviderApiKey(cwd) {
|
|
6900
|
+
const { provider, apiKeyEnv } = resolveProviderFromConfig(cwd);
|
|
6901
|
+
const envVar = apiKeyEnv ?? (provider === "openai" ? "OPENAI_API_KEY" : "ANTHROPIC_API_KEY");
|
|
6902
|
+
const key = readProviderApiKey(provider, apiKeyEnv);
|
|
6572
6903
|
if (key !== void 0) {
|
|
6573
|
-
return { name:
|
|
6904
|
+
return { name: envVar, status: "ok", detail: `set in environment (provider: ${provider})` };
|
|
6574
6905
|
}
|
|
6575
|
-
const raw = process.env[
|
|
6906
|
+
const raw = process.env[envVar];
|
|
6576
6907
|
if (typeof raw === "string" && raw.length > 0) {
|
|
6577
6908
|
return {
|
|
6578
|
-
name:
|
|
6909
|
+
name: envVar,
|
|
6579
6910
|
status: "warn",
|
|
6580
6911
|
detail: "set but blank/whitespace-only \u2014 LLM commands will fail; export a real key"
|
|
6581
6912
|
};
|
|
6582
6913
|
}
|
|
6583
6914
|
return {
|
|
6584
|
-
name:
|
|
6915
|
+
name: envVar,
|
|
6585
6916
|
status: "warn",
|
|
6586
|
-
detail:
|
|
6917
|
+
detail: `not set; export it before running LLM-backed commands (provider: ${provider})`
|
|
6587
6918
|
};
|
|
6588
6919
|
}
|
|
6589
6920
|
function checkDb(dbPath) {
|
|
@@ -6873,12 +7204,20 @@ Use formal techniques (same enumeration as design.web). Prefer:
|
|
|
6873
7204
|
Rules:
|
|
6874
7205
|
- steps describe HTTP interactions: "POST /api/v1/users with body {email, password}",
|
|
6875
7206
|
not implementation details.
|
|
6876
|
-
- oracles describe response shape + status code + observable side effects.
|
|
7207
|
+
- oracles describe response shape + status code + observable side effects. EVERY oracle
|
|
6877
7208
|
MUST reference an HTTP status code (e.g. "200", "201", "401", "404") AND/OR a
|
|
6878
7209
|
response.<property> path so the writer skill can produce a deterministic assertion.
|
|
7210
|
+
An oracle that names neither is invalid \u2014 do not emit it.
|
|
6879
7211
|
- dataNeeds describe request body categories: "valid signup payload", "payload missing
|
|
6880
7212
|
email", "payload with email > 254 chars".
|
|
6881
7213
|
- For each endpoint, generate at minimum 3 scenarios; more if the endpoint is risk-tagged.
|
|
7214
|
+
- COVERAGE FLOOR (non-negotiable): across the suite you MUST include at least one
|
|
7215
|
+
scenario with testType="negative" (e.g. invalid auth / 4xx) AND at least one with
|
|
7216
|
+
testType="boundary" (e.g. a min/max field length or numeric edge). Suites missing
|
|
7217
|
+
either are incomplete.
|
|
7218
|
+
- Every requirement id provided in the input MUST be referenced by at least one
|
|
7219
|
+
scenario's requirementIds \u2014 do not leave a stated requirement uncovered, and do not
|
|
7220
|
+
cite a requirement id that wasn't given.
|
|
6882
7221
|
- Cross-endpoint dependency tests (e.g., create then read) get their own scenario with
|
|
6883
7222
|
testType=integration.
|
|
6884
7223
|
|
|
@@ -6953,19 +7292,26 @@ function checkAuthScenarios(output, endpoints) {
|
|
|
6953
7292
|
const authNeeded = endpoints.filter((e) => e.authRequired);
|
|
6954
7293
|
if (authNeeded.length === 0) return 1;
|
|
6955
7294
|
const scenarios = output.designSpec.scenarios;
|
|
6956
|
-
const
|
|
7295
|
+
const isAuthNegative = (s) => {
|
|
7296
|
+
if (s.testType !== "negative") return false;
|
|
7297
|
+
const oracleMentionsAuthCode = /\b401\b|\b403\b|unauthor|forbidden/i.test(s.oracle);
|
|
7298
|
+
const dataNeedsMentionsAuth = s.dataNeeds.some(
|
|
7299
|
+
(d) => /\b(missing|invalid|no|expired|wrong)\b.*(token|auth|key|credential|role)/i.test(d)
|
|
7300
|
+
);
|
|
7301
|
+
return oracleMentionsAuthCode || dataNeedsMentionsAuth;
|
|
7302
|
+
};
|
|
7303
|
+
const anyAuthTest = authNeeded.some((ep) => {
|
|
6957
7304
|
const re = endpointStepPattern(ep.path, ep.method);
|
|
6958
|
-
return scenarios.some((s) =>
|
|
6959
|
-
|
|
6960
|
-
|
|
6961
|
-
|
|
6962
|
-
|
|
6963
|
-
|
|
6964
|
-
|
|
6965
|
-
return s.testType === "negative" && matchesEndpoint && (oracleMentions401 || dataNeedsMentionsAuth);
|
|
6966
|
-
});
|
|
7305
|
+
return scenarios.some((s) => isAuthNegative(s) && re.test(s.steps.join("\n")));
|
|
7306
|
+
});
|
|
7307
|
+
const hasGenericAuthTest = scenarios.some(isAuthNegative);
|
|
7308
|
+
if (!anyAuthTest && !hasGenericAuthTest) return 0.5;
|
|
7309
|
+
const explicitlyCovered = authNeeded.filter((ep) => {
|
|
7310
|
+
const re = endpointStepPattern(ep.path, ep.method);
|
|
7311
|
+
return scenarios.some((s) => isAuthNegative(s) && re.test(s.steps.join("\n")));
|
|
6967
7312
|
}).length;
|
|
6968
|
-
|
|
7313
|
+
const fraction = explicitlyCovered / authNeeded.length;
|
|
7314
|
+
return Math.max(0.85, fraction);
|
|
6969
7315
|
}
|
|
6970
7316
|
var designApiSkill = {
|
|
6971
7317
|
id: "design.api",
|
|
@@ -8202,6 +8548,15 @@ var skills = {
|
|
|
8202
8548
|
"audit.a11y": auditA11ySkill
|
|
8203
8549
|
};
|
|
8204
8550
|
|
|
8551
|
+
// src/llm.ts
|
|
8552
|
+
function resolveLlmClient(injected, llmConfig) {
|
|
8553
|
+
if (injected !== void 0) return injected;
|
|
8554
|
+
const opts = {};
|
|
8555
|
+
if (llmConfig?.provider !== void 0) opts.provider = llmConfig.provider;
|
|
8556
|
+
if (llmConfig?.model !== void 0) opts.model = llmConfig.model;
|
|
8557
|
+
return createLlmClient(opts);
|
|
8558
|
+
}
|
|
8559
|
+
|
|
8205
8560
|
// src/commands/a11y.ts
|
|
8206
8561
|
function loadConfig(cwd) {
|
|
8207
8562
|
const candidate = path12.join(cwd, ".qaios", "config.yaml");
|
|
@@ -8242,7 +8597,7 @@ async function runA11y(opts) {
|
|
|
8242
8597
|
const storage = opts.storage ?? Storage.open(path12.join(qaiosDir, "workflows.db"), { skipMigrations: false });
|
|
8243
8598
|
const auditLogger = new AuditLogger(storage.db);
|
|
8244
8599
|
const workflowsRepo = new WorkflowsRepository(storage.db);
|
|
8245
|
-
const llm = opts.llm
|
|
8600
|
+
const llm = resolveLlmClient(opts.llm, config?.llm);
|
|
8246
8601
|
const writeOut = (line) => {
|
|
8247
8602
|
if (opts.quiet === true) return;
|
|
8248
8603
|
if (opts.json === true) process.stdout.write(JSON.stringify({ kind: "log", line }) + "\n");
|
|
@@ -8547,6 +8902,18 @@ async function runExplore(opts) {
|
|
|
8547
8902
|
}
|
|
8548
8903
|
};
|
|
8549
8904
|
}
|
|
8905
|
+
if (opts.duration !== void 0) {
|
|
8906
|
+
const d = opts.duration;
|
|
8907
|
+
if (!Number.isFinite(d) || !Number.isInteger(d) || d < 60) {
|
|
8908
|
+
return {
|
|
8909
|
+
exitCode: ExitCode.USER_ERROR,
|
|
8910
|
+
error: {
|
|
8911
|
+
code: "qaios.explore.invalid_duration",
|
|
8912
|
+
message: `--duration must be a whole number of seconds \u2265 60 (got ${String(d)}).`
|
|
8913
|
+
}
|
|
8914
|
+
};
|
|
8915
|
+
}
|
|
8916
|
+
}
|
|
8550
8917
|
const config = loadConfig2(cwd);
|
|
8551
8918
|
const memory = loadProjectMemory(cwd);
|
|
8552
8919
|
const mode = opts.mode ?? config?.mode ?? "LITE";
|
|
@@ -8554,7 +8921,7 @@ async function runExplore(opts) {
|
|
|
8554
8921
|
const storage = opts.storage ?? Storage.open(path12.join(qaiosDir, "workflows.db"), { skipMigrations: false });
|
|
8555
8922
|
const auditLogger = new AuditLogger(storage.db);
|
|
8556
8923
|
const workflowsRepo = new WorkflowsRepository(storage.db);
|
|
8557
|
-
const llm = opts.llm
|
|
8924
|
+
const llm = resolveLlmClient(opts.llm, config?.llm);
|
|
8558
8925
|
const writeOut = (line) => {
|
|
8559
8926
|
if (opts.quiet === true) return;
|
|
8560
8927
|
if (opts.json === true) {
|
|
@@ -8965,7 +9332,7 @@ async function runFix(opts) {
|
|
|
8965
9332
|
const testResultsRepo = new TestResultsRepository(storage.db);
|
|
8966
9333
|
const workflowsRepo = new WorkflowsRepository(storage.db);
|
|
8967
9334
|
const config = loadConfig3(cwd);
|
|
8968
|
-
const llm = opts.llm
|
|
9335
|
+
const llm = resolveLlmClient(opts.llm, config?.llm);
|
|
8969
9336
|
const mode = opts.mode ?? config?.mode ?? "LITE";
|
|
8970
9337
|
const writeOut = (line) => {
|
|
8971
9338
|
if (opts.quiet === true) return;
|
|
@@ -9594,8 +9961,6 @@ function runInit(opts = {}) {
|
|
|
9594
9961
|
if (existsSync(qaiosDir) && opts.force) {
|
|
9595
9962
|
rmSync(qaiosDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
9596
9963
|
}
|
|
9597
|
-
mkdirSync(qaiosDir, { recursive: true });
|
|
9598
|
-
const filesWritten = [];
|
|
9599
9964
|
const modeParse = Mode.safeParse(opts.mode ?? "LITE");
|
|
9600
9965
|
if (!modeParse.success) {
|
|
9601
9966
|
return {
|
|
@@ -9614,9 +9979,7 @@ function runInit(opts = {}) {
|
|
|
9614
9979
|
testDir: opts.testDir ?? detection.testDir ?? "tests"
|
|
9615
9980
|
}
|
|
9616
9981
|
});
|
|
9617
|
-
|
|
9618
|
-
writeFileSync(configPath, stringify(config), "utf-8");
|
|
9619
|
-
filesWritten.push(path12.relative(cwd, configPath));
|
|
9982
|
+
mkdirSync(qaiosDir, { recursive: true });
|
|
9620
9983
|
const dbPath = path12.join(qaiosDir, "workflows.db");
|
|
9621
9984
|
let migrations;
|
|
9622
9985
|
try {
|
|
@@ -9624,16 +9987,33 @@ function runInit(opts = {}) {
|
|
|
9624
9987
|
migrations = storage.runMigrations();
|
|
9625
9988
|
storage.close();
|
|
9626
9989
|
} catch (err) {
|
|
9990
|
+
try {
|
|
9991
|
+
rmSync(qaiosDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
|
9992
|
+
} catch {
|
|
9993
|
+
}
|
|
9994
|
+
const raw = err.message ?? String(err);
|
|
9995
|
+
const isBindingError = /bindings file|was compiled against|NODE_MODULE_VERSION|\.node/i.test(
|
|
9996
|
+
raw
|
|
9997
|
+
);
|
|
9998
|
+
const message = isBindingError ? `Could not load the native SQLite module (better-sqlite3). This usually means the prebuilt binary didn't download or doesn't match your Node version.
|
|
9999
|
+
\u2022 Ensure you're on Node 20 LTS (run \`node -v\`).
|
|
10000
|
+
\u2022 Reinstall to fetch/rebuild the binary: \`npm rebuild better-sqlite3\` (or reinstall qaios).
|
|
10001
|
+
\u2022 Behind a proxy/firewall? The binary is fetched from GitHub releases \u2014 allow that, or install build tools so it can compile.
|
|
10002
|
+
Original error: ${raw}` : `Failed to initialise workflows.db: ${raw}`;
|
|
9627
10003
|
return {
|
|
9628
|
-
exitCode: ExitCode.INTERNAL,
|
|
10004
|
+
exitCode: err instanceof StorageError ? ExitCode.INTERNAL : ExitCode.TOOL_ERROR,
|
|
9629
10005
|
error: {
|
|
9630
|
-
code: err instanceof StorageError ? err.code : "qaios.init.db_failed",
|
|
9631
|
-
message
|
|
10006
|
+
code: isBindingError ? "qaios.init.sqlite_binding_missing" : err instanceof StorageError ? err.code : "qaios.init.db_failed",
|
|
10007
|
+
message
|
|
9632
10008
|
},
|
|
9633
10009
|
detection
|
|
9634
10010
|
};
|
|
9635
10011
|
}
|
|
10012
|
+
const filesWritten = [];
|
|
9636
10013
|
filesWritten.push(path12.relative(cwd, dbPath));
|
|
10014
|
+
const configPath = path12.join(qaiosDir, "config.yaml");
|
|
10015
|
+
writeFileSync(configPath, stringify(config), "utf-8");
|
|
10016
|
+
filesWritten.push(path12.relative(cwd, configPath));
|
|
9637
10017
|
const gitignorePath = path12.join(qaiosDir, ".gitignore");
|
|
9638
10018
|
writeFileSync(gitignorePath, QAIOS_GITIGNORE, "utf-8");
|
|
9639
10019
|
filesWritten.push(path12.relative(cwd, gitignorePath));
|
|
@@ -9732,7 +10112,7 @@ function readRawConfig(configPath) {
|
|
|
9732
10112
|
}
|
|
9733
10113
|
return parsed;
|
|
9734
10114
|
}
|
|
9735
|
-
function
|
|
10115
|
+
function readKey2(obj, key) {
|
|
9736
10116
|
const segments = key.split(".");
|
|
9737
10117
|
let cursor = obj;
|
|
9738
10118
|
for (const seg of segments) {
|
|
@@ -9823,7 +10203,7 @@ function getValue(configPath, opts, writeOut) {
|
|
|
9823
10203
|
}
|
|
9824
10204
|
return { exitCode: ExitCode.SUCCESS, value: raw };
|
|
9825
10205
|
}
|
|
9826
|
-
const value =
|
|
10206
|
+
const value = readKey2(raw, opts.key);
|
|
9827
10207
|
if (value === void 0) {
|
|
9828
10208
|
return {
|
|
9829
10209
|
exitCode: ExitCode.USER_ERROR,
|
|
@@ -10214,6 +10594,16 @@ async function testServer(repo, opts, writeOut) {
|
|
|
10214
10594
|
if (ownsClient) await client.close();
|
|
10215
10595
|
}
|
|
10216
10596
|
}
|
|
10597
|
+
function loadLlmConfig(cwd) {
|
|
10598
|
+
const candidate = path12.join(cwd, ".qaios", "config.yaml");
|
|
10599
|
+
if (!existsSync(candidate)) return void 0;
|
|
10600
|
+
try {
|
|
10601
|
+
const parsed = parse(readFileSync(candidate, "utf-8"));
|
|
10602
|
+
return parsed?.llm;
|
|
10603
|
+
} catch {
|
|
10604
|
+
return void 0;
|
|
10605
|
+
}
|
|
10606
|
+
}
|
|
10217
10607
|
async function applyDecision(args) {
|
|
10218
10608
|
const { gate, action, gatesRepo, auditLogger, orchestrator, skipResume } = args;
|
|
10219
10609
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -10274,7 +10664,7 @@ async function runReview(opts) {
|
|
|
10274
10664
|
const storage = opts.storage ?? Storage.open(path12.join(qaiosDir, "workflows.db"), { skipMigrations: false });
|
|
10275
10665
|
const auditLogger = new AuditLogger(storage.db);
|
|
10276
10666
|
const gatesRepo = new GatesRepository(storage.db);
|
|
10277
|
-
const llm = opts.llm
|
|
10667
|
+
const llm = resolveLlmClient(opts.llm, loadLlmConfig(cwd));
|
|
10278
10668
|
try {
|
|
10279
10669
|
const pending = gatesRepo.listPending(opts.workflowId);
|
|
10280
10670
|
if (pending.length === 0) {
|
|
@@ -10499,8 +10889,8 @@ async function runRun(opts) {
|
|
|
10499
10889
|
const ownsStorage = opts.storage === void 0;
|
|
10500
10890
|
const storage = opts.storage ?? Storage.open(path12.join(qaiosDir, "workflows.db"), { skipMigrations: false });
|
|
10501
10891
|
const auditLogger = new AuditLogger(storage.db);
|
|
10502
|
-
const llm = opts.llm ?? new LlmClient();
|
|
10503
10892
|
const config = loadRunConfig(cwd);
|
|
10893
|
+
const llm = resolveLlmClient(opts.llm, config?.llm);
|
|
10504
10894
|
const args = {
|
|
10505
10895
|
cwd,
|
|
10506
10896
|
noClassify: opts.noClassify === true,
|
|
@@ -10799,7 +11189,7 @@ async function runSnapshotCheck(opts) {
|
|
|
10799
11189
|
const storage = opts.storage ?? Storage.open(path12.join(qaiosDir, "workflows.db"), { skipMigrations: false });
|
|
10800
11190
|
const auditLogger = new AuditLogger(storage.db);
|
|
10801
11191
|
const baselineRepo = new VisualBaselinesRepository(storage.db);
|
|
10802
|
-
const llm = opts.llm
|
|
11192
|
+
const llm = resolveLlmClient(opts.llm, config?.llm);
|
|
10803
11193
|
let baselines = baselineRepo.list();
|
|
10804
11194
|
if (opts.feature !== void 0) {
|
|
10805
11195
|
const feat = opts.feature;
|
|
@@ -11375,7 +11765,7 @@ ${epSummary}`;
|
|
|
11375
11765
|
const ownsStorage = opts.storage === void 0;
|
|
11376
11766
|
const storage = opts.storage ?? Storage.open(path12.join(qaiosDir, "workflows.db"), { skipMigrations: false });
|
|
11377
11767
|
const auditLogger = new AuditLogger(storage.db);
|
|
11378
|
-
const llm = opts.llm
|
|
11768
|
+
const llm = resolveLlmClient(opts.llm, config?.llm);
|
|
11379
11769
|
const gateConfig = {};
|
|
11380
11770
|
if (opts.nonInteractive === true) gateConfig.nonInteractive = true;
|
|
11381
11771
|
if (config?.gates?.autoExpireOnTimeout !== void 0) {
|
|
@@ -12015,13 +12405,19 @@ function formatDoctor(checks) {
|
|
|
12015
12405
|
return lines.join("\n");
|
|
12016
12406
|
}
|
|
12017
12407
|
var invokedAsScript = (() => {
|
|
12018
|
-
|
|
12019
|
-
|
|
12020
|
-
|
|
12021
|
-
|
|
12022
|
-
|
|
12023
|
-
|
|
12024
|
-
|
|
12408
|
+
const argv1 = process.argv[1];
|
|
12409
|
+
if (!argv1) return false;
|
|
12410
|
+
const thisFile = fileURLToPath(import.meta.url);
|
|
12411
|
+
const realOf = (p) => {
|
|
12412
|
+
try {
|
|
12413
|
+
return realpathSync(p);
|
|
12414
|
+
} catch {
|
|
12415
|
+
return path12.resolve(p);
|
|
12416
|
+
}
|
|
12417
|
+
};
|
|
12418
|
+
if (realOf(argv1) === realOf(thisFile)) return true;
|
|
12419
|
+
const invokedName = path12.basename(argv1).replace(/\.(js|mjs|cjs)$/, "");
|
|
12420
|
+
return invokedName === "qaios";
|
|
12025
12421
|
})();
|
|
12026
12422
|
if (invokedAsScript) {
|
|
12027
12423
|
installDeprecationWarningFilter();
|
|
@@ -18,6 +18,19 @@
|
|
|
18
18
|
// used unconditionally for stability — same input → same PNG → same SHA.
|
|
19
19
|
|
|
20
20
|
import { writeFileSync } from 'node:fs';
|
|
21
|
+
import { createRequire } from 'node:module';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { pathToFileURL } from 'node:url';
|
|
24
|
+
|
|
25
|
+
// Resolve `playwright` from the USER's project (process.cwd()), not from
|
|
26
|
+
// where this script lives in QAIOS's install dir. See axe-runner.mjs for
|
|
27
|
+
// the full rationale (a bare import resolves relative to this file, so a
|
|
28
|
+
// global QAIOS can't see the user's local playwright).
|
|
29
|
+
const projectRequire = createRequire(path.join(process.cwd(), 'package.json'));
|
|
30
|
+
const importFromProject = async (specifier) => {
|
|
31
|
+
const resolved = projectRequire.resolve(specifier);
|
|
32
|
+
return import(pathToFileURL(resolved).href);
|
|
33
|
+
};
|
|
21
34
|
|
|
22
35
|
const url = process.env.QAIOS_SCREENSHOT_URL;
|
|
23
36
|
const out = process.env.QAIOS_SCREENSHOT_OUTPUT;
|
|
@@ -37,7 +50,9 @@ const fullPage = process.env.QAIOS_SCREENSHOT_FULL_PAGE !== '0';
|
|
|
37
50
|
|
|
38
51
|
let chromium;
|
|
39
52
|
try {
|
|
40
|
-
|
|
53
|
+
const pw = await importFromProject('playwright');
|
|
54
|
+
chromium = pw.chromium ?? pw.default?.chromium;
|
|
55
|
+
if (!chromium) throw new Error('playwright resolved but has no chromium export');
|
|
41
56
|
} catch (err) {
|
|
42
57
|
process.stderr.write(
|
|
43
58
|
`capture-screenshot: failed to load 'playwright' from project — is it installed? ${err?.message ?? err}\n`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qatonic_innovations/qaios",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "AI QA engineer in your terminal — designs, writes, runs, heals, and explores tests for web UI and APIs with audit-grade traceability.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
49
49
|
"better-sqlite3": "^11.7.0",
|
|
50
50
|
"commander": "^12.1.0",
|
|
51
|
+
"openai": "^4.77.0",
|
|
51
52
|
"ink": "^5.2.1",
|
|
52
53
|
"pino": "^9.5.0",
|
|
53
54
|
"pino-pretty": "^11.3.0",
|
|
@@ -65,10 +66,7 @@
|
|
|
65
66
|
"@types/pixelmatch": "^5.2.6",
|
|
66
67
|
"@types/pngjs": "^6.0.5",
|
|
67
68
|
"@types/react": "^18.3.28",
|
|
68
|
-
"ink-testing-library": "^4.0.0"
|
|
69
|
-
"@qaios/runtime": "0.0.0",
|
|
70
|
-
"@qaios/shared": "0.0.0",
|
|
71
|
-
"@qaios/skills": "0.0.0"
|
|
69
|
+
"ink-testing-library": "^4.0.0"
|
|
72
70
|
},
|
|
73
71
|
"scripts": {
|
|
74
72
|
"build": "tsup && node scripts/copy-templates.mjs && node scripts/copy-runtime-assets.mjs",
|