@kirrosh/zond 0.23.0 → 0.26.1
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/CHANGELOG.md +176 -1
- package/README.md +8 -7
- package/package.json +2 -3
- package/src/CLAUDE.md +112 -0
- package/src/cli/commands/add-api.ts +19 -7
- package/src/cli/commands/api/annotate/index.ts +359 -4
- package/src/cli/commands/api/annotate/lifecycle.ts +1 -1
- package/src/cli/commands/api/annotate/overlay.ts +1 -1
- package/src/cli/commands/api/annotate/pagination.ts +10 -6
- package/src/cli/commands/api/annotate/prompts.ts +39 -2
- package/src/cli/commands/audit.ts +360 -54
- package/src/cli/commands/check.ts +15 -2
- package/src/cli/commands/checks.ts +352 -36
- package/src/cli/commands/cleanup.ts +4 -30
- package/src/cli/commands/coverage.ts +275 -57
- package/src/cli/commands/db.ts +311 -8
- package/src/cli/commands/discover.ts +281 -161
- package/src/cli/commands/doctor.ts +57 -3
- package/src/cli/commands/fixtures.ts +1 -1
- package/src/cli/commands/generate.ts +24 -7
- package/src/cli/commands/init/bootstrap.ts +4 -1
- package/src/cli/commands/init/index.ts +2 -2
- package/src/cli/commands/init/skills.ts +43 -0
- package/src/cli/commands/init/templates/agents.md +12 -3
- package/src/cli/commands/init/templates/skills/warm-up-target.md +122 -0
- package/src/cli/commands/init/templates/skills/zond-checks.md +268 -44
- package/src/cli/commands/init/templates/skills/zond-seed.md +114 -0
- package/src/cli/commands/init/templates/skills/zond-triage.md +88 -26
- package/src/cli/commands/init/templates/skills/zond.md +274 -64
- package/src/cli/commands/init/templates/zond-config.yml +1 -1
- package/src/cli/commands/prepare-fixtures.ts +14 -52
- package/src/cli/commands/probe/_seed-bodies.ts +52 -0
- package/src/cli/commands/probe/mass-assignment.ts +101 -10
- package/src/cli/commands/probe/security.ts +95 -12
- package/src/cli/commands/probe/webhooks.ts +2 -0
- package/src/cli/commands/probe.ts +87 -11
- package/src/cli/commands/refresh-api.ts +59 -1
- package/src/cli/commands/report-bundle.ts +3 -11
- package/src/cli/commands/request.ts +116 -0
- package/src/cli/commands/run.ts +53 -5
- package/src/cli/commands/schema-from-runs.ts +128 -0
- package/src/cli/commands/secrets.ts +133 -0
- package/src/cli/json-envelope.ts +0 -20
- package/src/cli/json-schemas.ts +51 -0
- package/src/cli/output.ts +17 -1
- package/src/cli/program.ts +5 -4
- package/src/cli/safe-live.ts +24 -0
- package/src/cli/status-filter.ts +0 -10
- package/src/core/audit/persist.ts +183 -0
- package/src/core/checks/budget.ts +59 -0
- package/src/core/checks/checks/cross_call_references.ts +17 -4
- package/src/core/checks/checks/cursor_boundary_fuzzing.ts +219 -0
- package/src/core/checks/checks/idempotency_replay.ts +1 -5
- package/src/core/checks/checks/ignored_auth.ts +44 -1
- package/src/core/checks/checks/index.ts +3 -0
- package/src/core/checks/checks/lifecycle_transitions.ts +169 -26
- package/src/core/checks/checks/negative_data_rejection.ts +119 -16
- package/src/core/checks/checks/not_a_server_error.ts +8 -0
- package/src/core/checks/checks/open_cors_on_sensitive.ts +47 -18
- package/src/core/checks/checks/pagination_invariants.ts +298 -117
- package/src/core/checks/checks/positive_data_acceptance.ts +1 -4
- package/src/core/checks/checks/status_code_conformance.ts +78 -7
- package/src/core/checks/mode.ts +3 -0
- package/src/core/checks/recommended-action.ts +5 -1
- package/src/core/checks/runner.ts +614 -27
- package/src/core/checks/spec-findings.ts +308 -0
- package/src/core/checks/types.ts +117 -1
- package/src/core/checks/zond-extensions.ts +73 -0
- package/src/core/classifier/recommended-action.ts +35 -6
- package/src/core/coverage/loader.ts +31 -0
- package/src/core/diagnostics/db-analysis.ts +200 -106
- package/src/core/diagnostics/failure-class.ts +21 -1
- package/src/core/diagnostics/failure-hints.ts +4 -208
- package/src/core/diagnostics/suggested-fixes.ts +2 -3
- package/src/core/generator/chunker.ts +1 -8
- package/src/core/generator/data-factory.ts +199 -61
- package/src/core/generator/fixtures-builder.ts +38 -31
- package/src/core/generator/index.ts +0 -2
- package/src/core/generator/openapi-reader.ts +98 -4
- package/src/core/generator/path-param-disambig.ts +30 -4
- package/src/core/generator/resources-builder.ts +276 -26
- package/src/core/generator/schema-utils.ts +22 -0
- package/src/core/generator/suite-generator.ts +168 -15
- package/src/core/generator/types.ts +6 -0
- package/src/core/identity/identity-file.ts +0 -0
- package/src/core/output/README.md +11 -29
- package/src/core/output/index.ts +1 -1
- package/src/core/output/run.ts +0 -35
- package/src/core/output/types.ts +0 -7
- package/src/core/parser/dynamic-values.ts +160 -0
- package/src/core/parser/variables.ts +0 -0
- package/src/core/probe/dry-run-envelope.ts +4 -0
- package/src/core/probe/mass-assignment/classify.ts +175 -0
- package/src/core/probe/mass-assignment/cleanup.ts +52 -0
- package/src/core/probe/mass-assignment/digest.ts +114 -0
- package/src/core/probe/mass-assignment/orchestrator.ts +459 -0
- package/src/core/probe/mass-assignment/regression.ts +141 -0
- package/src/core/probe/mass-assignment/suspects.ts +92 -0
- package/src/core/probe/mass-assignment/types.ts +135 -0
- package/src/core/probe/mass-assignment-probe.ts +23 -1118
- package/src/core/probe/mass-assignment-template.ts +32 -4
- package/src/core/probe/path-discovery.ts +3 -4
- package/src/core/probe/probe-harness.ts +21 -22
- package/src/core/probe/security/baseline.ts +174 -0
- package/src/core/probe/security/classify.ts +341 -0
- package/src/core/probe/security/cleanup.ts +125 -0
- package/src/core/probe/security/detectors.ts +71 -0
- package/src/core/probe/security/digest.ts +104 -0
- package/src/core/probe/security/orchestrator.ts +398 -0
- package/src/core/probe/security/regression.ts +103 -0
- package/src/core/probe/security/types.ts +151 -0
- package/src/core/probe/security-probe-class.ts +8 -2
- package/src/core/probe/security-probe.ts +28 -1449
- package/src/core/probe/shared.ts +26 -0
- package/src/core/probe/webhooks-probe.ts +5 -7
- package/src/core/runner/assertions.ts +1 -1
- package/src/core/runner/executor.ts +3 -18
- package/src/core/runner/form-encode.ts +8 -18
- package/src/core/runner/http-client.ts +38 -1
- package/src/core/runner/preflight-vars.ts +19 -15
- package/src/core/runner/rate-limiter.ts +11 -29
- package/src/core/runner/run-kind.ts +7 -1
- package/src/core/runner/schema-validator.ts +2 -6
- package/src/core/runner/send-request.ts +11 -6
- package/src/core/runner/types.ts +6 -0
- package/src/core/setup-api.ts +53 -15
- package/src/core/severity/index.ts +0 -63
- package/src/core/spec/infer-schema.ts +102 -0
- package/src/core/spec/merge-specs.ts +156 -0
- package/src/core/spec/schema-from-runs.ts +117 -0
- package/src/core/spec/schema-overlay.ts +130 -0
- package/src/core/util/ajv.ts +13 -0
- package/src/core/util/headers.ts +9 -0
- package/src/core/util/url.ts +24 -0
- package/src/core/workspace/fixture-gap-report.ts +84 -0
- package/src/core/workspace/fixture-gaps.ts +71 -0
- package/src/core/workspace/root.ts +13 -11
- package/src/db/migrate.ts +2 -0
- package/src/db/migrations/0002_run_kind_request.sql +59 -0
- package/src/db/queries/collections.ts +2 -2
- package/src/db/queries/results.ts +88 -0
- package/src/db/queries/runs.ts +56 -2
- package/src/db/queries.ts +3 -0
- package/src/db/schema.ts +7 -7
- package/src/cli/commands/bootstrap.ts +0 -710
- package/src/core/anti-fp/bootstrap.ts +0 -34
- package/src/core/anti-fp/index.ts +0 -33
- package/src/core/anti-fp/registry.ts +0 -44
- package/src/core/anti-fp/rules/baseline-echo.ts +0 -74
- package/src/core/anti-fp/rules/schemathesis/body_negation_becomes_valid.ts +0 -52
- package/src/core/anti-fp/rules/schemathesis/coverage_phase_boundary_positive.ts +0 -38
- package/src/core/anti-fp/rules/schemathesis/has_unverifiable_mutations.ts +0 -35
- package/src/core/anti-fp/rules/schemathesis/index.ts +0 -24
- package/src/core/anti-fp/rules/schemathesis/string_type_mutation_becomes_valid.ts +0 -53
- package/src/core/anti-fp/rules/subscription-gated/index.ts +0 -11
- package/src/core/anti-fp/rules/subscription-gated/paid-plan-403.ts +0 -75
- package/src/core/anti-fp/types.ts +0 -68
- package/src/core/generator/create-body.ts +0 -89
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { getDb } from "../../db/schema.ts";
|
|
2
2
|
import { listCollections, listRuns, getRunById, getResultsByRunId, getCollectionById } from "../../db/queries.ts";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import {
|
|
4
|
+
import { classifyFailure, recommendedActionForGenerated, isGeneratedTest, type RecommendedAction } from "./failure-hints.ts";
|
|
5
5
|
import { buildSuggestedFixes, type SuggestedFix } from "./suggested-fixes.ts";
|
|
6
|
-
import { AUTH_PATH_RE } from "../runner/auth-path.ts";
|
|
7
6
|
|
|
8
7
|
function truncateErrorMessage(raw: string | null | undefined, verbose?: boolean): string | undefined {
|
|
9
8
|
if (!raw) return undefined;
|
|
@@ -25,6 +24,48 @@ function truncateErrorMessage(raw: string | null | undefined, verbose?: boolean)
|
|
|
25
24
|
return msgLines.join("\n");
|
|
26
25
|
}
|
|
27
26
|
|
|
27
|
+
/**
|
|
28
|
+
* ARV-305: build a short reason string for `by_recommended_action.examples`.
|
|
29
|
+
*
|
|
30
|
+
* Preference order:
|
|
31
|
+
* 1. trimmed `error_message` (assertion- or network-level message)
|
|
32
|
+
* 2. first failing assertion → `<field> <rule>: got <actual>`
|
|
33
|
+
* 3. undefined (nothing left to say)
|
|
34
|
+
*
|
|
35
|
+
* Without (2) every assertion-only failure leaves examples[].reason as
|
|
36
|
+
* undefined, so triage agents lose the one signal that lets them route
|
|
37
|
+
* past method/path/status (regenerate_suite vs tighten_validation
|
|
38
|
+
* collapse to identical-looking buckets).
|
|
39
|
+
*/
|
|
40
|
+
function buildExampleReason(
|
|
41
|
+
errorMessage: unknown,
|
|
42
|
+
assertions: unknown,
|
|
43
|
+
): string | undefined {
|
|
44
|
+
const trim = (s: string) => (s.length > 120 ? `${s.slice(0, 117)}...` : s);
|
|
45
|
+
if (typeof errorMessage === "string" && errorMessage.length > 0) {
|
|
46
|
+
return trim(errorMessage);
|
|
47
|
+
}
|
|
48
|
+
if (!Array.isArray(assertions)) return undefined;
|
|
49
|
+
for (const a of assertions) {
|
|
50
|
+
if (!a || typeof a !== "object") continue;
|
|
51
|
+
const row = a as Record<string, unknown>;
|
|
52
|
+
if (row.passed === false) {
|
|
53
|
+
const field = typeof row.field === "string" ? row.field : "";
|
|
54
|
+
const rule = typeof row.rule === "string" ? row.rule : "";
|
|
55
|
+
const actual = "actual" in row ? row.actual : undefined;
|
|
56
|
+
const actualStr = actual === undefined || actual === null
|
|
57
|
+
? ""
|
|
58
|
+
: typeof actual === "string"
|
|
59
|
+
? actual
|
|
60
|
+
: JSON.stringify(actual);
|
|
61
|
+
const parts = [field, rule].filter(Boolean).join(" ");
|
|
62
|
+
const text = actualStr ? `${parts}: got ${actualStr}` : parts;
|
|
63
|
+
return text ? trim(text) : undefined;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
28
69
|
function parseBodySafe(raw: string | null | undefined): unknown {
|
|
29
70
|
if (!raw) return undefined;
|
|
30
71
|
const truncated = raw.length > 2000 ? raw.slice(0, 2000) + "\u2026[truncated]" : raw;
|
|
@@ -145,7 +186,6 @@ export interface FailureGroup {
|
|
|
145
186
|
count: number;
|
|
146
187
|
failure_type: string;
|
|
147
188
|
recommended_action: RecommendedAction;
|
|
148
|
-
hint?: string;
|
|
149
189
|
examples: string[];
|
|
150
190
|
response_status: number | null;
|
|
151
191
|
}
|
|
@@ -171,9 +211,6 @@ export interface DiagnoseResult {
|
|
|
171
211
|
assertion_failures: number;
|
|
172
212
|
network_errors: number;
|
|
173
213
|
};
|
|
174
|
-
agent_directive?: string;
|
|
175
|
-
env_issue?: EnvIssue;
|
|
176
|
-
auth_hint?: string;
|
|
177
214
|
cascade_skips?: CascadeSkipGroup[];
|
|
178
215
|
/** TASK-29: actionable suggestions populated from 404 placeholder
|
|
179
216
|
* detection + .env.yaml unfilled-key audit. Empty / undefined when
|
|
@@ -190,8 +227,6 @@ export interface DiagnoseResult {
|
|
|
190
227
|
request_method: string | null;
|
|
191
228
|
request_url: string | null;
|
|
192
229
|
response_status: number | null;
|
|
193
|
-
hint?: string;
|
|
194
|
-
schema_hint?: string;
|
|
195
230
|
response_body?: unknown;
|
|
196
231
|
response_headers?: Record<string, string>;
|
|
197
232
|
assertions: unknown;
|
|
@@ -212,7 +247,24 @@ export interface DiagnoseResult {
|
|
|
212
247
|
* subset), so counts match `.summary.failed`. Each bucket carries
|
|
213
248
|
* total count + a small examples list (`<suite>/<test>`). Empty when
|
|
214
249
|
* there are no failures. */
|
|
215
|
-
by_recommended_action?: Record<string, {
|
|
250
|
+
by_recommended_action?: Record<string, {
|
|
251
|
+
count: number;
|
|
252
|
+
/** ARV-228: each example carries the per-failure context the
|
|
253
|
+
* zond-triage skill renders in its output template (`POST
|
|
254
|
+
* /v1/projects → 500 (×3) — <reason>`). Previously a bare
|
|
255
|
+
* `string[]` of `<suite>/<test>` ids — agents had to cross-join
|
|
256
|
+
* with `failures[]` to recover method/path/status, which broke
|
|
257
|
+
* triage scripts on large runs. Bounded to 5 entries per bucket
|
|
258
|
+
* (same cap as the legacy string form). */
|
|
259
|
+
examples: Array<{
|
|
260
|
+
suite: string;
|
|
261
|
+
test: string;
|
|
262
|
+
method: string;
|
|
263
|
+
path: string;
|
|
264
|
+
status: number;
|
|
265
|
+
reason?: string;
|
|
266
|
+
}>;
|
|
267
|
+
}>;
|
|
216
268
|
}
|
|
217
269
|
|
|
218
270
|
export function diagnoseRun(runId: number, verbose?: boolean, dbPath?: string, maxExamples?: number): DiagnoseResult {
|
|
@@ -233,9 +285,6 @@ export function diagnoseRun(runId: number, verbose?: boolean, dbPath?: string, m
|
|
|
233
285
|
.filter(r => r.status === "fail" || r.status === "error")
|
|
234
286
|
.map(r => {
|
|
235
287
|
const parsedBody = parseBodySafe(r.response_body);
|
|
236
|
-
const hint = envHint(r.request_url, r.error_message, envFilePath) ??
|
|
237
|
-
softDeleteHint(r.response_status, r.request_method, parsedBody) ??
|
|
238
|
-
statusHint(r.response_status);
|
|
239
288
|
const failure_type = classifyFailure(r.status, r.response_status);
|
|
240
289
|
// ARV-42: generator-emitted suites should not route to fix_test_logic —
|
|
241
290
|
// editing the YAML gets clobbered on the next `zond audit`.
|
|
@@ -247,7 +296,6 @@ export function diagnoseRun(runId: number, verbose?: boolean, dbPath?: string, m
|
|
|
247
296
|
// test-logic mistakes.
|
|
248
297
|
const schema_violation = hasSchemaAssertion(r.assertions);
|
|
249
298
|
const rec_action = recommendedActionForGenerated(failure_type, r.response_status, generated, schema_violation);
|
|
250
|
-
const sHint = schemaHint(failure_type, r.response_status);
|
|
251
299
|
return {
|
|
252
300
|
suite_name: r.suite_name,
|
|
253
301
|
test_name: r.test_name,
|
|
@@ -259,8 +307,6 @@ export function diagnoseRun(runId: number, verbose?: boolean, dbPath?: string, m
|
|
|
259
307
|
request_method: r.request_method,
|
|
260
308
|
request_url: r.request_url,
|
|
261
309
|
response_status: r.response_status,
|
|
262
|
-
...(hint ? { hint } : {}),
|
|
263
|
-
...(sHint ? { schema_hint: sHint } : {}),
|
|
264
310
|
response_body: parsedBody,
|
|
265
311
|
response_headers: filterHeaders(r.response_headers),
|
|
266
312
|
assertions: r.assertions,
|
|
@@ -268,77 +314,11 @@ export function diagnoseRun(runId: number, verbose?: boolean, dbPath?: string, m
|
|
|
268
314
|
};
|
|
269
315
|
});
|
|
270
316
|
|
|
271
|
-
// TASK-70 + TASK-98 — env_issue detector.
|
|
272
|
-
//
|
|
273
|
-
// Two passes:
|
|
274
|
-
// 1. Run-level: if every non-5xx failure shares a single env-category,
|
|
275
|
-
// treat it as a global env_issue (legacy TASK-70 behaviour). This
|
|
276
|
-
// catches the most common case — base_url unset, every test broken.
|
|
277
|
-
// 2. Suite-level clustering: group failures by suite, flag each suite
|
|
278
|
-
// whose non-5xx failures are ≥80% env-symptomatic (TASK-98). Catches
|
|
279
|
-
// per-suite missing variables, expired auth tokens, dead webhook
|
|
280
|
-
// hosts — situations where the run is *mixed* but a specific suite
|
|
281
|
-
// is clearly env-broken.
|
|
282
|
-
//
|
|
283
|
-
// The fix_env override only applies to failures inside an affected suite.
|
|
284
|
-
// 5xx (api_error) is excluded everywhere — backend bugs stay
|
|
285
|
-
// report_backend_bug regardless of env state.
|
|
286
|
-
let env_issue: EnvIssue | undefined;
|
|
287
|
-
const legacyEnvHint = computeSharedEnvIssue(failures, envFilePath);
|
|
288
|
-
const clusters = clusterEnvIssues(failures);
|
|
289
|
-
const built = buildEnvIssue(clusters, envFilePath);
|
|
290
|
-
|
|
291
|
-
let affectedSuites: Set<string>;
|
|
292
|
-
if (built) {
|
|
293
|
-
env_issue = built;
|
|
294
|
-
affectedSuites = new Set(built.affected_suites);
|
|
295
|
-
} else if (legacyEnvHint) {
|
|
296
|
-
// Legacy global env_issue (no clustered match — e.g. only one failure,
|
|
297
|
-
// or every suite has a single failing test). Preserve the original
|
|
298
|
-
// single-message form but expose it via the new envelope shape so
|
|
299
|
-
// downstream consumers see one stable contract.
|
|
300
|
-
const allSuites = [...new Set(failures.filter(f => f.failure_type !== "api_error").map(f => f.suite_name))].sort();
|
|
301
|
-
env_issue = {
|
|
302
|
-
message: legacyEnvHint,
|
|
303
|
-
scope: "run",
|
|
304
|
-
affected_suites: allSuites,
|
|
305
|
-
symptoms: {},
|
|
306
|
-
};
|
|
307
|
-
affectedSuites = new Set(allSuites);
|
|
308
|
-
} else {
|
|
309
|
-
affectedSuites = new Set();
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
if (env_issue) {
|
|
313
|
-
for (const f of failures) {
|
|
314
|
-
if (f.failure_type === "api_error") continue; // real backend bug — keep
|
|
315
|
-
if (!affectedSuites.has(f.suite_name)) continue; // out-of-scope suite
|
|
316
|
-
f.recommended_action = "fix_env";
|
|
317
|
-
delete f.hint;
|
|
318
|
-
delete f.schema_hint;
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
|
|
322
317
|
let apiErrors = 0, assertionFailures = 0, networkErrors = 0;
|
|
323
|
-
let authFailureCount = 0;
|
|
324
318
|
for (const f of failures) {
|
|
325
319
|
if (f.failure_type === "api_error") apiErrors++;
|
|
326
320
|
else if (f.failure_type === "assertion_failed") assertionFailures++;
|
|
327
321
|
else if (f.failure_type === "network_error") networkErrors++;
|
|
328
|
-
if (f.response_status === 401 || f.response_status === 403) authFailureCount++;
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
let agent_directive: string | undefined;
|
|
332
|
-
if (apiErrors > 0) {
|
|
333
|
-
const fixable = assertionFailures + networkErrors;
|
|
334
|
-
agent_directive =
|
|
335
|
-
`${apiErrors} test${apiErrors === 1 ? "" : "s"} returned 5xx server errors. ` +
|
|
336
|
-
`Do NOT change test expectations to accept 5xx responses. ` +
|
|
337
|
-
`These are backend bugs, not test logic errors. ` +
|
|
338
|
-
`Stop iterating on these tests and report the failures to the API team.` +
|
|
339
|
-
(fixable > 0
|
|
340
|
-
? ` The remaining ${fixable} failure${fixable === 1 ? "" : "s"} may be fixable in test logic.`
|
|
341
|
-
: "");
|
|
342
322
|
}
|
|
343
323
|
|
|
344
324
|
// Cascade skips: skipped tests due to missing captures from failed create steps
|
|
@@ -361,19 +341,6 @@ export function diagnoseRun(runId: number, verbose?: boolean, dbPath?: string, m
|
|
|
361
341
|
}))
|
|
362
342
|
: undefined;
|
|
363
343
|
|
|
364
|
-
// Auth hint: when many tests fail with 401/403, suggest auth setup
|
|
365
|
-
let auth_hint: string | undefined;
|
|
366
|
-
if (authFailureCount >= 5 && authFailureCount / diagRun.total >= 0.3) {
|
|
367
|
-
const loginEndpoint = allResults.find(
|
|
368
|
-
r => r.request_method?.toUpperCase() === "POST" && AUTH_PATH_RE.test(r.request_url ?? "")
|
|
369
|
-
);
|
|
370
|
-
if (loginEndpoint) {
|
|
371
|
-
auth_hint = `${authFailureCount} tests failed with 401/403. Found auth endpoint: POST ${loginEndpoint.request_url} — add \`setup: true\` to your auth suite so its captured token is shared with all other suites, or set auth_token manually in .env.yaml`;
|
|
372
|
-
} else {
|
|
373
|
-
auth_hint = `${authFailureCount} tests failed with 401/403 — add \`setup: true\` to your auth suite so its captured token is shared with all other suites, or set auth_token in .env.yaml`;
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
|
|
377
344
|
const { grouped_failures, compactFailures } = verbose
|
|
378
345
|
? { grouped_failures: undefined, compactFailures: failures }
|
|
379
346
|
: groupFailures(failures, maxExamples);
|
|
@@ -395,7 +362,13 @@ export function diagnoseRun(runId: number, verbose?: boolean, dbPath?: string, m
|
|
|
395
362
|
// from the full failure set (not compactFailures) so counts match
|
|
396
363
|
// .summary.failed. Bounded examples list (5) keeps payload small while
|
|
397
364
|
// still pointing at concrete suites the agent can open.
|
|
398
|
-
|
|
365
|
+
//
|
|
366
|
+
// ARV-228: each example is now an object carrying method/path/status/
|
|
367
|
+
// reason so the zond-triage skill can render its output template
|
|
368
|
+
// ("POST /v1/projects → 500 (×3) — TypeError") without cross-joining
|
|
369
|
+
// failures[]. Bound preserved at 5/bucket; ordering = insertion order
|
|
370
|
+
// (matches failures[] traversal, deterministic per run).
|
|
371
|
+
const by_recommended_action: Record<string, NonNullable<DiagnoseResult["by_recommended_action"]>[string]> = {};
|
|
399
372
|
for (const f of failures) {
|
|
400
373
|
const key = f.recommended_action;
|
|
401
374
|
let bucket = by_recommended_action[key];
|
|
@@ -405,7 +378,27 @@ export function diagnoseRun(runId: number, verbose?: boolean, dbPath?: string, m
|
|
|
405
378
|
}
|
|
406
379
|
bucket.count += 1;
|
|
407
380
|
if (bucket.examples.length < 5) {
|
|
408
|
-
bucket
|
|
381
|
+
// Trim reason to keep the bucket compact — full error_message lives
|
|
382
|
+
// in failures[].error_message for agents that want it.
|
|
383
|
+
//
|
|
384
|
+
// ARV-305: when there is no top-level error_message (typical for
|
|
385
|
+
// assertion failures — the row carries the failing rule in
|
|
386
|
+
// .assertions, not a free-form message), build a reason out of
|
|
387
|
+
// the first failing assertion so the example is not stripped
|
|
388
|
+
// down to method/path/status. The fallback keeps the field
|
|
389
|
+
// populated whenever any failure context exists.
|
|
390
|
+
const reason = buildExampleReason(f.error_message, f.assertions);
|
|
391
|
+
bucket.examples.push({
|
|
392
|
+
suite: f.suite_name,
|
|
393
|
+
test: f.test_name,
|
|
394
|
+
// DB columns are nullable (early steps may not have a request
|
|
395
|
+
// recorded yet — e.g. fixture-load failures). Coerce to "" / 0
|
|
396
|
+
// rather than leaking null into the contract.
|
|
397
|
+
method: f.request_method ?? "",
|
|
398
|
+
path: f.request_url ?? "",
|
|
399
|
+
status: f.response_status ?? 0,
|
|
400
|
+
...(reason ? { reason } : {}),
|
|
401
|
+
});
|
|
409
402
|
}
|
|
410
403
|
}
|
|
411
404
|
|
|
@@ -424,9 +417,6 @@ export function diagnoseRun(runId: number, verbose?: boolean, dbPath?: string, m
|
|
|
424
417
|
assertion_failures: assertionFailures,
|
|
425
418
|
network_errors: networkErrors,
|
|
426
419
|
},
|
|
427
|
-
...(agent_directive ? { agent_directive } : {}),
|
|
428
|
-
...(env_issue ? { env_issue } : {}),
|
|
429
|
-
...(auth_hint ? { auth_hint } : {}),
|
|
430
420
|
...(cascade_skips ? { cascade_skips } : {}),
|
|
431
421
|
...(suggestedFixes.length > 0 ? { suggested_fixes: suggestedFixes } : {}),
|
|
432
422
|
failures: compactFailures,
|
|
@@ -435,7 +425,7 @@ export function diagnoseRun(runId: number, verbose?: boolean, dbPath?: string, m
|
|
|
435
425
|
};
|
|
436
426
|
}
|
|
437
427
|
|
|
438
|
-
type FailureItem = { suite_name: string; test_name: string; failure_type: string; recommended_action: RecommendedAction;
|
|
428
|
+
type FailureItem = { suite_name: string; test_name: string; failure_type: string; recommended_action: RecommendedAction; response_status: number | null; group_count?: number };
|
|
439
429
|
|
|
440
430
|
/** Group similar failures for compact output. Exported for testing. */
|
|
441
431
|
export function groupFailures<T extends FailureItem>(failures: T[], maxExamples = 2): { grouped_failures?: FailureGroup[]; compactFailures: T[] } {
|
|
@@ -443,7 +433,7 @@ export function groupFailures<T extends FailureItem>(failures: T[], maxExamples
|
|
|
443
433
|
return { compactFailures: failures };
|
|
444
434
|
}
|
|
445
435
|
|
|
446
|
-
const groupMap = new Map<string, { items: T[]; failure_type: string;
|
|
436
|
+
const groupMap = new Map<string, { items: T[]; failure_type: string; response_status: number | null }>();
|
|
447
437
|
|
|
448
438
|
for (const f of failures) {
|
|
449
439
|
const key = `${f.response_status ?? "null"}|${f.failure_type}`;
|
|
@@ -454,7 +444,6 @@ export function groupFailures<T extends FailureItem>(failures: T[], maxExamples
|
|
|
454
444
|
groupMap.set(key, {
|
|
455
445
|
items: [f],
|
|
456
446
|
failure_type: f.failure_type,
|
|
457
|
-
hint: f.hint,
|
|
458
447
|
response_status: f.response_status,
|
|
459
448
|
});
|
|
460
449
|
}
|
|
@@ -482,7 +471,6 @@ export function groupFailures<T extends FailureItem>(failures: T[], maxExamples
|
|
|
482
471
|
count: group.items.length,
|
|
483
472
|
failure_type: group.failure_type,
|
|
484
473
|
recommended_action: group.items[0]!.recommended_action,
|
|
485
|
-
hint: group.hint,
|
|
486
474
|
examples: (showAll ? group.items : group.items.slice(0, maxExamples)).map(f => `${f.suite_name}/${f.test_name}`),
|
|
487
475
|
response_status: group.response_status,
|
|
488
476
|
});
|
|
@@ -499,6 +487,29 @@ export function groupFailures<T extends FailureItem>(failures: T[], maxExamples
|
|
|
499
487
|
return { grouped_failures, compactFailures };
|
|
500
488
|
}
|
|
501
489
|
|
|
490
|
+
export interface BodyFieldChange {
|
|
491
|
+
field: string;
|
|
492
|
+
change: "added" | "removed" | "type_changed";
|
|
493
|
+
before?: string;
|
|
494
|
+
after?: string;
|
|
495
|
+
/** ARV-352: structural scope of the change, derived deterministically from
|
|
496
|
+
* the path. `element` = the path crosses an array boundary (`[]`), i.e.
|
|
497
|
+
* it's a field of a *collection item* — on list/log endpoints two samplings
|
|
498
|
+
* return DIFFERENT objects, so element-level added/removed/type_changed is
|
|
499
|
+
* schema-of-union variance across the sampled set, not a contract move.
|
|
500
|
+
* `container` = no `[]` in the path — the response envelope/pagination
|
|
501
|
+
* skeleton, where a change IS real drift. NOT a suppression heuristic
|
|
502
|
+
* (ARV-337): nothing is dropped or down-ranked; the agent judges using the
|
|
503
|
+
* scope tag + endpoint context. */
|
|
504
|
+
scope: "container" | "element";
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
export interface BodyDiff {
|
|
508
|
+
suite: string;
|
|
509
|
+
test: string;
|
|
510
|
+
changes: BodyFieldChange[];
|
|
511
|
+
}
|
|
512
|
+
|
|
502
513
|
export interface CompareResult {
|
|
503
514
|
runA: { id: number; started_at: string };
|
|
504
515
|
runB: { id: number; started_at: string };
|
|
@@ -508,12 +519,73 @@ export interface CompareResult {
|
|
|
508
519
|
unchanged: number;
|
|
509
520
|
newTests: number;
|
|
510
521
|
removedTests: number;
|
|
522
|
+
bodyChanges: number;
|
|
523
|
+
/** ARV-352: of `bodyChanges`, how many touch the response envelope
|
|
524
|
+
* (`container`) vs collection-item fields (`element`). Element-heavy diffs
|
|
525
|
+
* on list/log endpoints are schema-of-union variance across a re-sampled
|
|
526
|
+
* set, not contract drift — split so triage doesn't read them as regression. */
|
|
527
|
+
bodyChangesContainer: number;
|
|
528
|
+
bodyChangesElement: number;
|
|
511
529
|
};
|
|
512
530
|
regressions: Array<{ suite: string; test: string; before: string; after: string }>;
|
|
513
531
|
fixes: Array<{ suite: string; test: string; before: string; after: string }>;
|
|
532
|
+
/** ARV-339: field-level response-shape diff for tests present in both runs.
|
|
533
|
+
* Status-diff answers "what broke"; this answers "how the contract moved". */
|
|
534
|
+
body_changes: BodyDiff[];
|
|
514
535
|
hasRegressions: boolean;
|
|
515
536
|
}
|
|
516
537
|
|
|
538
|
+
/** ARV-339: flatten a parsed JSON body into `path → union of leaf types`.
|
|
539
|
+
* Array elements collapse under `[]` so item count/order don't add noise. */
|
|
540
|
+
function bodyShape(value: unknown, path: string, out: Map<string, Set<string>>): void {
|
|
541
|
+
if (Array.isArray(value)) {
|
|
542
|
+
if (value.length === 0) addShape(out, path, "array");
|
|
543
|
+
for (const item of value) bodyShape(item, `${path}[]`, out);
|
|
544
|
+
} else if (value !== null && typeof value === "object") {
|
|
545
|
+
const entries = Object.entries(value as Record<string, unknown>);
|
|
546
|
+
if (entries.length === 0) addShape(out, path, "object");
|
|
547
|
+
for (const [k, v] of entries) bodyShape(v, path ? `${path}.${k}` : k, out);
|
|
548
|
+
} else {
|
|
549
|
+
addShape(out, path, value === null ? "null" : typeof value);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function addShape(out: Map<string, Set<string>>, path: string, type: string): void {
|
|
554
|
+
const key = path || "$";
|
|
555
|
+
const set = out.get(key) ?? new Set<string>();
|
|
556
|
+
set.add(type);
|
|
557
|
+
out.set(key, set);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const typeLabel = (s: Set<string>): string => [...s].sort().join("|");
|
|
561
|
+
|
|
562
|
+
/** ARV-339: diff two stored response bodies at field level. Returns [] when
|
|
563
|
+
* either side is missing / non-JSON — a shape diff of prose is meaningless. */
|
|
564
|
+
export function diffBodyShapes(rawA: string | null, rawB: string | null): BodyFieldChange[] {
|
|
565
|
+
if (!rawA || !rawB || rawA === rawB) return [];
|
|
566
|
+
let a: unknown, b: unknown;
|
|
567
|
+
try { a = JSON.parse(rawA); b = JSON.parse(rawB); } catch { return []; }
|
|
568
|
+
if (typeof a !== "object" || a === null || typeof b !== "object" || b === null) return [];
|
|
569
|
+
const shapeA = new Map<string, Set<string>>();
|
|
570
|
+
const shapeB = new Map<string, Set<string>>();
|
|
571
|
+
bodyShape(a, "", shapeA);
|
|
572
|
+
bodyShape(b, "", shapeB);
|
|
573
|
+
const scopeOf = (field: string): "container" | "element" =>
|
|
574
|
+
field.includes("[]") ? "element" : "container";
|
|
575
|
+
const changes: BodyFieldChange[] = [];
|
|
576
|
+
for (const [field, typesB] of shapeB) {
|
|
577
|
+
const typesA = shapeA.get(field);
|
|
578
|
+
if (!typesA) changes.push({ field, change: "added", after: typeLabel(typesB), scope: scopeOf(field) });
|
|
579
|
+
else if (typeLabel(typesA) !== typeLabel(typesB)) {
|
|
580
|
+
changes.push({ field, change: "type_changed", before: typeLabel(typesA), after: typeLabel(typesB), scope: scopeOf(field) });
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
for (const [field, typesA] of shapeA) {
|
|
584
|
+
if (!shapeB.has(field)) changes.push({ field, change: "removed", before: typeLabel(typesA), scope: scopeOf(field) });
|
|
585
|
+
}
|
|
586
|
+
return changes.sort((x, y) => x.field.localeCompare(y.field));
|
|
587
|
+
}
|
|
588
|
+
|
|
517
589
|
export function compareRuns(idA: number, idB: number, dbPath?: string): CompareResult {
|
|
518
590
|
getDb(dbPath);
|
|
519
591
|
const runARecord = getRunById(idA);
|
|
@@ -526,8 +598,16 @@ export function compareRuns(idA: number, idB: number, dbPath?: string): CompareR
|
|
|
526
598
|
|
|
527
599
|
const mapA = new Map<string, string>();
|
|
528
600
|
const mapB = new Map<string, string>();
|
|
529
|
-
|
|
530
|
-
|
|
601
|
+
const bodyA = new Map<string, string | null>();
|
|
602
|
+
const bodyB = new Map<string, string | null>();
|
|
603
|
+
for (const r of resultsA) {
|
|
604
|
+
mapA.set(`${r.suite_name}::${r.test_name}`, r.status);
|
|
605
|
+
bodyA.set(`${r.suite_name}::${r.test_name}`, r.response_body);
|
|
606
|
+
}
|
|
607
|
+
for (const r of resultsB) {
|
|
608
|
+
mapB.set(`${r.suite_name}::${r.test_name}`, r.status);
|
|
609
|
+
bodyB.set(`${r.suite_name}::${r.test_name}`, r.response_body);
|
|
610
|
+
}
|
|
531
611
|
|
|
532
612
|
const regressions: Array<{ suite: string; test: string; before: string; after: string }> = [];
|
|
533
613
|
const fixes: Array<{ suite: string; test: string; before: string; after: string }> = [];
|
|
@@ -535,10 +615,14 @@ export function compareRuns(idA: number, idB: number, dbPath?: string): CompareR
|
|
|
535
615
|
let newTests = 0;
|
|
536
616
|
let removedTests = 0;
|
|
537
617
|
|
|
618
|
+
const body_changes: BodyDiff[] = [];
|
|
619
|
+
|
|
538
620
|
for (const [key, statusB] of mapB) {
|
|
539
621
|
const statusA = mapA.get(key);
|
|
540
622
|
if (statusA === undefined) { newTests++; continue; }
|
|
541
623
|
const [suite, test] = key.split("::") as [string, string];
|
|
624
|
+
const changes = diffBodyShapes(bodyA.get(key) ?? null, bodyB.get(key) ?? null);
|
|
625
|
+
if (changes.length > 0) body_changes.push({ suite, test, changes });
|
|
542
626
|
const wasPass = statusA === "pass";
|
|
543
627
|
const isPass = statusB === "pass";
|
|
544
628
|
const wasFail = statusA === "fail" || statusA === "error";
|
|
@@ -554,9 +638,19 @@ export function compareRuns(idA: number, idB: number, dbPath?: string): CompareR
|
|
|
554
638
|
return {
|
|
555
639
|
runA: { id: idA, started_at: runARecord.started_at },
|
|
556
640
|
runB: { id: idB, started_at: runBRecord.started_at },
|
|
557
|
-
summary: {
|
|
641
|
+
summary: {
|
|
642
|
+
regressions: regressions.length,
|
|
643
|
+
fixes: fixes.length,
|
|
644
|
+
unchanged,
|
|
645
|
+
newTests,
|
|
646
|
+
removedTests,
|
|
647
|
+
bodyChanges: body_changes.length,
|
|
648
|
+
bodyChangesContainer: body_changes.filter(d => d.changes.some(c => c.scope === "container")).length,
|
|
649
|
+
bodyChangesElement: body_changes.filter(d => d.changes.every(c => c.scope === "element")).length,
|
|
650
|
+
},
|
|
558
651
|
regressions,
|
|
559
652
|
fixes,
|
|
653
|
+
body_changes,
|
|
560
654
|
hasRegressions: regressions.length > 0,
|
|
561
655
|
};
|
|
562
656
|
}
|
|
@@ -91,7 +91,27 @@ export function classifyFailure(result: StepResult): FailureClassification | nul
|
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
// 4.
|
|
94
|
+
// 4. ARV-236: 401/403 on a step that expected success → env_issue
|
|
95
|
+
// ("token_scope"). Surfaces narrowly-scoped tokens (Resend free plan,
|
|
96
|
+
// GitHub PAT without `repo`, Stripe restricted key, etc.) so coverage
|
|
97
|
+
// and triage don't tag them as fail. Only fires when the failing
|
|
98
|
+
// assertion is on `status` and the expected status was 2xx — a real
|
|
99
|
+
// "expect 403" test deliberately checking auth still classifies normally.
|
|
100
|
+
if (typeof respStatus === "number" && (respStatus === 401 || respStatus === 403)) {
|
|
101
|
+
const statusFail = failed.find((a) => a.field === "status");
|
|
102
|
+
if (statusFail) {
|
|
103
|
+
const expected = expectedStatusList(statusFail);
|
|
104
|
+
const allExpected2xx = expected?.every((s) => s >= 200 && s < 300) ?? false;
|
|
105
|
+
if (allExpected2xx) {
|
|
106
|
+
return {
|
|
107
|
+
failure_class: "env_issue",
|
|
108
|
+
failure_class_reason: `token_scope: API returned ${respStatus} where the test expected ${expected!.join("/")} — token lacks scope or plan for this endpoint`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 5. Negative-probe family — distinguish "API accepted bad input" (likely_bug)
|
|
95
115
|
// from "API rejected with a different 4xx" (quirk).
|
|
96
116
|
if (generator === "negative-probe" || generator === "method-probe") {
|
|
97
117
|
const statusFail = failed.find((a) => a.field === "status");
|