@kirrosh/zond 0.7.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 (102) hide show
  1. package/CHANGELOG.md +130 -0
  2. package/LICENSE +21 -0
  3. package/README.md +130 -0
  4. package/package.json +53 -0
  5. package/src/bun-types.d.ts +5 -0
  6. package/src/cli/commands/add-api.ts +51 -0
  7. package/src/cli/commands/ai-generate.ts +106 -0
  8. package/src/cli/commands/chat.ts +43 -0
  9. package/src/cli/commands/ci-init.ts +163 -0
  10. package/src/cli/commands/collections.ts +41 -0
  11. package/src/cli/commands/compare.ts +129 -0
  12. package/src/cli/commands/coverage.ts +156 -0
  13. package/src/cli/commands/doctor.ts +127 -0
  14. package/src/cli/commands/init.ts +84 -0
  15. package/src/cli/commands/mcp.ts +16 -0
  16. package/src/cli/commands/run.ts +156 -0
  17. package/src/cli/commands/runs.ts +108 -0
  18. package/src/cli/commands/serve.ts +22 -0
  19. package/src/cli/commands/update.ts +142 -0
  20. package/src/cli/commands/validate.ts +18 -0
  21. package/src/cli/index.ts +529 -0
  22. package/src/cli/output.ts +24 -0
  23. package/src/cli/runtime.ts +7 -0
  24. package/src/core/agent/agent-loop.ts +116 -0
  25. package/src/core/agent/context-manager.ts +41 -0
  26. package/src/core/agent/system-prompt.ts +28 -0
  27. package/src/core/agent/tools/diagnose-failure.ts +51 -0
  28. package/src/core/agent/tools/explore-api.ts +40 -0
  29. package/src/core/agent/tools/index.ts +46 -0
  30. package/src/core/agent/tools/query-results.ts +40 -0
  31. package/src/core/agent/tools/run-tests.ts +38 -0
  32. package/src/core/agent/tools/send-request.ts +44 -0
  33. package/src/core/agent/tools/validate-tests.ts +23 -0
  34. package/src/core/agent/types.ts +22 -0
  35. package/src/core/diagnostics/failure-hints.ts +63 -0
  36. package/src/core/generator/ai/ai-generator.ts +61 -0
  37. package/src/core/generator/ai/llm-client.ts +159 -0
  38. package/src/core/generator/ai/output-parser.ts +307 -0
  39. package/src/core/generator/ai/prompt-builder.ts +153 -0
  40. package/src/core/generator/ai/types.ts +56 -0
  41. package/src/core/generator/chunker.ts +47 -0
  42. package/src/core/generator/coverage-scanner.ts +87 -0
  43. package/src/core/generator/data-factory.ts +115 -0
  44. package/src/core/generator/endpoint-warnings.ts +43 -0
  45. package/src/core/generator/index.ts +12 -0
  46. package/src/core/generator/openapi-reader.ts +143 -0
  47. package/src/core/generator/schema-utils.ts +52 -0
  48. package/src/core/generator/serializer.ts +189 -0
  49. package/src/core/generator/types.ts +48 -0
  50. package/src/core/parser/filter.ts +14 -0
  51. package/src/core/parser/index.ts +21 -0
  52. package/src/core/parser/schema.ts +175 -0
  53. package/src/core/parser/types.ts +52 -0
  54. package/src/core/parser/variables.ts +154 -0
  55. package/src/core/parser/yaml-parser.ts +85 -0
  56. package/src/core/reporter/console.ts +175 -0
  57. package/src/core/reporter/index.ts +23 -0
  58. package/src/core/reporter/json.ts +9 -0
  59. package/src/core/reporter/junit.ts +78 -0
  60. package/src/core/reporter/types.ts +12 -0
  61. package/src/core/runner/assertions.ts +173 -0
  62. package/src/core/runner/execute-run.ts +97 -0
  63. package/src/core/runner/executor.ts +183 -0
  64. package/src/core/runner/http-client.ts +69 -0
  65. package/src/core/runner/index.ts +12 -0
  66. package/src/core/runner/types.ts +48 -0
  67. package/src/core/setup-api.ts +113 -0
  68. package/src/core/utils.ts +9 -0
  69. package/src/db/queries.ts +774 -0
  70. package/src/db/schema.ts +159 -0
  71. package/src/mcp/descriptions.ts +88 -0
  72. package/src/mcp/server.ts +52 -0
  73. package/src/mcp/tools/ci-init.ts +54 -0
  74. package/src/mcp/tools/coverage-analysis.ts +141 -0
  75. package/src/mcp/tools/describe-endpoint.ts +241 -0
  76. package/src/mcp/tools/explore-api.ts +84 -0
  77. package/src/mcp/tools/generate-and-save.ts +129 -0
  78. package/src/mcp/tools/generate-missing-tests.ts +91 -0
  79. package/src/mcp/tools/generate-tests-guide.ts +391 -0
  80. package/src/mcp/tools/manage-server.ts +86 -0
  81. package/src/mcp/tools/query-db.ts +255 -0
  82. package/src/mcp/tools/run-tests.ts +71 -0
  83. package/src/mcp/tools/save-test-suite.ts +218 -0
  84. package/src/mcp/tools/send-request.ts +63 -0
  85. package/src/mcp/tools/set-work-dir.ts +35 -0
  86. package/src/mcp/tools/setup-api.ts +84 -0
  87. package/src/mcp/tools/validate-tests.ts +43 -0
  88. package/src/tui/chat-ui.ts +150 -0
  89. package/src/web/data/collection-state.ts +360 -0
  90. package/src/web/routes/api.ts +234 -0
  91. package/src/web/routes/dashboard.ts +313 -0
  92. package/src/web/routes/runs.ts +64 -0
  93. package/src/web/schemas.ts +121 -0
  94. package/src/web/server.ts +134 -0
  95. package/src/web/static/htmx.min.js +1 -0
  96. package/src/web/static/style.css +827 -0
  97. package/src/web/views/endpoints-tab.ts +170 -0
  98. package/src/web/views/health-strip.ts +92 -0
  99. package/src/web/views/layout.ts +48 -0
  100. package/src/web/views/results.ts +209 -0
  101. package/src/web/views/runs-tab.ts +126 -0
  102. package/src/web/views/suites-tab.ts +153 -0
@@ -0,0 +1,163 @@
1
+ import { resolve, dirname } from "path";
2
+ import { existsSync, mkdirSync, writeFileSync } from "fs";
3
+ import { printSuccess, printError } from "../output.ts";
4
+
5
+ export interface CiInitOptions {
6
+ platform?: "github" | "gitlab";
7
+ force: boolean;
8
+ dir?: string;
9
+ }
10
+
11
+ const GH_ACTIONS_TEMPLATE = `name: API Tests
12
+ on:
13
+ push:
14
+ branches: [main]
15
+ pull_request:
16
+ schedule:
17
+ - cron: "0 */6 * * *"
18
+ workflow_dispatch:
19
+ repository_dispatch:
20
+ types: [api-updated]
21
+
22
+ permissions:
23
+ contents: read
24
+ checks: write
25
+ pull-requests: write
26
+
27
+ jobs:
28
+ test:
29
+ runs-on: ubuntu-latest
30
+ steps:
31
+ - uses: actions/checkout@v4
32
+
33
+ - name: Install zond
34
+ run: curl -fsSL https://raw.githubusercontent.com/kirrosh/zond/master/install.sh | sh
35
+
36
+ - name: Check coverage
37
+ run: zond coverage --api myapi --fail-on-coverage 60
38
+ # Fails if coverage drops below 60% — adjust threshold as needed
39
+
40
+ - name: Run smoke tests (read-only, safe for production)
41
+ run: |
42
+ mkdir -p test-results
43
+ zond run apis/ --tag smoke --safe --report junit --no-db > test-results/smoke.xml
44
+ # Use --env-var "API_KEY=\${{ secrets.API_KEY }}" to inject secrets without writing to disk
45
+ continue-on-error: true
46
+
47
+ - name: Run CRUD tests (staging only)
48
+ run: |
49
+ zond run apis/ --tag crud --env staging --report junit --no-db > test-results/crud.xml
50
+ # Add --env-var "BASE_URL=\${{ secrets.STAGING_URL }}" for staging URL
51
+ continue-on-error: true
52
+
53
+ - name: Publish test results
54
+ uses: EnricoMi/publish-unit-test-result-action@v2
55
+ if: always()
56
+ with:
57
+ files: test-results/*.xml
58
+
59
+ - uses: actions/upload-artifact@v4
60
+ if: always()
61
+ with:
62
+ name: test-results
63
+ path: test-results/
64
+ `;
65
+
66
+ const GITLAB_CI_TEMPLATE = `# Trigger via API: curl -X POST --form ref=main --form token=TRIGGER_TOKEN $CI_API_V4_URL/projects/$CI_PROJECT_ID/trigger/pipeline
67
+
68
+ variables:
69
+ # Set API_KEY in GitLab CI/CD → Settings → Variables
70
+ API_KEY: ""
71
+
72
+ api-coverage:
73
+ image: ubuntu:latest
74
+ before_script:
75
+ - apt-get update -qq && apt-get install -y -qq curl
76
+ - curl -fsSL https://raw.githubusercontent.com/kirrosh/zond/master/install.sh | sh
77
+ script:
78
+ - zond coverage --api myapi --fail-on-coverage 60
79
+
80
+ api-smoke:
81
+ image: ubuntu:latest
82
+ before_script:
83
+ - apt-get update -qq && apt-get install -y -qq curl
84
+ - curl -fsSL https://raw.githubusercontent.com/kirrosh/zond/master/install.sh | sh
85
+ script:
86
+ - mkdir -p test-results
87
+ # Use --env-var to inject secrets without writing to disk
88
+ - zond run apis/ --tag smoke --safe --report junit --no-db --env-var "API_KEY=$API_KEY" > test-results/smoke.xml
89
+ allow_failure:
90
+ exit_codes: 1
91
+ artifacts:
92
+ when: always
93
+ reports:
94
+ junit: test-results/smoke.xml
95
+
96
+ api-crud:
97
+ image: ubuntu:latest
98
+ before_script:
99
+ - apt-get update -qq && apt-get install -y -qq curl
100
+ - curl -fsSL https://raw.githubusercontent.com/kirrosh/zond/master/install.sh | sh
101
+ script:
102
+ - mkdir -p test-results
103
+ - zond run apis/ --tag crud --env staging --report junit --no-db > test-results/crud.xml
104
+ allow_failure:
105
+ exit_codes: 1
106
+ artifacts:
107
+ when: always
108
+ reports:
109
+ junit: test-results/crud.xml
110
+ `;
111
+
112
+ function writeIfMissing(filePath: string, content: string, force: boolean): boolean {
113
+ if (!force && existsSync(filePath)) {
114
+ console.log(` Skipped ${filePath} (already exists, use --force to overwrite)`);
115
+ return false;
116
+ }
117
+ const dir = dirname(filePath);
118
+ if (!existsSync(dir)) {
119
+ mkdirSync(dir, { recursive: true });
120
+ }
121
+ writeFileSync(filePath, content, "utf-8");
122
+ console.log(` Created ${filePath}`);
123
+ return true;
124
+ }
125
+
126
+ function detectPlatform(cwd: string): "github" | "gitlab" | undefined {
127
+ if (existsSync(resolve(cwd, ".github"))) return "github";
128
+ if (existsSync(resolve(cwd, ".gitlab-ci.yml"))) return "gitlab";
129
+ return undefined;
130
+ }
131
+
132
+ export async function ciInitCommand(options: CiInitOptions): Promise<number> {
133
+ const cwd = options.dir ? resolve(options.dir) : process.cwd();
134
+ let platform = options.platform;
135
+
136
+ if (!platform) {
137
+ platform = detectPlatform(cwd);
138
+ if (!platform) {
139
+ platform = "github";
140
+ console.log("No CI platform detected, defaulting to GitHub Actions.\n");
141
+ } else {
142
+ console.log(`Detected ${platform === "github" ? "GitHub Actions" : "GitLab CI"}.\n`);
143
+ }
144
+ }
145
+
146
+ console.log(`Generating ${platform === "github" ? "GitHub Actions" : "GitLab CI"} workflow...\n`);
147
+
148
+ let created = false;
149
+
150
+ if (platform === "github") {
151
+ const targetPath = resolve(cwd, ".github/workflows/api-tests.yml");
152
+ created = writeIfMissing(targetPath, GH_ACTIONS_TEMPLATE, options.force);
153
+ } else {
154
+ const targetPath = resolve(cwd, ".gitlab-ci.yml");
155
+ created = writeIfMissing(targetPath, GITLAB_CI_TEMPLATE, options.force);
156
+ }
157
+
158
+ if (created) {
159
+ printSuccess("CI workflow created. Commit and push to activate.");
160
+ }
161
+
162
+ return 0;
163
+ }
@@ -0,0 +1,41 @@
1
+ import { getDb } from "../../db/schema.ts";
2
+ import { listCollections } from "../../db/queries.ts";
3
+ import { formatDuration } from "../../core/reporter/console.ts";
4
+
5
+ export function collectionsCommand(dbPath?: string): number {
6
+ getDb(dbPath);
7
+ const collections = listCollections();
8
+
9
+ if (collections.length === 0) {
10
+ console.log("No collections found.");
11
+ console.log("Hint: use `zond generate --from <spec>` to create a collection automatically.");
12
+ return 0;
13
+ }
14
+
15
+ // Print table header
16
+ const header = [
17
+ "ID".padEnd(5),
18
+ "Name".padEnd(30),
19
+ "Runs".padEnd(6),
20
+ "Pass Rate".padEnd(11),
21
+ "Last Run".padEnd(20),
22
+ ].join(" ");
23
+
24
+ console.log(header);
25
+ console.log("-".repeat(header.length));
26
+
27
+ for (const c of collections) {
28
+ const passRate = c.total_runs > 0 ? `${c.pass_rate}%` : "-";
29
+ const lastRun = c.last_run_at ?? "-";
30
+ const row = [
31
+ String(c.id).padEnd(5),
32
+ c.name.slice(0, 30).padEnd(30),
33
+ String(c.total_runs).padEnd(6),
34
+ passRate.padEnd(11),
35
+ lastRun.slice(0, 20).padEnd(20),
36
+ ].join(" ");
37
+ console.log(row);
38
+ }
39
+
40
+ return 0;
41
+ }
@@ -0,0 +1,129 @@
1
+ import { getDb } from "../../db/schema.ts";
2
+ import { getRunById, getResultsByRunId } from "../../db/queries.ts";
3
+ import { printError } from "../output.ts";
4
+
5
+ const RESET = "\x1b[0m";
6
+ const GREEN = "\x1b[32m";
7
+ const RED = "\x1b[31m";
8
+ const YELLOW = "\x1b[33m";
9
+ const BOLD = "\x1b[1m";
10
+
11
+ function useColor(): boolean {
12
+ return process.stdout.isTTY ?? false;
13
+ }
14
+
15
+ export interface CompareOptions {
16
+ runA: number;
17
+ runB: number;
18
+ dbPath?: string;
19
+ }
20
+
21
+ export async function compareCommand(options: CompareOptions): Promise<number> {
22
+ const { runA, runB, dbPath } = options;
23
+
24
+ try {
25
+ getDb(dbPath);
26
+
27
+ const runARecord = getRunById(runA);
28
+ const runBRecord = getRunById(runB);
29
+
30
+ if (!runARecord) {
31
+ printError(`Run #${runA} not found`);
32
+ return 2;
33
+ }
34
+ if (!runBRecord) {
35
+ printError(`Run #${runB} not found`);
36
+ return 2;
37
+ }
38
+
39
+ const resultsA = getResultsByRunId(runA);
40
+ const resultsB = getResultsByRunId(runB);
41
+
42
+ // Build lookup maps: "suite_name::test_name" → status
43
+ const mapA = new Map<string, string>();
44
+ const mapB = new Map<string, string>();
45
+
46
+ for (const r of resultsA) {
47
+ mapA.set(`${r.suite_name}::${r.test_name}`, r.status);
48
+ }
49
+ for (const r of resultsB) {
50
+ mapB.set(`${r.suite_name}::${r.test_name}`, r.status);
51
+ }
52
+
53
+ const regressions: Array<{ suite: string; test: string; before: string; after: string }> = [];
54
+ const fixes: Array<{ suite: string; test: string; before: string; after: string }> = [];
55
+ const unchanged: number[] = [];
56
+ let newTests = 0;
57
+ let removedTests = 0;
58
+
59
+ // Check all keys from B (current run)
60
+ for (const [key, statusB] of mapB) {
61
+ const statusA = mapA.get(key);
62
+ if (statusA === undefined) {
63
+ newTests++;
64
+ continue;
65
+ }
66
+ const wasPass = statusA === "pass";
67
+ const isPass = statusB === "pass";
68
+ const wasFail = statusA === "fail" || statusA === "error";
69
+ const isFail = statusB === "fail" || statusB === "error";
70
+
71
+ const [suite, test] = key.split("::") as [string, string];
72
+
73
+ if (wasPass && isFail) {
74
+ regressions.push({ suite, test, before: statusA, after: statusB });
75
+ } else if (wasFail && isPass) {
76
+ fixes.push({ suite, test, before: statusA, after: statusB });
77
+ } else {
78
+ unchanged.push(1);
79
+ }
80
+ }
81
+
82
+ // Count removed tests
83
+ for (const key of mapA.keys()) {
84
+ if (!mapB.has(key)) removedTests++;
85
+ }
86
+
87
+ const color = useColor();
88
+
89
+ // Header
90
+ console.log(`\nComparing run #${runA} (${runARecord.started_at.slice(0, 19)}) → run #${runB} (${runBRecord.started_at.slice(0, 19)})\n`);
91
+
92
+ // Summary line
93
+ const parts = [
94
+ `${color ? BOLD : ""}${regressions.length} regressions${color ? RESET : ""}`,
95
+ `${fixes.length} fixes`,
96
+ `${unchanged.length} unchanged`,
97
+ ];
98
+ if (newTests > 0) parts.push(`${newTests} new`);
99
+ if (removedTests > 0) parts.push(`${removedTests} removed`);
100
+ console.log(parts.join(" | ") + "\n");
101
+
102
+ // Regressions
103
+ if (regressions.length > 0) {
104
+ console.log(`${color ? RED + BOLD : ""}Regressions (pass → fail):${color ? RESET : ""}`);
105
+ for (const r of regressions) {
106
+ console.log(` ${color ? RED : ""}✗${color ? RESET : ""} [${r.suite}] ${r.test} (${r.before} → ${r.after})`);
107
+ }
108
+ console.log("");
109
+ }
110
+
111
+ // Fixes
112
+ if (fixes.length > 0) {
113
+ console.log(`${color ? GREEN : ""}Fixes (fail → pass):${color ? RESET : ""}`);
114
+ for (const f of fixes) {
115
+ console.log(` ${color ? GREEN : ""}✓${color ? RESET : ""} [${f.suite}] ${f.test} (${f.before} → ${f.after})`);
116
+ }
117
+ console.log("");
118
+ }
119
+
120
+ if (regressions.length === 0 && fixes.length === 0) {
121
+ console.log(`${color ? GREEN : ""}No regressions detected.${color ? RESET : ""}`);
122
+ }
123
+
124
+ return regressions.length > 0 ? 1 : 0;
125
+ } catch (err) {
126
+ printError(err instanceof Error ? err.message : String(err));
127
+ return 2;
128
+ }
129
+ }
@@ -0,0 +1,156 @@
1
+ import { readOpenApiSpec, extractEndpoints, scanCoveredEndpoints, filterUncoveredEndpoints, normalizePath, specPathToRegex, analyzeEndpoints } from "../../core/generator/index.ts";
2
+ import { getDb } from "../../db/schema.ts";
3
+ import { getResultsByRunId, getRunById } from "../../db/queries.ts";
4
+ import { printError, printSuccess } from "../output.ts";
5
+
6
+ export interface CoverageOptions {
7
+ spec: string;
8
+ tests: string;
9
+ failOnCoverage?: number;
10
+ runId?: number;
11
+ }
12
+
13
+ const RESET = "\x1b[0m";
14
+ const GREEN = "\x1b[32m";
15
+ const RED = "\x1b[31m";
16
+ const YELLOW = "\x1b[33m";
17
+ const DIM = "\x1b[2m";
18
+
19
+ function useColor(): boolean {
20
+ return process.stdout.isTTY ?? false;
21
+ }
22
+
23
+ function extractPathFromUrl(url: string): string | null {
24
+ try {
25
+ return new URL(url).pathname;
26
+ } catch {
27
+ return url.startsWith("/") ? url : null;
28
+ }
29
+ }
30
+
31
+ export async function coverageCommand(options: CoverageOptions): Promise<number> {
32
+ const { spec, tests } = options;
33
+
34
+ try {
35
+ const doc = await readOpenApiSpec(spec);
36
+ const allEndpoints = extractEndpoints(doc);
37
+
38
+ if (allEndpoints.length === 0) {
39
+ printError("No endpoints found in the OpenAPI spec");
40
+ return 1;
41
+ }
42
+
43
+ const covered = await scanCoveredEndpoints(tests);
44
+ const uncovered = filterUncoveredEndpoints(allEndpoints, covered);
45
+ const coveredCount = allEndpoints.length - uncovered.length;
46
+ const percentage = Math.round((coveredCount / allEndpoints.length) * 100);
47
+
48
+ const color = useColor();
49
+
50
+ // Enriched mode with run results
51
+ if (options.runId != null) {
52
+ getDb();
53
+ const run = getRunById(options.runId);
54
+ if (!run) {
55
+ printError(`Run #${options.runId} not found`);
56
+ return 2;
57
+ }
58
+
59
+ const results = getResultsByRunId(options.runId);
60
+
61
+ // Build endpoint → status map
62
+ const endpointStatus = new Map<string, "passing" | "api_error" | "test_failed">();
63
+ for (const r of results) {
64
+ if (!r.request_url || !r.request_method) continue;
65
+ const urlPath = extractPathFromUrl(r.request_url);
66
+ if (!urlPath) continue;
67
+ const normalizedUrl = normalizePath(urlPath);
68
+
69
+ for (const ep of allEndpoints) {
70
+ const regex = specPathToRegex(ep.path);
71
+ if (r.request_method === ep.method && regex.test(normalizedUrl)) {
72
+ const key = `${ep.method} ${ep.path}`;
73
+ const existing = endpointStatus.get(key);
74
+
75
+ if (r.response_status !== null && r.response_status >= 500) {
76
+ endpointStatus.set(key, "api_error");
77
+ } else if (r.status === "fail" || r.status === "error") {
78
+ if (existing !== "api_error") {
79
+ endpointStatus.set(key, "test_failed");
80
+ }
81
+ } else if (!existing) {
82
+ endpointStatus.set(key, "passing");
83
+ }
84
+ break;
85
+ }
86
+ }
87
+ }
88
+
89
+ let passing = 0;
90
+ let apiError = 0;
91
+ let testFailed = 0;
92
+ for (const status of endpointStatus.values()) {
93
+ if (status === "passing") passing++;
94
+ else if (status === "api_error") apiError++;
95
+ else if (status === "test_failed") testFailed++;
96
+ }
97
+
98
+ console.log(`Coverage: ${coveredCount}/${allEndpoints.length} endpoints (${percentage}%) — Run #${options.runId}`);
99
+ console.log("");
100
+
101
+ if (passing > 0) {
102
+ console.log(` ${color ? GREEN : ""}✅ ${passing} covered and passing${color ? RESET : ""}`);
103
+ }
104
+ if (apiError > 0) {
105
+ console.log(` ${color ? YELLOW : ""}⚠️ ${apiError} covered but returning 5xx (possibly broken API)${color ? RESET : ""}`);
106
+ }
107
+ if (testFailed > 0) {
108
+ console.log(` ${color ? RED : ""}❌ ${testFailed} covered, test assertions failed${color ? RESET : ""}`);
109
+ }
110
+ if (uncovered.length > 0) {
111
+ console.log(` ${color ? DIM : ""}⬜ ${uncovered.length} not covered${color ? RESET : ""}`);
112
+ }
113
+ } else {
114
+ // Standard mode
115
+ console.log(`Coverage: ${coveredCount}/${allEndpoints.length} endpoints (${percentage}%)`);
116
+ console.log("");
117
+
118
+ // Covered endpoints
119
+ if (coveredCount > 0) {
120
+ console.log(`${color ? GREEN : ""}Covered:${color ? RESET : ""}`);
121
+ for (const ep of allEndpoints) {
122
+ if (!uncovered.includes(ep)) {
123
+ console.log(` ${color ? GREEN : ""}✓${color ? RESET : ""} ${ep.method.padEnd(7)} ${ep.path}`);
124
+ }
125
+ }
126
+ console.log("");
127
+ }
128
+
129
+ // Uncovered endpoints
130
+ if (uncovered.length > 0) {
131
+ console.log(`${color ? RED : ""}Uncovered:${color ? RESET : ""}`);
132
+ for (const ep of uncovered) {
133
+ console.log(` ${color ? RED : ""}✗${color ? RESET : ""} ${ep.method.padEnd(7)} ${ep.path}`);
134
+ }
135
+ }
136
+ }
137
+
138
+ // Static warnings (always shown)
139
+ const warnings = analyzeEndpoints(allEndpoints);
140
+ if (warnings.length > 0) {
141
+ console.log("");
142
+ console.log(`${color ? YELLOW : ""}Spec warnings:${color ? RESET : ""}`);
143
+ for (const w of warnings) {
144
+ console.log(` ${color ? YELLOW : ""}⚠${color ? RESET : ""} ${w.method.padEnd(7)} ${w.path}: ${w.warnings.join(", ")}`);
145
+ }
146
+ }
147
+
148
+ if (options.failOnCoverage !== undefined) {
149
+ return percentage < options.failOnCoverage ? 1 : 0;
150
+ }
151
+ return uncovered.length > 0 ? 1 : 0;
152
+ } catch (err) {
153
+ printError(err instanceof Error ? err.message : String(err));
154
+ return 2;
155
+ }
156
+ }
@@ -0,0 +1,127 @@
1
+ import { existsSync } from "fs";
2
+ import { resolve } from "path";
3
+ import { getDb, closeDb } from "../../db/schema.ts";
4
+
5
+ export interface DoctorOptions {
6
+ dbPath?: string;
7
+ }
8
+
9
+ interface Check {
10
+ label: string;
11
+ ok: boolean;
12
+ detail: string;
13
+ }
14
+
15
+ export async function doctorCommand(options: DoctorOptions): Promise<number> {
16
+ const checks: Check[] = [];
17
+
18
+ // 1. Database
19
+ checks.push(checkDatabase(options.dbPath));
20
+
21
+ // 2. Test files
22
+ checks.push(checkTestFiles());
23
+
24
+ // 3. OpenAPI spec
25
+ checks.push(checkOpenApiSpec());
26
+
27
+ // 4. Environment files
28
+ checks.push(checkEnvFiles());
29
+
30
+ // 5. Ollama
31
+ checks.push(await checkOllama());
32
+
33
+ // Print results
34
+ console.log("\nzond doctor\n");
35
+
36
+ let hasFailure = false;
37
+ for (const check of checks) {
38
+ const icon = check.ok ? "\u2713" : "\u2717";
39
+ console.log(` ${icon} ${check.label}: ${check.detail}`);
40
+ if (!check.ok) hasFailure = true;
41
+ }
42
+
43
+ console.log("");
44
+ if (hasFailure) {
45
+ console.log("Some checks failed. See details above.");
46
+ } else {
47
+ console.log("All checks passed.");
48
+ }
49
+
50
+ return hasFailure ? 1 : 0;
51
+ }
52
+
53
+ function checkDatabase(dbPath?: string): Check {
54
+ const path = dbPath ? resolve(dbPath) : resolve(process.cwd(), "zond.db");
55
+ try {
56
+ const db = getDb(path);
57
+ const runs = (db.query("SELECT COUNT(*) as cnt FROM runs").get() as { cnt: number }).cnt;
58
+ const envs = (db.query("SELECT COUNT(*) as cnt FROM environments").get() as { cnt: number }).cnt;
59
+ closeDb();
60
+ return { label: "Database", ok: true, detail: `${path} (${runs} runs, ${envs} environments)` };
61
+ } catch (err) {
62
+ return { label: "Database", ok: false, detail: `Cannot open ${path}: ${(err as Error).message}` };
63
+ }
64
+ }
65
+
66
+ function checkTestFiles(): Check {
67
+ const dirs = [".", "tests", "test"];
68
+ const found: string[] = [];
69
+
70
+ for (const dir of dirs) {
71
+ const full = resolve(process.cwd(), dir);
72
+ if (!existsSync(full)) continue;
73
+ try {
74
+ const glob = new Bun.Glob("**/*.yaml");
75
+ for (const file of glob.scanSync({ cwd: full, absolute: false })) {
76
+ if (!file.startsWith(".env.")) {
77
+ found.push(`${dir}/${file}`);
78
+ }
79
+ }
80
+ } catch { /* ignore */ }
81
+ }
82
+
83
+ if (found.length > 0) {
84
+ return { label: "Test files", ok: true, detail: `${found.length} YAML file(s) found` };
85
+ }
86
+ return { label: "Test files", ok: false, detail: "No YAML test files found in cwd or tests/" };
87
+ }
88
+
89
+ function checkOpenApiSpec(): Check {
90
+ const candidates = ["openapi.yaml", "openapi.json", "openapi.yml", "swagger.yaml", "swagger.json"];
91
+ for (const name of candidates) {
92
+ const full = resolve(process.cwd(), name);
93
+ if (existsSync(full)) {
94
+ return { label: "OpenAPI spec", ok: true, detail: name };
95
+ }
96
+ }
97
+ return { label: "OpenAPI spec", ok: false, detail: "No openapi.yaml/json found (optional)" };
98
+ }
99
+
100
+ function checkEnvFiles(): Check {
101
+ const found: string[] = [];
102
+ try {
103
+ const glob = new Bun.Glob(".env.*.yaml");
104
+ for (const file of glob.scanSync({ cwd: process.cwd(), absolute: false })) {
105
+ found.push(file);
106
+ }
107
+ } catch { /* ignore */ }
108
+
109
+ if (found.length > 0) {
110
+ return { label: "Environment files", ok: true, detail: found.join(", ") };
111
+ }
112
+ return { label: "Environment files", ok: false, detail: "No .env.*.yaml files found (optional)" };
113
+ }
114
+
115
+ async function checkOllama(): Promise<Check> {
116
+ try {
117
+ const res = await fetch("http://localhost:11434/api/tags", { signal: AbortSignal.timeout(3000) });
118
+ if (res.ok) {
119
+ const data = await res.json() as { models?: { name: string }[] };
120
+ const count = data.models?.length ?? 0;
121
+ return { label: "Ollama", ok: true, detail: `Running (${count} model(s) available)` };
122
+ }
123
+ return { label: "Ollama", ok: false, detail: `Responded with status ${res.status}` };
124
+ } catch {
125
+ return { label: "Ollama", ok: false, detail: "Not reachable at localhost:11434 (optional, needed for chat)" };
126
+ }
127
+ }
@@ -0,0 +1,84 @@
1
+ import { resolve, dirname } from "path";
2
+ import { existsSync, mkdirSync, writeFileSync } from "fs";
3
+ import { printSuccess } from "../output.ts";
4
+
5
+ export interface InitCommandOptions {
6
+ force: boolean;
7
+ }
8
+
9
+ const EXAMPLE_TEST = `name: Example Smoke Test
10
+ base_url: "{{base_url}}"
11
+
12
+ tests:
13
+ - name: "List posts"
14
+ GET: /posts
15
+ expect:
16
+ status: 200
17
+ body:
18
+ id: { type: integer }
19
+
20
+ - name: "Get single post"
21
+ GET: /posts/1
22
+ expect:
23
+ status: 200
24
+ body:
25
+ id: { equals: 1 }
26
+ title: { type: string }
27
+ `;
28
+
29
+ const ENV_DEV = `base_url: https://jsonplaceholder.typicode.com
30
+ `;
31
+
32
+ const MCP_CONFIG = `{
33
+ "mcpServers": {
34
+ "zond": {
35
+ "command": "zond",
36
+ "args": ["mcp"]
37
+ }
38
+ }
39
+ }
40
+ `;
41
+
42
+ function writeIfMissing(filePath: string, content: string, force: boolean): boolean {
43
+ if (!force && existsSync(filePath)) {
44
+ console.log(` Skipped ${filePath} (already exists)`);
45
+ return false;
46
+ }
47
+ const dir = dirname(filePath);
48
+ if (!existsSync(dir)) {
49
+ mkdirSync(dir, { recursive: true });
50
+ }
51
+ writeFileSync(filePath, content, "utf-8");
52
+ console.log(` Created ${filePath}`);
53
+ return true;
54
+ }
55
+
56
+ function isClaudeCodeAvailable(): boolean {
57
+ try {
58
+ const result = Bun.spawnSync(["claude", "--version"], {
59
+ stdout: "ignore",
60
+ stderr: "ignore",
61
+ });
62
+ return result.exitCode === 0;
63
+ } catch {
64
+ return false;
65
+ }
66
+ }
67
+
68
+ export async function initCommand(options: InitCommandOptions): Promise<number> {
69
+ const cwd = process.cwd();
70
+
71
+ console.log("Initializing zond project...\n");
72
+
73
+ writeIfMissing(resolve(cwd, "tests/example.yaml"), EXAMPLE_TEST, options.force);
74
+ writeIfMissing(resolve(cwd, ".env.dev.yaml"), ENV_DEV, options.force);
75
+
76
+ // Create .mcp.json if Claude Code is detected
77
+ if (isClaudeCodeAvailable()) {
78
+ writeIfMissing(resolve(cwd, ".mcp.json"), MCP_CONFIG, options.force);
79
+ printSuccess("Claude Code detected — .mcp.json created");
80
+ }
81
+
82
+ console.log("\nReady! Run: zond run tests/");
83
+ return 0;
84
+ }