@kirrosh/zond 0.22.0 → 0.26.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/CHANGELOG.md +811 -0
- package/README.md +59 -6
- package/package.json +9 -7
- package/src/CLAUDE.md +112 -0
- package/src/cli/argv.ts +122 -0
- package/src/cli/commands/add-api.ts +146 -0
- package/src/cli/commands/api/annotate/idempotency.ts +59 -0
- package/src/cli/commands/api/annotate/index.ts +880 -0
- package/src/cli/commands/api/annotate/lifecycle.ts +74 -0
- package/src/cli/commands/api/annotate/overlay.ts +206 -0
- package/src/cli/commands/api/annotate/pagination.ts +64 -0
- package/src/cli/commands/api/annotate/prompts.ts +220 -0
- package/src/cli/commands/api/annotate/readback.ts +58 -0
- package/src/cli/commands/api/annotate/resources.ts +91 -0
- package/src/cli/commands/api/annotate/seed-bodies.ts +61 -0
- package/src/cli/commands/audit.ts +786 -0
- package/src/cli/commands/catalog.ts +35 -0
- package/src/cli/commands/check.ts +361 -0
- package/src/cli/commands/checks.ts +1072 -0
- package/src/cli/commands/ci-init.ts +43 -0
- package/src/cli/commands/clean.ts +212 -0
- package/src/cli/commands/cleanup.ts +236 -0
- package/src/cli/commands/completions.ts +16 -0
- package/src/cli/commands/coverage.ts +823 -132
- package/src/cli/commands/db.ts +486 -12
- package/src/cli/commands/describe.ts +37 -2
- package/src/cli/commands/discover.ts +1356 -0
- package/src/cli/commands/doctor.ts +661 -0
- package/src/cli/commands/fixtures.ts +402 -0
- package/src/cli/commands/generate.ts +438 -47
- package/src/cli/commands/init/bootstrap.ts +34 -2
- package/src/cli/commands/{init.ts → init/index.ts} +99 -5
- package/src/cli/commands/init/skills.ts +99 -3
- package/src/cli/commands/init/templates/agents.md +77 -64
- package/src/cli/commands/init/templates/skills/warm-up-target.md +122 -0
- package/src/cli/commands/init/templates/skills/zond-checks.md +621 -0
- package/src/cli/commands/init/templates/skills/zond-seed.md +114 -0
- package/src/cli/commands/init/templates/skills/zond-triage.md +272 -0
- package/src/cli/commands/init/templates/skills/zond.md +802 -125
- package/src/cli/commands/init/templates/zond-config.yml +8 -9
- package/src/cli/commands/prepare-fixtures.ts +97 -0
- package/src/cli/commands/probe/_seed-bodies.ts +52 -0
- package/src/cli/commands/probe/mass-assignment.ts +594 -0
- package/src/cli/commands/probe/security.ts +537 -0
- package/src/cli/commands/probe/static.ts +255 -0
- package/src/cli/commands/probe/webhooks.ts +163 -0
- package/src/cli/commands/probe.ts +535 -0
- package/src/cli/commands/reference.ts +87 -0
- package/src/cli/commands/refresh-api.ts +227 -0
- package/src/cli/commands/remove-api.ts +150 -0
- package/src/cli/commands/report-bundle.ts +310 -0
- package/src/cli/commands/report.ts +241 -0
- package/src/cli/commands/request.ts +495 -4
- package/src/cli/commands/run.ts +870 -53
- package/src/cli/commands/schema-from-runs.ts +128 -0
- package/src/cli/commands/secrets.ts +133 -0
- package/src/cli/commands/session.ts +244 -0
- package/src/cli/commands/use.ts +18 -1
- package/src/cli/index.ts +20 -3
- package/src/cli/json-envelope.ts +92 -3
- package/src/cli/json-schemas.ts +314 -0
- package/src/cli/output.ts +17 -1
- package/src/cli/program.ts +199 -635
- package/src/cli/resolve.ts +105 -0
- package/src/cli/safe-live.ts +24 -0
- package/src/cli/status-filter.ts +114 -0
- package/src/cli/util/api-context.ts +85 -0
- package/src/cli/version.ts +5 -0
- package/src/core/audit/persist.ts +183 -0
- package/src/core/checks/budget.ts +59 -0
- package/src/core/checks/checks/_crud-helpers.ts +133 -0
- package/src/core/checks/checks/_negative_mutator.ts +133 -0
- package/src/core/checks/checks/_readback-helpers.ts +133 -0
- package/src/core/checks/checks/content_type_conformance.ts +39 -0
- package/src/core/checks/checks/cross_call_references.ts +147 -0
- package/src/core/checks/checks/cursor_boundary_fuzzing.ts +219 -0
- package/src/core/checks/checks/ensure_resource_availability.ts +62 -0
- package/src/core/checks/checks/idempotency_replay.ts +242 -0
- package/src/core/checks/checks/ignored_auth.ts +254 -0
- package/src/core/checks/checks/index.ts +68 -0
- package/src/core/checks/checks/lifecycle_transitions.ts +416 -0
- package/src/core/checks/checks/missing_required_header.ts +40 -0
- package/src/core/checks/checks/negative_data_rejection.ts +148 -0
- package/src/core/checks/checks/not_a_server_error.ts +35 -0
- package/src/core/checks/checks/open_cors_on_sensitive.ts +160 -0
- package/src/core/checks/checks/pagination_invariants.ts +419 -0
- package/src/core/checks/checks/positive_data_acceptance.ts +33 -0
- package/src/core/checks/checks/rate_limit_headers_absent.ts +77 -0
- package/src/core/checks/checks/response_headers_conformance.ts +74 -0
- package/src/core/checks/checks/response_schema_conformance.ts +30 -0
- package/src/core/checks/checks/status_code_conformance.ts +132 -0
- package/src/core/checks/checks/unsupported_method.ts +63 -0
- package/src/core/checks/checks/use_after_free.ts +78 -0
- package/src/core/checks/index.ts +30 -0
- package/src/core/checks/mode.ts +82 -0
- package/src/core/checks/recommended-action.ts +68 -0
- package/src/core/checks/registry.ts +78 -0
- package/src/core/checks/runner.ts +1461 -0
- package/src/core/checks/sarif.ts +230 -0
- package/src/core/checks/spec-findings.ts +308 -0
- package/src/core/checks/stateful.ts +121 -0
- package/src/core/checks/types.ts +305 -0
- package/src/core/checks/zond-extensions.ts +73 -0
- package/src/core/classifier/recommended-action.ts +251 -0
- package/src/core/context/current.ts +22 -6
- package/src/core/context/session.ts +78 -0
- package/src/core/coverage/loader.ts +216 -0
- package/src/core/coverage/reasons.ts +300 -0
- package/src/core/diagnostics/db-analysis.ts +293 -59
- package/src/core/diagnostics/failure-class.ts +140 -0
- package/src/core/diagnostics/failure-hints.ts +88 -89
- package/src/core/diagnostics/spec-pointer.ts +99 -0
- package/src/core/diagnostics/suggested-fixes.ts +155 -0
- package/src/core/exporter/case-study/index.ts +270 -0
- package/src/core/exporter/curl.ts +40 -0
- package/src/core/exporter/exporter.ts +48 -0
- package/src/core/exporter/html-report/escape.ts +24 -0
- package/src/core/exporter/html-report/index.ts +479 -0
- package/src/core/exporter/html-report/script.ts +100 -0
- package/src/core/exporter/html-report/styles.ts +408 -0
- package/src/core/generator/chunker.ts +38 -19
- package/src/core/generator/coverage-phase.ts +0 -0
- package/src/core/generator/data-factory.ts +586 -22
- package/src/core/generator/describe.ts +1 -1
- package/src/core/generator/fixtures-builder.ts +332 -0
- package/src/core/generator/index.ts +5 -5
- package/src/core/generator/openapi-reader.ts +135 -7
- package/src/core/generator/path-param-disambig.ts +140 -0
- package/src/core/generator/resources-builder.ts +898 -0
- package/src/core/generator/schema-utils.ts +33 -3
- package/src/core/generator/serializer.ts +103 -13
- package/src/core/generator/suite-generator.ts +583 -122
- package/src/core/generator/types.ts +14 -0
- package/src/core/identity/identity-file.ts +0 -0
- package/src/core/lint/affects.ts +28 -0
- package/src/core/lint/config.ts +96 -0
- package/src/core/lint/format.ts +42 -0
- package/src/core/lint/index.ts +94 -0
- package/src/core/lint/reporter.ts +128 -0
- package/src/core/lint/rules/consistency.ts +158 -0
- package/src/core/lint/rules/heuristics.ts +97 -0
- package/src/core/lint/rules/strictness.ts +109 -0
- package/src/core/lint/types.ts +96 -0
- package/src/core/lint/walker.ts +248 -0
- package/src/core/meta/meta-store.ts +6 -73
- package/src/core/output/README.md +73 -0
- package/src/core/output/index.ts +13 -0
- package/src/core/output/run.ts +91 -0
- package/src/core/output/types.ts +122 -0
- package/src/core/parser/dynamic-values.ts +160 -0
- package/src/core/parser/env-interpolation.ts +104 -0
- package/src/core/parser/filter.ts +57 -0
- package/src/core/parser/schema.ts +129 -4
- package/src/core/parser/types.ts +19 -1
- package/src/core/parser/variables.ts +0 -0
- package/src/core/parser/yaml-parser.ts +58 -12
- package/src/core/probe/bootstrap.ts +34 -0
- package/src/core/probe/dry-run-envelope.ts +61 -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-class.ts +198 -0
- package/src/core/probe/mass-assignment-probe.ts +27 -0
- package/src/core/probe/mass-assignment-template.ts +240 -0
- package/src/core/probe/method-probe.ts +43 -76
- package/src/core/probe/method-shared.ts +69 -0
- package/src/core/probe/negative-probe.ts +183 -149
- package/src/core/probe/orphan-tracker.ts +188 -0
- package/src/core/probe/path-discovery.ts +439 -0
- package/src/core/probe/probe-harness.ts +119 -0
- package/src/core/probe/registry.ts +89 -0
- package/src/core/probe/runner.ts +136 -0
- 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 +207 -0
- package/src/core/probe/security-probe.ts +32 -0
- package/src/core/probe/shared.ts +531 -0
- package/src/core/probe/static-probe-class.ts +125 -0
- package/src/core/probe/types.ts +165 -0
- package/src/core/probe/verdict-aggregator.ts +33 -0
- package/src/core/probe/webhooks-probe.ts +282 -0
- package/src/core/reporter/console.ts +41 -2
- package/src/core/reporter/index.ts +2 -3
- package/src/core/reporter/json.ts +11 -1
- package/src/core/reporter/junit.ts +27 -12
- package/src/core/reporter/ndjson.ts +37 -0
- package/src/core/reporter/types.ts +3 -0
- package/src/core/runner/assertions.ts +59 -2
- package/src/core/runner/async-pool.ts +108 -0
- package/src/core/runner/auth-path.ts +8 -0
- package/src/core/runner/ci-context.ts +72 -0
- package/src/core/runner/executor.ts +265 -36
- package/src/core/runner/form-encode.ts +41 -0
- package/src/core/runner/http-client.ts +112 -2
- package/src/core/runner/learn-drift.ts +293 -0
- package/src/core/runner/preflight-vars.ts +153 -0
- package/src/core/runner/progress-tracker.ts +73 -0
- package/src/core/runner/rate-limiter.ts +87 -33
- package/src/core/runner/run-kind.ts +45 -0
- package/src/core/runner/schema-validator.ts +308 -0
- package/src/core/runner/send-request.ts +158 -20
- package/src/core/runner/types.ts +44 -0
- package/src/core/secrets/registry.ts +164 -0
- package/src/core/secrets/secrets-file.ts +115 -0
- package/src/core/selectors/operation-filter.ts +144 -0
- package/src/core/setup-api.ts +457 -20
- package/src/core/severity/category.ts +94 -0
- package/src/core/severity/index.ts +58 -0
- package/src/core/spec/infer-schema.ts +102 -0
- package/src/core/spec/layers.ts +154 -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/format-eta.ts +21 -0
- package/src/core/util/headers.ts +9 -0
- package/src/core/util/url.ts +24 -0
- package/src/core/utils.ts +5 -1
- package/src/core/workspace/config.ts +129 -0
- package/src/core/workspace/fixture-gap-report.ts +84 -0
- package/src/core/workspace/fixture-gaps.ts +71 -0
- package/src/core/workspace/manifest.ts +283 -0
- package/src/core/workspace/output-rotation.ts +62 -0
- package/src/core/workspace/root.ts +13 -11
- package/src/core/workspace/triage-path.ts +87 -0
- package/src/db/lint-runs.ts +47 -0
- package/src/db/migrate.ts +128 -0
- package/src/db/migrations/0001_run_kind.sql +25 -0
- package/src/db/migrations/0002_run_kind_request.sql +59 -0
- package/src/db/migrations/sql.d.ts +4 -0
- package/src/db/queries/collections.ts +133 -0
- package/src/db/queries/coverage.ts +9 -0
- package/src/db/queries/dashboard.ts +59 -0
- package/src/db/queries/results.ts +216 -0
- package/src/db/queries/runs.ts +289 -0
- package/src/db/queries/sessions.ts +42 -0
- package/src/db/queries/settings.ts +28 -0
- package/src/db/queries/types.ts +172 -0
- package/src/db/queries.ts +75 -802
- package/src/db/schema.ts +178 -50
- package/src/cli/commands/export.ts +0 -144
- package/src/cli/commands/guide.ts +0 -127
- package/src/cli/commands/init/templates/skills/scenarios.md +0 -97
- package/src/cli/commands/probe-methods.ts +0 -108
- package/src/cli/commands/probe-validation.ts +0 -124
- package/src/cli/commands/serve.ts +0 -114
- package/src/cli/commands/sync.ts +0 -268
- package/src/cli/commands/update.ts +0 -189
- package/src/cli/commands/validate.ts +0 -34
- package/src/core/diagnostics/render-md.ts +0 -112
- package/src/core/exporter/postman.ts +0 -963
- package/src/core/generator/guide-builder.ts +0 -253
- package/src/core/meta/types.ts +0 -19
- package/src/core/parser/index.ts +0 -21
- package/src/core/runner/execute-run.ts +0 -132
- package/src/core/runner/index.ts +0 -12
- package/src/core/sync/spec-differ.ts +0 -38
- package/src/web/data/collection-state.ts +0 -362
- package/src/web/routes/api.ts +0 -314
- package/src/web/routes/dashboard.ts +0 -350
- package/src/web/routes/runs.ts +0 -64
- package/src/web/schemas.ts +0 -121
- package/src/web/server.ts +0 -134
- package/src/web/static/htmx.min.cjs +0 -1
- package/src/web/static/style.css +0 -1148
- package/src/web/views/endpoints-tab.ts +0 -174
- package/src/web/views/explorer-tab.ts +0 -402
- package/src/web/views/health-strip.ts +0 -92
- package/src/web/views/layout.ts +0 -48
- package/src/web/views/results.ts +0 -210
- package/src/web/views/runs-tab.ts +0 -126
- package/src/web/views/suites-tab.ts +0 -181
|
@@ -1,10 +1,10 @@
|
|
|
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 {
|
|
5
|
-
import {
|
|
4
|
+
import { classifyFailure, recommendedActionForGenerated, isGeneratedTest, type RecommendedAction } from "./failure-hints.ts";
|
|
5
|
+
import { buildSuggestedFixes, type SuggestedFix } from "./suggested-fixes.ts";
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
function truncateErrorMessage(raw: string | null | undefined, verbose?: boolean): string | undefined {
|
|
8
8
|
if (!raw) return undefined;
|
|
9
9
|
if (verbose || raw.length < 500) return raw;
|
|
10
10
|
const lines = raw.split(/\r?\n/);
|
|
@@ -24,7 +24,49 @@ export function truncateErrorMessage(raw: string | null | undefined, verbose?: b
|
|
|
24
24
|
return msgLines.join("\n");
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
|
|
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
|
+
|
|
69
|
+
function parseBodySafe(raw: string | null | undefined): unknown {
|
|
28
70
|
if (!raw) return undefined;
|
|
29
71
|
const truncated = raw.length > 2000 ? raw.slice(0, 2000) + "\u2026[truncated]" : raw;
|
|
30
72
|
try {
|
|
@@ -40,7 +82,33 @@ const USEFUL_HEADERS = new Set([
|
|
|
40
82
|
]);
|
|
41
83
|
const USEFUL_PREFIXES = ["x-", "ratelimit"];
|
|
42
84
|
|
|
43
|
-
|
|
85
|
+
/** ARV-103 (F8): true when at least one assertion on the failing step is
|
|
86
|
+
* a schema-validation kind. `--validate-schema` annotates each violated
|
|
87
|
+
* field with `kind: "schema"` (set in src/core/runner/schema-validator.ts).
|
|
88
|
+
* The assertions column is stored as JSON in SQLite; parse defensively. */
|
|
89
|
+
function hasSchemaAssertion(raw: string | unknown[] | null | undefined): boolean {
|
|
90
|
+
if (raw === null || raw === undefined) return false;
|
|
91
|
+
let arr: unknown[];
|
|
92
|
+
if (Array.isArray(raw)) {
|
|
93
|
+
arr = raw;
|
|
94
|
+
} else if (typeof raw === "string") {
|
|
95
|
+
try {
|
|
96
|
+
const parsed = JSON.parse(raw);
|
|
97
|
+
if (!Array.isArray(parsed)) return false;
|
|
98
|
+
arr = parsed;
|
|
99
|
+
} catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
} else {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
for (const a of arr) {
|
|
106
|
+
if (a && typeof a === "object" && (a as { kind?: unknown }).kind === "schema") return true;
|
|
107
|
+
}
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function filterHeaders(raw: string | null | undefined): Record<string, string> | undefined {
|
|
44
112
|
if (!raw) return undefined;
|
|
45
113
|
try {
|
|
46
114
|
const h = JSON.parse(raw) as Record<string, string>;
|
|
@@ -118,7 +186,6 @@ export interface FailureGroup {
|
|
|
118
186
|
count: number;
|
|
119
187
|
failure_type: string;
|
|
120
188
|
recommended_action: RecommendedAction;
|
|
121
|
-
hint?: string;
|
|
122
189
|
examples: string[];
|
|
123
190
|
response_status: number | null;
|
|
124
191
|
}
|
|
@@ -144,10 +211,11 @@ export interface DiagnoseResult {
|
|
|
144
211
|
assertion_failures: number;
|
|
145
212
|
network_errors: number;
|
|
146
213
|
};
|
|
147
|
-
agent_directive?: string;
|
|
148
|
-
env_issue?: string;
|
|
149
|
-
auth_hint?: string;
|
|
150
214
|
cascade_skips?: CascadeSkipGroup[];
|
|
215
|
+
/** TASK-29: actionable suggestions populated from 404 placeholder
|
|
216
|
+
* detection + .env.yaml unfilled-key audit. Empty / undefined when
|
|
217
|
+
* nothing actionable was found. */
|
|
218
|
+
suggested_fixes?: SuggestedFix[];
|
|
151
219
|
failures: Array<{
|
|
152
220
|
suite_name: string;
|
|
153
221
|
test_name: string;
|
|
@@ -159,14 +227,44 @@ export interface DiagnoseResult {
|
|
|
159
227
|
request_method: string | null;
|
|
160
228
|
request_url: string | null;
|
|
161
229
|
response_status: number | null;
|
|
162
|
-
hint?: string;
|
|
163
|
-
schema_hint?: string;
|
|
164
230
|
response_body?: unknown;
|
|
165
231
|
response_headers?: Record<string, string>;
|
|
166
232
|
assertions: unknown;
|
|
167
233
|
duration_ms: number | null;
|
|
234
|
+
/** ARV-159: when this entry is the representative of a collapsed group
|
|
235
|
+
* (status|failure_type signature), the total size of that group. Lets
|
|
236
|
+
* consumers reading `.data.failures[]` see "this signature stands for
|
|
237
|
+
* N underlying tests" without cross-referencing `.grouped_failures[]`.
|
|
238
|
+
* Omitted when no collapsing occurred (failures ≤ 5 or
|
|
239
|
+
* --verbose). */
|
|
240
|
+
group_count?: number;
|
|
168
241
|
}>;
|
|
169
242
|
grouped_failures?: FailureGroup[];
|
|
243
|
+
/** ARV-101 (F6): top-level aggregation keyed by `recommended_action`
|
|
244
|
+
* enum so triage agents (zond-triage skill) can route on the canonical
|
|
245
|
+
* action without re-folding `failures[].recommended_action` through
|
|
246
|
+
* `jq | group_by`. Built from the *full* failure set (not the compact
|
|
247
|
+
* subset), so counts match `.summary.failed`. Each bucket carries
|
|
248
|
+
* total count + a small examples list (`<suite>/<test>`). Empty when
|
|
249
|
+
* there are no failures. */
|
|
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
|
+
}>;
|
|
170
268
|
}
|
|
171
269
|
|
|
172
270
|
export function diagnoseRun(runId: number, verbose?: boolean, dbPath?: string, maxExamples?: number): DiagnoseResult {
|
|
@@ -187,12 +285,17 @@ export function diagnoseRun(runId: number, verbose?: boolean, dbPath?: string, m
|
|
|
187
285
|
.filter(r => r.status === "fail" || r.status === "error")
|
|
188
286
|
.map(r => {
|
|
189
287
|
const parsedBody = parseBodySafe(r.response_body);
|
|
190
|
-
const hint = envHint(r.request_url, r.error_message, envFilePath) ??
|
|
191
|
-
softDeleteHint(r.response_status, r.request_method, parsedBody) ??
|
|
192
|
-
statusHint(r.response_status);
|
|
193
288
|
const failure_type = classifyFailure(r.status, r.response_status);
|
|
194
|
-
|
|
195
|
-
|
|
289
|
+
// ARV-42: generator-emitted suites should not route to fix_test_logic —
|
|
290
|
+
// editing the YAML gets clobbered on the next `zond audit`.
|
|
291
|
+
const generated = isGeneratedTest(r.provenance, r.suite_file);
|
|
292
|
+
// ARV-103 (F8): walk the assertions array to detect a schema-kind
|
|
293
|
+
// failure (--validate-schema annotates each assertion with its kind).
|
|
294
|
+
// When present, propagate the flag so the classifier routes to
|
|
295
|
+
// report_backend_bug — schema violations are real contract bugs, not
|
|
296
|
+
// test-logic mistakes.
|
|
297
|
+
const schema_violation = hasSchemaAssertion(r.assertions);
|
|
298
|
+
const rec_action = recommendedActionForGenerated(failure_type, r.response_status, generated, schema_violation);
|
|
196
299
|
return {
|
|
197
300
|
suite_name: r.suite_name,
|
|
198
301
|
test_name: r.test_name,
|
|
@@ -204,8 +307,6 @@ export function diagnoseRun(runId: number, verbose?: boolean, dbPath?: string, m
|
|
|
204
307
|
request_method: r.request_method,
|
|
205
308
|
request_url: r.request_url,
|
|
206
309
|
response_status: r.response_status,
|
|
207
|
-
...(hint ? { hint } : {}),
|
|
208
|
-
...(sHint ? { schema_hint: sHint } : {}),
|
|
209
310
|
response_body: parsedBody,
|
|
210
311
|
response_headers: filterHeaders(r.response_headers),
|
|
211
312
|
assertions: r.assertions,
|
|
@@ -213,28 +314,11 @@ export function diagnoseRun(runId: number, verbose?: boolean, dbPath?: string, m
|
|
|
213
314
|
};
|
|
214
315
|
});
|
|
215
316
|
|
|
216
|
-
const sharedEnvHint = computeSharedEnvIssue(failures, envFilePath);
|
|
217
|
-
|
|
218
317
|
let apiErrors = 0, assertionFailures = 0, networkErrors = 0;
|
|
219
|
-
let authFailureCount = 0;
|
|
220
318
|
for (const f of failures) {
|
|
221
319
|
if (f.failure_type === "api_error") apiErrors++;
|
|
222
320
|
else if (f.failure_type === "assertion_failed") assertionFailures++;
|
|
223
321
|
else if (f.failure_type === "network_error") networkErrors++;
|
|
224
|
-
if (f.response_status === 401 || f.response_status === 403) authFailureCount++;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
let agent_directive: string | undefined;
|
|
228
|
-
if (apiErrors > 0) {
|
|
229
|
-
const fixable = assertionFailures + networkErrors;
|
|
230
|
-
agent_directive =
|
|
231
|
-
`${apiErrors} test${apiErrors === 1 ? "" : "s"} returned 5xx server errors. ` +
|
|
232
|
-
`Do NOT change test expectations to accept 5xx responses. ` +
|
|
233
|
-
`These are backend bugs, not test logic errors. ` +
|
|
234
|
-
`Stop iterating on these tests and report the failures to the API team.` +
|
|
235
|
-
(fixable > 0
|
|
236
|
-
? ` The remaining ${fixable} failure${fixable === 1 ? "" : "s"} may be fixable in test logic.`
|
|
237
|
-
: "");
|
|
238
322
|
}
|
|
239
323
|
|
|
240
324
|
// Cascade skips: skipped tests due to missing captures from failed create steps
|
|
@@ -257,23 +341,67 @@ export function diagnoseRun(runId: number, verbose?: boolean, dbPath?: string, m
|
|
|
257
341
|
}))
|
|
258
342
|
: undefined;
|
|
259
343
|
|
|
260
|
-
// Auth hint: when many tests fail with 401/403, suggest auth setup
|
|
261
|
-
let auth_hint: string | undefined;
|
|
262
|
-
if (authFailureCount >= 5 && authFailureCount / diagRun.total >= 0.3) {
|
|
263
|
-
const loginEndpoint = allResults.find(
|
|
264
|
-
r => r.request_method?.toUpperCase() === "POST" && AUTH_PATH_RE.test(r.request_url ?? "")
|
|
265
|
-
);
|
|
266
|
-
if (loginEndpoint) {
|
|
267
|
-
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`;
|
|
268
|
-
} else {
|
|
269
|
-
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`;
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
|
|
273
344
|
const { grouped_failures, compactFailures } = verbose
|
|
274
345
|
? { grouped_failures: undefined, compactFailures: failures }
|
|
275
346
|
: groupFailures(failures, maxExamples);
|
|
276
347
|
|
|
348
|
+
// TASK-29: surface placeholder path-params + unfilled .env.yaml keys.
|
|
349
|
+
const suggestedFixes = buildSuggestedFixes({
|
|
350
|
+
failures: failures.map(f => ({
|
|
351
|
+
response_status: f.response_status,
|
|
352
|
+
request_url: f.request_url,
|
|
353
|
+
suite_name: f.suite_name,
|
|
354
|
+
test_name: f.test_name,
|
|
355
|
+
})),
|
|
356
|
+
envFilePath,
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
// ARV-101 (F6): aggregate failures by recommended_action enum so triage
|
|
360
|
+
// agents read .data.by_recommended_action.fix_env.count instead of
|
|
361
|
+
// re-folding failures[].recommended_action through `jq | group_by`. Built
|
|
362
|
+
// from the full failure set (not compactFailures) so counts match
|
|
363
|
+
// .summary.failed. Bounded examples list (5) keeps payload small while
|
|
364
|
+
// still pointing at concrete suites the agent can open.
|
|
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]> = {};
|
|
372
|
+
for (const f of failures) {
|
|
373
|
+
const key = f.recommended_action;
|
|
374
|
+
let bucket = by_recommended_action[key];
|
|
375
|
+
if (!bucket) {
|
|
376
|
+
bucket = { count: 0, examples: [] };
|
|
377
|
+
by_recommended_action[key] = bucket;
|
|
378
|
+
}
|
|
379
|
+
bucket.count += 1;
|
|
380
|
+
if (bucket.examples.length < 5) {
|
|
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
|
+
});
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
277
405
|
return {
|
|
278
406
|
run: {
|
|
279
407
|
id: diagRun.id,
|
|
@@ -289,16 +417,15 @@ export function diagnoseRun(runId: number, verbose?: boolean, dbPath?: string, m
|
|
|
289
417
|
assertion_failures: assertionFailures,
|
|
290
418
|
network_errors: networkErrors,
|
|
291
419
|
},
|
|
292
|
-
...(agent_directive ? { agent_directive } : {}),
|
|
293
|
-
...(sharedEnvHint ? { env_issue: sharedEnvHint } : {}),
|
|
294
|
-
...(auth_hint ? { auth_hint } : {}),
|
|
295
420
|
...(cascade_skips ? { cascade_skips } : {}),
|
|
421
|
+
...(suggestedFixes.length > 0 ? { suggested_fixes: suggestedFixes } : {}),
|
|
296
422
|
failures: compactFailures,
|
|
297
423
|
...(grouped_failures ? { grouped_failures } : {}),
|
|
424
|
+
...(failures.length > 0 ? { by_recommended_action } : {}),
|
|
298
425
|
};
|
|
299
426
|
}
|
|
300
427
|
|
|
301
|
-
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 };
|
|
302
429
|
|
|
303
430
|
/** Group similar failures for compact output. Exported for testing. */
|
|
304
431
|
export function groupFailures<T extends FailureItem>(failures: T[], maxExamples = 2): { grouped_failures?: FailureGroup[]; compactFailures: T[] } {
|
|
@@ -306,7 +433,7 @@ export function groupFailures<T extends FailureItem>(failures: T[], maxExamples
|
|
|
306
433
|
return { compactFailures: failures };
|
|
307
434
|
}
|
|
308
435
|
|
|
309
|
-
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 }>();
|
|
310
437
|
|
|
311
438
|
for (const f of failures) {
|
|
312
439
|
const key = `${f.response_status ?? "null"}|${f.failure_type}`;
|
|
@@ -317,7 +444,6 @@ export function groupFailures<T extends FailureItem>(failures: T[], maxExamples
|
|
|
317
444
|
groupMap.set(key, {
|
|
318
445
|
items: [f],
|
|
319
446
|
failure_type: f.failure_type,
|
|
320
|
-
hint: f.hint,
|
|
321
447
|
response_status: f.response_status,
|
|
322
448
|
});
|
|
323
449
|
}
|
|
@@ -345,20 +471,45 @@ export function groupFailures<T extends FailureItem>(failures: T[], maxExamples
|
|
|
345
471
|
count: group.items.length,
|
|
346
472
|
failure_type: group.failure_type,
|
|
347
473
|
recommended_action: group.items[0]!.recommended_action,
|
|
348
|
-
hint: group.hint,
|
|
349
474
|
examples: (showAll ? group.items : group.items.slice(0, maxExamples)).map(f => `${f.suite_name}/${f.test_name}`),
|
|
350
475
|
response_status: group.response_status,
|
|
351
476
|
});
|
|
352
477
|
if (isApiError) {
|
|
353
478
|
compactFailures.push(...group.items);
|
|
354
479
|
} else {
|
|
355
|
-
|
|
480
|
+
// ARV-159: tag the representative with the group size so
|
|
481
|
+
// `.data.failures[]` carries the multiplier inline.
|
|
482
|
+
const rep = { ...group.items[0]!, group_count: group.items.length };
|
|
483
|
+
compactFailures.push(rep as T);
|
|
356
484
|
}
|
|
357
485
|
}
|
|
358
486
|
|
|
359
487
|
return { grouped_failures, compactFailures };
|
|
360
488
|
}
|
|
361
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
|
+
|
|
362
513
|
export interface CompareResult {
|
|
363
514
|
runA: { id: number; started_at: string };
|
|
364
515
|
runB: { id: number; started_at: string };
|
|
@@ -368,12 +519,73 @@ export interface CompareResult {
|
|
|
368
519
|
unchanged: number;
|
|
369
520
|
newTests: number;
|
|
370
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;
|
|
371
529
|
};
|
|
372
530
|
regressions: Array<{ suite: string; test: string; before: string; after: string }>;
|
|
373
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[];
|
|
374
535
|
hasRegressions: boolean;
|
|
375
536
|
}
|
|
376
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
|
+
|
|
377
589
|
export function compareRuns(idA: number, idB: number, dbPath?: string): CompareResult {
|
|
378
590
|
getDb(dbPath);
|
|
379
591
|
const runARecord = getRunById(idA);
|
|
@@ -386,8 +598,16 @@ export function compareRuns(idA: number, idB: number, dbPath?: string): CompareR
|
|
|
386
598
|
|
|
387
599
|
const mapA = new Map<string, string>();
|
|
388
600
|
const mapB = new Map<string, string>();
|
|
389
|
-
|
|
390
|
-
|
|
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
|
+
}
|
|
391
611
|
|
|
392
612
|
const regressions: Array<{ suite: string; test: string; before: string; after: string }> = [];
|
|
393
613
|
const fixes: Array<{ suite: string; test: string; before: string; after: string }> = [];
|
|
@@ -395,10 +615,14 @@ export function compareRuns(idA: number, idB: number, dbPath?: string): CompareR
|
|
|
395
615
|
let newTests = 0;
|
|
396
616
|
let removedTests = 0;
|
|
397
617
|
|
|
618
|
+
const body_changes: BodyDiff[] = [];
|
|
619
|
+
|
|
398
620
|
for (const [key, statusB] of mapB) {
|
|
399
621
|
const statusA = mapA.get(key);
|
|
400
622
|
if (statusA === undefined) { newTests++; continue; }
|
|
401
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 });
|
|
402
626
|
const wasPass = statusA === "pass";
|
|
403
627
|
const isPass = statusB === "pass";
|
|
404
628
|
const wasFail = statusA === "fail" || statusA === "error";
|
|
@@ -414,9 +638,19 @@ export function compareRuns(idA: number, idB: number, dbPath?: string): CompareR
|
|
|
414
638
|
return {
|
|
415
639
|
runA: { id: idA, started_at: runARecord.started_at },
|
|
416
640
|
runB: { id: idB, started_at: runBRecord.started_at },
|
|
417
|
-
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
|
+
},
|
|
418
651
|
regressions,
|
|
419
652
|
fixes,
|
|
653
|
+
body_changes,
|
|
420
654
|
hasRegressions: regressions.length > 0,
|
|
421
655
|
};
|
|
422
656
|
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TASK-101: failure classification — definitely_bug / likely_bug / quirk / env_issue.
|
|
3
|
+
*
|
|
4
|
+
* Goal: бэкендер за секунду видит «реально баг» vs «quirk зонда / probe
|
|
5
|
+
* фолс-позитив». Чисто read-only классификация: не меняет статус step,
|
|
6
|
+
* не влияет на pass/fail. Только tag-овая аналитика.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { StepResult, AssertionResult } from "../runner/types.ts";
|
|
10
|
+
import type { SourceMetadata } from "../parser/types.ts";
|
|
11
|
+
|
|
12
|
+
export type FailureClass =
|
|
13
|
+
| "definitely_bug"
|
|
14
|
+
| "likely_bug"
|
|
15
|
+
| "quirk"
|
|
16
|
+
| "env_issue"
|
|
17
|
+
/** Step was skipped because an upstream step failed to produce a required
|
|
18
|
+
* capture (or produced a tainted one). Not a failure on its own — render
|
|
19
|
+
* collapsed under the root failure to avoid drowning the user. */
|
|
20
|
+
| "cascade";
|
|
21
|
+
|
|
22
|
+
export interface FailureClassification {
|
|
23
|
+
failure_class: FailureClass;
|
|
24
|
+
failure_class_reason: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function failedAssertionsOf(result: StepResult): AssertionResult[] {
|
|
28
|
+
return result.assertions.filter((a) => !a.passed);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function ruleStartsWith(a: AssertionResult, prefix: string): boolean {
|
|
32
|
+
return typeof a.rule === "string" && a.rule.startsWith(prefix);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function expectedStatusList(a: AssertionResult): number[] | null {
|
|
36
|
+
if (Array.isArray(a.expected)) {
|
|
37
|
+
const arr = a.expected.filter((v): v is number => typeof v === "number");
|
|
38
|
+
return arr.length > 0 ? arr : null;
|
|
39
|
+
}
|
|
40
|
+
return typeof a.expected === "number" ? [a.expected] : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Classify a failed step. Returns `null` for pass/skip/unclassifiable failures —
|
|
45
|
+
* UI renders those as "unclassified" rather than crashing.
|
|
46
|
+
*/
|
|
47
|
+
export function classifyFailure(result: StepResult): FailureClassification | null {
|
|
48
|
+
if (result.status === "pass" || result.status === "skip") return null;
|
|
49
|
+
|
|
50
|
+
// Network/runtime error before any HTTP response → env-side
|
|
51
|
+
if (result.status === "error") {
|
|
52
|
+
return {
|
|
53
|
+
failure_class: "env_issue",
|
|
54
|
+
failure_class_reason: result.error ?? "request failed before producing a response",
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const respStatus = result.response?.status;
|
|
59
|
+
const provenance: SourceMetadata | null | undefined = result.provenance;
|
|
60
|
+
const generator = typeof provenance?.generator === "string" ? provenance.generator : undefined;
|
|
61
|
+
const failed = failedAssertionsOf(result);
|
|
62
|
+
|
|
63
|
+
// 1. Backend 5xx — always a backend bug regardless of test intent.
|
|
64
|
+
if (typeof respStatus === "number" && respStatus >= 500) {
|
|
65
|
+
return {
|
|
66
|
+
failure_class: "definitely_bug",
|
|
67
|
+
failure_class_reason: `API returned ${respStatus} — server-side error`,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 2. Response did not match its OpenAPI schema — spec guarantees X, server returned ≠ X.
|
|
72
|
+
const schemaFail = failed.find((a) => ruleStartsWith(a, "schema."));
|
|
73
|
+
if (schemaFail) {
|
|
74
|
+
return {
|
|
75
|
+
failure_class: "definitely_bug",
|
|
76
|
+
failure_class_reason: `Response violates OpenAPI schema at ${schemaFail.field}`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 3. Mass-assignment probe: extras must not apply. A failed not_equals
|
|
81
|
+
// assertion on this generator means the sentinel value leaked through.
|
|
82
|
+
if (generator === "mass-assignment-probe") {
|
|
83
|
+
const extrasLeak = failed.find(
|
|
84
|
+
(a) => ruleStartsWith(a, "not_equals") || ruleStartsWith(a, "set_equals"),
|
|
85
|
+
);
|
|
86
|
+
if (extrasLeak) {
|
|
87
|
+
return {
|
|
88
|
+
failure_class: "definitely_bug",
|
|
89
|
+
failure_class_reason: `Mass-assignment: client-supplied extras were accepted (${extrasLeak.field})`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
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)
|
|
115
|
+
// from "API rejected with a different 4xx" (quirk).
|
|
116
|
+
if (generator === "negative-probe" || generator === "method-probe") {
|
|
117
|
+
const statusFail = failed.find((a) => a.field === "status");
|
|
118
|
+
if (statusFail && typeof respStatus === "number") {
|
|
119
|
+
const expected = expectedStatusList(statusFail);
|
|
120
|
+
const allExpected4xx = expected?.every((s) => s >= 400 && s < 500) ?? false;
|
|
121
|
+
if (allExpected4xx) {
|
|
122
|
+
if (respStatus >= 200 && respStatus < 300) {
|
|
123
|
+
return {
|
|
124
|
+
failure_class: "likely_bug",
|
|
125
|
+
failure_class_reason: `Negative probe expected 4xx, got ${respStatus} — API accepts invalid input`,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
if (respStatus >= 400 && respStatus < 500) {
|
|
129
|
+
return {
|
|
130
|
+
failure_class: "quirk",
|
|
131
|
+
failure_class_reason: `Negative probe expected ${expected!.join("/")}, got ${respStatus} — different 4xx code`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Default: leave unclassified. UI / CLI render as "unclassified".
|
|
139
|
+
return null;
|
|
140
|
+
}
|