@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,84 @@
1
+ import { z } from "zod";
2
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { dirname, isAbsolute, join, resolve } from "node:path";
4
+ import { existsSync } from "node:fs";
5
+ import { setupApi } from "../../core/setup-api.ts";
6
+ import { resetDb } from "../../db/schema.ts";
7
+ import { TOOL_DESCRIPTIONS } from "../descriptions.js";
8
+
9
+ function findProjectRoot(fromPath: string): string | null {
10
+ let current = existsSync(fromPath) ? fromPath : dirname(fromPath);
11
+ while (true) {
12
+ if (existsSync(join(current, ".git")) || existsSync(join(current, "package.json"))) {
13
+ return current;
14
+ }
15
+ const parent = dirname(current);
16
+ if (parent === current) return null;
17
+ current = parent;
18
+ }
19
+ }
20
+
21
+ export function registerSetupApiTool(server: McpServer, dbPath?: string) {
22
+ server.registerTool("setup_api", {
23
+ description: TOOL_DESCRIPTIONS.setup_api,
24
+ inputSchema: {
25
+ name: z.string().describe("API name (e.g. 'petstore')"),
26
+ specPath: z.optional(z.string()).describe("Path or URL to OpenAPI spec"),
27
+ dir: z.optional(z.string()).describe("Base directory (default: ./apis/<name>/)"),
28
+ envVars: z.optional(z.string()).describe("Environment variables as JSON string (e.g. '{\"base_url\": \"...\", \"token\": \"...\"}')"),
29
+ force: z.optional(z.boolean()).describe("If true, delete existing API with same name and recreate from scratch"),
30
+ },
31
+ }, async ({ name, specPath, dir, envVars, force }) => {
32
+ try {
33
+ let parsedEnvVars: Record<string, string> | undefined;
34
+ if (envVars) {
35
+ try {
36
+ parsedEnvVars = JSON.parse(envVars);
37
+ } catch {
38
+ return {
39
+ content: [{ type: "text" as const, text: JSON.stringify({ error: "envVars must be a valid JSON string" }, null, 2) }],
40
+ isError: true,
41
+ };
42
+ }
43
+ }
44
+
45
+ // Auto-chdir to project root when dir is an absolute path
46
+ if (dir && isAbsolute(resolve(dir))) {
47
+ const resolvedDir = resolve(dir);
48
+ const root = findProjectRoot(resolvedDir);
49
+ if (root && root !== process.cwd()) {
50
+ process.chdir(root);
51
+ resetDb();
52
+ }
53
+ }
54
+
55
+ const result = await setupApi({
56
+ name,
57
+ spec: specPath,
58
+ dir,
59
+ envVars: parsedEnvVars,
60
+ dbPath,
61
+ force,
62
+ });
63
+
64
+ const envFilePath = join(result.baseDir, ".env.yaml");
65
+ const response = {
66
+ ...result,
67
+ nextSteps: [
68
+ `Edit ${envFilePath} to add credentials (auth_token, api_key, base_url, etc.)`,
69
+ `File is already git-ignored via .gitignore`,
70
+ `Then run: run_tests(testPath: "${result.testPath}")`,
71
+ ],
72
+ };
73
+
74
+ return {
75
+ content: [{ type: "text" as const, text: JSON.stringify(response, null, 2) }],
76
+ };
77
+ } catch (err) {
78
+ return {
79
+ content: [{ type: "text" as const, text: JSON.stringify({ error: (err as Error).message }, null, 2) }],
80
+ isError: true,
81
+ };
82
+ }
83
+ });
84
+ }
@@ -0,0 +1,43 @@
1
+ import { z } from "zod";
2
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { parse } from "../../core/parser/yaml-parser.ts";
4
+ import { TOOL_DESCRIPTIONS } from "../descriptions.js";
5
+
6
+ export function registerValidateTestsTool(server: McpServer) {
7
+ server.registerTool("validate_tests", {
8
+ description: TOOL_DESCRIPTIONS.validate_tests,
9
+ inputSchema: {
10
+ testPath: z.string().describe("Path to test YAML file or directory"),
11
+ },
12
+ }, async ({ testPath }) => {
13
+ try {
14
+ const suites = await parse(testPath);
15
+ const totalSteps = suites.reduce((sum, s) => sum + s.tests.length, 0);
16
+
17
+ const result = {
18
+ valid: true,
19
+ suites: suites.length,
20
+ tests: totalSteps,
21
+ details: suites.map(s => ({
22
+ name: s.name,
23
+ tests: s.tests.length,
24
+ tags: s.tags,
25
+ description: s.description,
26
+ source: (s as any)._source,
27
+ })),
28
+ };
29
+
30
+ return {
31
+ content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
32
+ };
33
+ } catch (err) {
34
+ return {
35
+ content: [{ type: "text" as const, text: JSON.stringify({
36
+ valid: false,
37
+ error: (err as Error).message,
38
+ }, null, 2) }],
39
+ isError: true,
40
+ };
41
+ }
42
+ });
43
+ }
@@ -0,0 +1,150 @@
1
+ import { createInterface } from "readline";
2
+ import { runAgentTurn } from "../core/agent/agent-loop.ts";
3
+ import { trimContext } from "../core/agent/context-manager.ts";
4
+ import type { AgentConfig, ToolEvent } from "../core/agent/types.ts";
5
+ import type { ModelMessage } from "ai";
6
+
7
+ // ── ANSI helpers ──
8
+ const RESET = "\x1b[0m";
9
+ const BOLD = "\x1b[1m";
10
+ const DIM = "\x1b[2m";
11
+ const CYAN = "\x1b[36m";
12
+ const GREEN = "\x1b[32m";
13
+ const YELLOW = "\x1b[33m";
14
+ const MAGENTA = "\x1b[35m";
15
+
16
+ // Mouse reporting escape sequences
17
+ const DISABLE_MOUSE = "\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l";
18
+ // Regex to strip mouse escape sequences from input
19
+ // Covers SGR extended, X10, urxvt mouse protocols
20
+ const MOUSE_SEQ_RE = /\x1b\[\<[\d;]*[mM]|\x1b\[M[\s\S]{3}|\x1b\[\d+;\d+;\d+M/g;
21
+
22
+ function printToolEvent(event: ToolEvent) {
23
+ const args = Object.entries(event.args)
24
+ .filter(([, v]) => v !== undefined)
25
+ .map(([k, v]) => `${k}=${JSON.stringify(v)}`)
26
+ .slice(0, 3)
27
+ .join(", ");
28
+
29
+ const result = event.result as Record<string, unknown> | null;
30
+ let summary = "done";
31
+ if (result) {
32
+ if ("error" in result) summary = `error: ${result.error}`;
33
+ else if ("status" in result) summary = String(result.status);
34
+ else if ("valid" in result) summary = result.valid ? "valid" : "invalid";
35
+ else if ("runId" in result) summary = `run #${result.runId}`;
36
+ }
37
+
38
+ process.stdout.write(` ${MAGENTA}↳ ${event.toolName}${RESET}${DIM}(${args})${RESET} → ${summary}\n`);
39
+ }
40
+
41
+ export async function startChatUI(config: AgentConfig): Promise<void> {
42
+ const { provider } = config.provider;
43
+ const model = config.provider.model;
44
+ const safeLabel = config.safeMode ? ` ${YELLOW}[SAFE]${RESET}` : "";
45
+
46
+ // Disable mouse reporting that may be left over from other TUI apps
47
+ process.stdout.write(DISABLE_MOUSE);
48
+
49
+ console.log(`\n${BOLD}${CYAN}zond chat${RESET} — ${provider}/${model}${safeLabel}`);
50
+ console.log(`${DIM}Commands: /clear, /tokens, /quit | Ctrl+C to exit${RESET}\n`);
51
+
52
+ const messages: ModelMessage[] = [];
53
+ let totalIn = 0;
54
+ let totalOut = 0;
55
+ let busy = false;
56
+
57
+ const rl = createInterface({
58
+ input: process.stdin,
59
+ output: process.stdout,
60
+ prompt: `${BOLD}${CYAN}> ${RESET}`,
61
+ terminal: true,
62
+ });
63
+
64
+ rl.prompt();
65
+
66
+ rl.on("line", async (raw: string) => {
67
+ // Strip mouse escape sequences that leak into input
68
+ const text = raw.replace(MOUSE_SEQ_RE, "").trim();
69
+
70
+ if (!text || busy) {
71
+ if (!busy) rl.prompt();
72
+ return;
73
+ }
74
+
75
+ if (text === "/quit" || text === "/exit") {
76
+ rl.close();
77
+ return;
78
+ }
79
+
80
+ if (text === "/clear") {
81
+ messages.length = 0;
82
+ totalIn = 0;
83
+ totalOut = 0;
84
+ console.log(`${DIM}Conversation cleared.${RESET}\n`);
85
+ rl.prompt();
86
+ return;
87
+ }
88
+
89
+ if (text === "/tokens") {
90
+ console.log(`${DIM}Tokens: ${totalIn} in / ${totalOut} out${RESET}\n`);
91
+ rl.prompt();
92
+ return;
93
+ }
94
+
95
+ busy = true;
96
+ messages.push({ role: "user", content: text });
97
+
98
+ // Trim context if conversation is long
99
+ const trimmed = trimContext(
100
+ messages.map((m) => ({
101
+ role: m.role as "user" | "assistant",
102
+ content: typeof m.content === "string" ? m.content : "",
103
+ })),
104
+ );
105
+
106
+ process.stdout.write(`\n${DIM}thinking...${RESET}`);
107
+
108
+ try {
109
+ const result = await runAgentTurn(
110
+ trimmed.map((m) => ({ role: m.role, content: m.content })),
111
+ config,
112
+ (event) => {
113
+ process.stdout.write(`\r\x1b[K`);
114
+ printToolEvent(event);
115
+ process.stdout.write(`${DIM}thinking...${RESET}`);
116
+ },
117
+ );
118
+
119
+ process.stdout.write(`\r\x1b[K`);
120
+
121
+ const responseText = result.text || "(no response)";
122
+ console.log(`\n${GREEN}${BOLD}AI${RESET} ${responseText}\n`);
123
+
124
+ totalIn += result.inputTokens;
125
+ totalOut += result.outputTokens;
126
+ messages.push({ role: "assistant", content: responseText });
127
+ } catch (err) {
128
+ process.stdout.write(`\r\x1b[K`);
129
+ console.log(`\n${YELLOW}Error: ${(err as Error).message}${RESET}\n`);
130
+ }
131
+
132
+ busy = false;
133
+ rl.prompt();
134
+ });
135
+
136
+ rl.on("close", () => {
137
+ // Disable mouse reporting on exit too
138
+ process.stdout.write(DISABLE_MOUSE);
139
+ console.log(`\n${DIM}Bye! (${totalIn} in / ${totalOut} out tokens)${RESET}`);
140
+ process.exit(0);
141
+ });
142
+
143
+ // Also disable mouse on SIGINT
144
+ process.on("SIGINT", () => {
145
+ process.stdout.write(DISABLE_MOUSE);
146
+ rl.close();
147
+ });
148
+
149
+ await new Promise<void>(() => {});
150
+ }
@@ -0,0 +1,360 @@
1
+ /**
2
+ * Unified collection state builder for Web UI.
3
+ * Aggregates spec endpoints, disk suites, coverage, run results, warnings, env diagnostics.
4
+ */
5
+
6
+ import type { CollectionRecord, RunRecord, StoredStepResult } from "../../db/queries.ts";
7
+ import { listRunsByCollection, getResultsByRunId, getRunById } from "../../db/queries.ts";
8
+ import type { EndpointWarning } from "../../core/generator/endpoint-warnings.ts";
9
+ import { envHint, statusHint, classifyFailure, computeSharedEnvIssue } from "../../core/diagnostics/failure-hints.ts";
10
+ import { join, basename } from "node:path";
11
+
12
+ // ── Types ──
13
+
14
+ export interface CoveringStep {
15
+ suiteName: string;
16
+ file: string; // relative filename (e.g. "auth-login.yaml")
17
+ stepName: string;
18
+ status: "pass" | "fail" | "error" | "skip" | null; // null = not run
19
+ responseStatus?: number;
20
+ durationMs?: number;
21
+ hint?: string; // failure hint
22
+ assertions?: { field: string; rule: string; passed: boolean; actual?: unknown; expected?: unknown }[];
23
+ }
24
+
25
+ export interface EndpointViewState {
26
+ method: string;
27
+ path: string;
28
+ summary?: string;
29
+ deprecated: boolean;
30
+ hasCoverage: boolean;
31
+ runStatus: "passing" | "api_error" | "test_failed" | "not_run" | "no_tests";
32
+ warnings: string[];
33
+ coveringFiles: string[];
34
+ coveringSteps: CoveringStep[];
35
+ }
36
+
37
+ export interface StepViewState {
38
+ name: string;
39
+ status: "pass" | "fail" | "error" | "skip";
40
+ durationMs?: number;
41
+ requestMethod?: string;
42
+ requestUrl?: string;
43
+ responseStatus?: number;
44
+ responseBody?: string;
45
+ assertions?: { field: string; rule: string; passed: boolean; actual?: unknown; expected?: unknown }[];
46
+ captures?: Record<string, unknown>;
47
+ hint?: string;
48
+ errorMessage?: string;
49
+ }
50
+
51
+ export interface SuiteViewState {
52
+ name: string;
53
+ description?: string;
54
+ tags: string[];
55
+ stepCount: number;
56
+ filePath: string;
57
+ status: "passed" | "failed" | "not_run" | "parse_error";
58
+ runResult?: { passed: number; failed: number; skipped: number };
59
+ parseError?: string;
60
+ steps: StepViewState[];
61
+ }
62
+
63
+ export interface CollectionState {
64
+ collection: CollectionRecord;
65
+ endpoints: EndpointViewState[];
66
+ totalEndpoints: number;
67
+ coveragePct: number;
68
+ coveredCount: number;
69
+ suites: SuiteViewState[];
70
+ latestRun: RunRecord | null;
71
+ latestRunResults: StoredStepResult[];
72
+ envAlert: string | null;
73
+ warnings: EndpointWarning[];
74
+ // Run stats
75
+ runPassed: number;
76
+ runFailed: number;
77
+ runSkipped: number;
78
+ runTotal: number;
79
+ runDurationMs: number | null;
80
+ }
81
+
82
+ // ── Cache ──
83
+
84
+ interface CacheEntry {
85
+ state: CollectionState;
86
+ timestamp: number;
87
+ }
88
+
89
+ const cache = new Map<number, CacheEntry>();
90
+ const CACHE_TTL_MS = 30_000;
91
+
92
+ export function invalidateCollectionCache(collectionId: number): void {
93
+ cache.delete(collectionId);
94
+ }
95
+
96
+ // ── Builder ──
97
+
98
+ export async function buildCollectionState(collection: CollectionRecord): Promise<CollectionState> {
99
+ const cached = cache.get(collection.id);
100
+ if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
101
+ return cached.state;
102
+ }
103
+
104
+ // Load spec endpoints
105
+ let specEndpoints: import("../../core/generator/types.ts").EndpointInfo[] = [];
106
+ let warnings: EndpointWarning[] = [];
107
+ if (collection.openapi_spec) {
108
+ try {
109
+ const { readOpenApiSpec, extractEndpoints } = await import("../../core/generator/openapi-reader.ts");
110
+ const { analyzeEndpoints } = await import("../../core/generator/endpoint-warnings.ts");
111
+ const doc = await readOpenApiSpec(collection.openapi_spec);
112
+ specEndpoints = extractEndpoints(doc);
113
+ warnings = analyzeEndpoints(specEndpoints);
114
+ } catch { /* spec unavailable */ }
115
+ }
116
+
117
+ // Scan coverage from disk
118
+ const { scanCoveredEndpoints } = await import("../../core/generator/coverage-scanner.ts");
119
+ const { specPathToRegex, normalizePath } = await import("../../core/generator/coverage-scanner.ts");
120
+ let coveredEndpoints: import("../../core/generator/coverage-scanner.ts").CoveredEndpoint[] = [];
121
+ try {
122
+ coveredEndpoints = await scanCoveredEndpoints(collection.test_path);
123
+ } catch { /* no tests on disk */ }
124
+
125
+ // Parse suites from disk
126
+ const { parseDirectorySafe } = await import("../../core/parser/yaml-parser.ts");
127
+ let diskSuites: import("../../core/parser/yaml-parser.ts").ParseDirectoryResult = { suites: [], errors: [] };
128
+ try {
129
+ diskSuites = await parseDirectorySafe(collection.test_path);
130
+ } catch { /* test dir missing */ }
131
+
132
+ // Get latest run
133
+ const runs = listRunsByCollection(collection.id, 1, 0);
134
+ const latestRun = runs.length > 0 ? (getRunById(runs[0]!.id) ?? null) : null;
135
+ const latestRunResults = latestRun ? getResultsByRunId(latestRun.id) : [];
136
+
137
+ // Build result maps: suite_name -> step statuses
138
+ const suiteResultMap = new Map<string, StoredStepResult[]>();
139
+ for (const r of latestRunResults) {
140
+ const list = suiteResultMap.get(r.suite_name) ?? [];
141
+ list.push(r);
142
+ suiteResultMap.set(r.suite_name, list);
143
+ }
144
+
145
+ // Build endpoint -> run status map
146
+ // key: "METHOD /path" from results
147
+ const endpointRunStatusMap = new Map<string, "passing" | "api_error" | "test_failed">();
148
+ for (const r of latestRunResults) {
149
+ if (r.request_method && r.request_url) {
150
+ // Extract path from URL
151
+ let urlPath: string;
152
+ try {
153
+ const u = new URL(r.request_url);
154
+ urlPath = u.pathname;
155
+ } catch {
156
+ urlPath = r.request_url;
157
+ }
158
+ const key = `${r.request_method} ${normalizePath(urlPath)}`;
159
+ const current = endpointRunStatusMap.get(key);
160
+ if (r.status === "fail" || r.status === "error") {
161
+ const ft = classifyFailure(r.status, r.response_status);
162
+ endpointRunStatusMap.set(key, ft === "api_error" ? "api_error" : "test_failed");
163
+ } else if (r.status === "pass" && current !== "test_failed" && current !== "api_error") {
164
+ endpointRunStatusMap.set(key, "passing");
165
+ }
166
+ }
167
+ }
168
+
169
+ // Build endpoint view states
170
+ const warningsMap = new Map<string, string[]>();
171
+ for (const w of warnings) {
172
+ warningsMap.set(`${w.method} ${w.path}`, w.warnings);
173
+ }
174
+
175
+ // Build map: normalized "METHOD /path" -> list of StoredStepResult
176
+ const resultsByEndpoint = new Map<string, StoredStepResult[]>();
177
+ for (const r of latestRunResults) {
178
+ if (r.request_method && r.request_url) {
179
+ let urlPath: string;
180
+ try { urlPath = new URL(r.request_url).pathname; } catch { urlPath = r.request_url; }
181
+ const key = `${r.request_method} ${normalizePath(urlPath)}`;
182
+ const list = resultsByEndpoint.get(key) ?? [];
183
+ list.push(r);
184
+ resultsByEndpoint.set(key, list);
185
+ }
186
+ }
187
+
188
+ // Map suite name -> file basename for display
189
+ const suiteNameToFile = new Map<string, string>();
190
+ for (const s of diskSuites.suites) {
191
+ suiteNameToFile.set(s.name, basename(s.filePath ?? s.name));
192
+ }
193
+
194
+ // Env file path for hints
195
+ const envFilePath = collection.base_dir
196
+ ? join(collection.base_dir, ".env.yaml").replace(/\\/g, "/")
197
+ : undefined;
198
+
199
+ const endpoints: EndpointViewState[] = specEndpoints.map(ep => {
200
+ const specRegex = specPathToRegex(ep.path);
201
+ const covering = coveredEndpoints.filter(
202
+ c => c.method === ep.method && specRegex.test(normalizePath(c.path)),
203
+ );
204
+ const hasCoverage = covering.length > 0;
205
+
206
+ // Determine run status
207
+ let runStatus: EndpointViewState["runStatus"] = "no_tests";
208
+ if (hasCoverage) {
209
+ runStatus = "not_run";
210
+ for (const [key, status] of endpointRunStatusMap) {
211
+ const [method, path] = [key.split(" ")[0], key.split(" ").slice(1).join(" ")];
212
+ if (method === ep.method && specRegex.test(path!)) {
213
+ runStatus = status;
214
+ break;
215
+ }
216
+ }
217
+ }
218
+
219
+ // Build covering steps from run results
220
+ const coveringSteps: CoveringStep[] = [];
221
+ for (const [key, results] of resultsByEndpoint) {
222
+ const [method, path] = [key.split(" ")[0], key.split(" ").slice(1).join(" ")];
223
+ if (method === ep.method && specRegex.test(path!)) {
224
+ for (const r of results) {
225
+ const hint = (r.status === "fail" || r.status === "error")
226
+ ? (envHint(r.request_url, r.error_message, envFilePath) ?? statusHint(r.response_status) ?? undefined)
227
+ : undefined;
228
+ coveringSteps.push({
229
+ suiteName: r.suite_name,
230
+ file: suiteNameToFile.get(r.suite_name) ?? r.suite_name,
231
+ stepName: r.test_name,
232
+ status: r.status as CoveringStep["status"],
233
+ responseStatus: r.response_status ?? undefined,
234
+ durationMs: r.duration_ms ?? undefined,
235
+ hint,
236
+ assertions: Array.isArray(r.assertions) ? r.assertions : undefined,
237
+ });
238
+ }
239
+ }
240
+ }
241
+
242
+ return {
243
+ method: ep.method,
244
+ path: ep.path,
245
+ summary: ep.summary,
246
+ deprecated: ep.deprecated ?? false,
247
+ hasCoverage,
248
+ runStatus,
249
+ warnings: warningsMap.get(`${ep.method} ${ep.path}`) ?? [],
250
+ coveringFiles: covering.map(c => c.file),
251
+ coveringSteps,
252
+ };
253
+ });
254
+
255
+ // Coverage stats
256
+ const totalEndpoints = endpoints.length;
257
+ const coveredCount = endpoints.filter(e => e.hasCoverage).length;
258
+ const coveragePct = totalEndpoints > 0 ? Math.round((coveredCount / totalEndpoints) * 100) : 0;
259
+
260
+ // Build suite view states
261
+ const suites: SuiteViewState[] = diskSuites.suites.map(s => {
262
+ const results = suiteResultMap.get(s.name);
263
+ let status: SuiteViewState["status"] = "not_run";
264
+ let runResult: SuiteViewState["runResult"] | undefined;
265
+ const steps: StepViewState[] = [];
266
+
267
+ if (results) {
268
+ const passed = results.filter(r => r.status === "pass").length;
269
+ const failed = results.filter(r => r.status === "fail" || r.status === "error").length;
270
+ const skipped = results.filter(r => r.status === "skip").length;
271
+ runResult = { passed, failed, skipped };
272
+ status = failed > 0 ? "failed" : "passed";
273
+
274
+ for (const r of results) {
275
+ const hint = (r.status === "fail" || r.status === "error")
276
+ ? (envHint(r.request_url, r.error_message, envFilePath) ?? statusHint(r.response_status) ?? undefined)
277
+ : undefined;
278
+ steps.push({
279
+ name: r.test_name,
280
+ status: r.status as StepViewState["status"],
281
+ durationMs: r.duration_ms ?? undefined,
282
+ requestMethod: r.request_method ?? undefined,
283
+ requestUrl: r.request_url ?? undefined,
284
+ responseStatus: r.response_status ?? undefined,
285
+ responseBody: r.response_body ?? undefined,
286
+ assertions: Array.isArray(r.assertions) ? r.assertions : undefined,
287
+ captures: r.captures && typeof r.captures === "object" ? r.captures as Record<string, unknown> : undefined,
288
+ hint,
289
+ errorMessage: r.error_message ?? undefined,
290
+ });
291
+ }
292
+ }
293
+
294
+ return {
295
+ name: s.name,
296
+ description: s.description,
297
+ tags: s.tags ?? [],
298
+ stepCount: s.tests.length,
299
+ filePath: s.filePath ?? "",
300
+ status,
301
+ runResult,
302
+ steps,
303
+ };
304
+ });
305
+
306
+ // Add parse errors as suites
307
+ for (const err of diskSuites.errors) {
308
+ suites.push({
309
+ name: err.file,
310
+ tags: [],
311
+ stepCount: 0,
312
+ filePath: err.file,
313
+ status: "parse_error",
314
+ parseError: err.error,
315
+ steps: [],
316
+ });
317
+ }
318
+
319
+ // Env diagnostics
320
+ let envAlert: string | null = null;
321
+ const failedResults = latestRunResults.filter(r => r.status === "fail" || r.status === "error");
322
+ if (failedResults.length > 0) {
323
+ const envFilePath = collection.base_dir
324
+ ? join(collection.base_dir, ".env.yaml").replace(/\\/g, "/")
325
+ : undefined;
326
+
327
+ const failuresWithHints = failedResults.map(r => ({
328
+ hint: envHint(r.request_url, r.error_message, envFilePath) ?? undefined,
329
+ }));
330
+ envAlert = computeSharedEnvIssue(failuresWithHints, envFilePath);
331
+ }
332
+
333
+ // Run stats
334
+ const runPassed = latestRun?.passed ?? 0;
335
+ const runFailed = latestRun?.failed ?? 0;
336
+ const runSkipped = latestRun?.skipped ?? 0;
337
+ const runTotal = latestRun?.total ?? 0;
338
+ const runDurationMs = latestRun?.duration_ms ?? null;
339
+
340
+ const state: CollectionState = {
341
+ collection,
342
+ endpoints,
343
+ totalEndpoints,
344
+ coveragePct,
345
+ coveredCount,
346
+ suites,
347
+ latestRun,
348
+ latestRunResults,
349
+ envAlert,
350
+ warnings,
351
+ runPassed,
352
+ runFailed,
353
+ runSkipped,
354
+ runTotal,
355
+ runDurationMs,
356
+ };
357
+
358
+ cache.set(collection.id, { state, timestamp: Date.now() });
359
+ return state;
360
+ }