@kirrosh/zond 0.22.0 → 0.23.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 +648 -0
- package/README.md +58 -6
- package/package.json +9 -6
- package/src/cli/argv.ts +122 -0
- package/src/cli/commands/add-api.ts +134 -0
- package/src/cli/commands/api/annotate/idempotency.ts +59 -0
- package/src/cli/commands/api/annotate/index.ts +525 -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 +60 -0
- package/src/cli/commands/api/annotate/prompts.ts +183 -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 +480 -0
- package/src/cli/commands/bootstrap.ts +710 -0
- package/src/cli/commands/catalog.ts +35 -0
- package/src/cli/commands/check.ts +348 -0
- package/src/cli/commands/checks.ts +756 -0
- package/src/cli/commands/ci-init.ts +43 -0
- package/src/cli/commands/clean.ts +212 -0
- package/src/cli/commands/cleanup.ts +262 -0
- package/src/cli/commands/completions.ts +16 -0
- package/src/cli/commands/coverage.ts +605 -132
- package/src/cli/commands/db.ts +178 -7
- package/src/cli/commands/describe.ts +37 -2
- package/src/cli/commands/discover.ts +1236 -0
- package/src/cli/commands/doctor.ts +607 -0
- package/src/cli/commands/fixtures.ts +402 -0
- package/src/cli/commands/generate.ts +420 -46
- package/src/cli/commands/init/bootstrap.ts +30 -1
- package/src/cli/commands/{init.ts → init/index.ts} +99 -5
- package/src/cli/commands/init/skills.ts +56 -3
- package/src/cli/commands/init/templates/agents.md +65 -61
- package/src/cli/commands/init/templates/skills/zond-checks.md +397 -0
- package/src/cli/commands/init/templates/skills/zond-triage.md +210 -0
- package/src/cli/commands/init/templates/skills/zond.md +592 -125
- package/src/cli/commands/init/templates/zond-config.yml +8 -9
- package/src/cli/commands/prepare-fixtures.ts +135 -0
- package/src/cli/commands/probe/mass-assignment.ts +503 -0
- package/src/cli/commands/probe/security.ts +454 -0
- package/src/cli/commands/probe/static.ts +255 -0
- package/src/cli/commands/probe/webhooks.ts +161 -0
- package/src/cli/commands/probe.ts +459 -0
- package/src/cli/commands/reference.ts +87 -0
- package/src/cli/commands/refresh-api.ts +169 -0
- package/src/cli/commands/remove-api.ts +150 -0
- package/src/cli/commands/report-bundle.ts +318 -0
- package/src/cli/commands/report.ts +241 -0
- package/src/cli/commands/request.ts +379 -4
- package/src/cli/commands/run.ts +842 -53
- 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 +112 -3
- package/src/cli/json-schemas.ts +263 -0
- package/src/cli/program.ts +198 -635
- package/src/cli/resolve.ts +105 -0
- package/src/cli/status-filter.ts +124 -0
- package/src/cli/util/api-context.ts +85 -0
- package/src/cli/version.ts +5 -0
- package/src/core/anti-fp/bootstrap.ts +34 -0
- package/src/core/anti-fp/index.ts +33 -0
- package/src/core/anti-fp/registry.ts +44 -0
- package/src/core/anti-fp/rules/baseline-echo.ts +74 -0
- package/src/core/anti-fp/rules/schemathesis/body_negation_becomes_valid.ts +52 -0
- package/src/core/anti-fp/rules/schemathesis/coverage_phase_boundary_positive.ts +38 -0
- package/src/core/anti-fp/rules/schemathesis/has_unverifiable_mutations.ts +35 -0
- package/src/core/anti-fp/rules/schemathesis/index.ts +24 -0
- package/src/core/anti-fp/rules/schemathesis/string_type_mutation_becomes_valid.ts +53 -0
- package/src/core/anti-fp/rules/subscription-gated/index.ts +11 -0
- package/src/core/anti-fp/rules/subscription-gated/paid-plan-403.ts +75 -0
- package/src/core/anti-fp/types.ts +68 -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 +134 -0
- package/src/core/checks/checks/ensure_resource_availability.ts +62 -0
- package/src/core/checks/checks/idempotency_replay.ts +246 -0
- package/src/core/checks/checks/ignored_auth.ts +211 -0
- package/src/core/checks/checks/index.ts +65 -0
- package/src/core/checks/checks/lifecycle_transitions.ts +273 -0
- package/src/core/checks/checks/missing_required_header.ts +40 -0
- package/src/core/checks/checks/negative_data_rejection.ts +45 -0
- package/src/core/checks/checks/not_a_server_error.ts +27 -0
- package/src/core/checks/checks/open_cors_on_sensitive.ts +131 -0
- package/src/core/checks/checks/pagination_invariants.ts +238 -0
- package/src/core/checks/checks/positive_data_acceptance.ts +36 -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 +61 -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 +79 -0
- package/src/core/checks/recommended-action.ts +64 -0
- package/src/core/checks/registry.ts +78 -0
- package/src/core/checks/runner.ts +874 -0
- package/src/core/checks/sarif.ts +230 -0
- package/src/core/checks/stateful.ts +121 -0
- package/src/core/checks/types.ts +189 -0
- package/src/core/classifier/recommended-action.ts +222 -0
- package/src/core/context/current.ts +22 -6
- package/src/core/context/session.ts +78 -0
- package/src/core/coverage/loader.ts +185 -0
- package/src/core/coverage/reasons.ts +300 -0
- package/src/core/diagnostics/db-analysis.ts +151 -11
- package/src/core/diagnostics/failure-class.ts +120 -0
- package/src/core/diagnostics/failure-hints.ts +212 -9
- package/src/core/diagnostics/spec-pointer.ts +99 -0
- package/src/core/diagnostics/suggested-fixes.ts +156 -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 +42 -16
- package/src/core/generator/coverage-phase.ts +0 -0
- package/src/core/generator/create-body.ts +89 -0
- package/src/core/generator/data-factory.ts +445 -19
- package/src/core/generator/describe.ts +1 -1
- package/src/core/generator/fixtures-builder.ts +325 -0
- package/src/core/generator/index.ts +7 -5
- package/src/core/generator/openapi-reader.ts +37 -3
- package/src/core/generator/path-param-disambig.ts +114 -0
- package/src/core/generator/resources-builder.ts +648 -0
- package/src/core/generator/schema-utils.ts +11 -3
- package/src/core/generator/serializer.ts +103 -13
- package/src/core/generator/suite-generator.ts +419 -111
- package/src/core/generator/types.ts +8 -0
- package/src/core/identity/identity-file.ts +129 -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 +91 -0
- package/src/core/output/index.ts +13 -0
- package/src/core/output/run.ts +126 -0
- package/src/core/output/types.ts +129 -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 +57 -0
- package/src/core/probe/mass-assignment-probe-class.ts +198 -0
- package/src/core/probe/mass-assignment-probe.ts +1122 -0
- package/src/core/probe/mass-assignment-template.ts +212 -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 +440 -0
- package/src/core/probe/probe-harness.ts +120 -0
- package/src/core/probe/registry.ts +89 -0
- package/src/core/probe/runner.ts +136 -0
- package/src/core/probe/security-probe-class.ts +201 -0
- package/src/core/probe/security-probe.ts +1453 -0
- package/src/core/probe/shared.ts +505 -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 +284 -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 +58 -1
- 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 +264 -20
- package/src/core/runner/form-encode.ts +51 -0
- package/src/core/runner/http-client.ts +75 -2
- package/src/core/runner/learn-drift.ts +293 -0
- package/src/core/runner/preflight-vars.ts +149 -0
- package/src/core/runner/progress-tracker.ts +73 -0
- package/src/core/runner/rate-limiter.ts +89 -17
- package/src/core/runner/run-kind.ts +39 -0
- package/src/core/runner/schema-validator.ts +312 -0
- package/src/core/runner/send-request.ts +153 -20
- package/src/core/runner/types.ts +38 -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 +415 -16
- package/src/core/severity/category.ts +94 -0
- package/src/core/severity/index.ts +121 -0
- package/src/core/spec/layers.ts +154 -0
- package/src/core/util/format-eta.ts +21 -0
- package/src/core/utils.ts +5 -1
- package/src/core/workspace/config.ts +129 -0
- package/src/core/workspace/manifest.ts +283 -0
- package/src/core/workspace/output-rotation.ts +62 -0
- package/src/core/workspace/triage-path.ts +87 -0
- package/src/db/lint-runs.ts +47 -0
- package/src/db/migrate.ts +126 -0
- package/src/db/migrations/0001_run_kind.sql +25 -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 +128 -0
- package/src/db/queries/runs.ts +235 -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 +72 -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
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coverage reasons engine — pure function: spec endpoints + run results +
|
|
3
|
+
* workspace context → matrix cells with explicit reason codes.
|
|
4
|
+
*
|
|
5
|
+
* The matrix has rows per endpoint (METHOD path) and three status-class
|
|
6
|
+
* columns (`2xx`, `4xx`, `5xx`). Each cell carries:
|
|
7
|
+
* - status: covered | partial | uncovered
|
|
8
|
+
* - reasons: zero or more codes that explain the cell state
|
|
9
|
+
* - results: stored step results that contributed to this cell
|
|
10
|
+
*
|
|
11
|
+
* "Default" branch is intentionally omitted — extractEndpoints already
|
|
12
|
+
* skips OpenAPI's "default" status code, so making a column for it would
|
|
13
|
+
* be permanently empty and noisy.
|
|
14
|
+
*
|
|
15
|
+
* The function is intentionally I/O-free: callers (server, exporter, CLI)
|
|
16
|
+
* load endpoints/results/fixtures separately and feed them in. This keeps
|
|
17
|
+
* the engine trivial to unit-test.
|
|
18
|
+
*/
|
|
19
|
+
import type { EndpointInfo } from "../generator/types.ts";
|
|
20
|
+
import type { StoredStepResult } from "../../db/queries.ts";
|
|
21
|
+
|
|
22
|
+
export type StatusClass = "2xx" | "4xx" | "5xx";
|
|
23
|
+
const STATUS_CLASSES: StatusClass[] = ["2xx", "4xx", "5xx"];
|
|
24
|
+
|
|
25
|
+
export type ReasonCode =
|
|
26
|
+
| "covered"
|
|
27
|
+
| "partial-failed"
|
|
28
|
+
| "not-generated"
|
|
29
|
+
| "no-spec"
|
|
30
|
+
| "deprecated"
|
|
31
|
+
| "no-fixtures"
|
|
32
|
+
| "ephemeral-only"
|
|
33
|
+
| "auth-scope-mismatch"
|
|
34
|
+
| "tag-filtered";
|
|
35
|
+
|
|
36
|
+
export interface CellResultRef {
|
|
37
|
+
resultId: number;
|
|
38
|
+
runId: number;
|
|
39
|
+
status: string;
|
|
40
|
+
responseStatus: number | null;
|
|
41
|
+
failureClass: string | null;
|
|
42
|
+
testName: string;
|
|
43
|
+
suiteFile: string | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface MatrixCell {
|
|
47
|
+
status: "covered" | "partial" | "uncovered";
|
|
48
|
+
reasons: ReasonCode[];
|
|
49
|
+
results: CellResultRef[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface MatrixRow {
|
|
53
|
+
endpoint: string;
|
|
54
|
+
method: string;
|
|
55
|
+
path: string;
|
|
56
|
+
tags: string[];
|
|
57
|
+
deprecated: boolean;
|
|
58
|
+
security: string[];
|
|
59
|
+
declaredStatuses: number[];
|
|
60
|
+
cells: Record<StatusClass, MatrixCell>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface BuildMatrixInput {
|
|
64
|
+
endpoints: EndpointInfo[];
|
|
65
|
+
results: StoredStepResult[];
|
|
66
|
+
fixturesAffected: Map<string, { name: string; required: boolean; source: string }[]>;
|
|
67
|
+
envVars: Set<string>;
|
|
68
|
+
ephemeralEndpoints: Set<string>;
|
|
69
|
+
tagFilter: string[];
|
|
70
|
+
profile: "safe" | "full";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface MatrixTotals {
|
|
74
|
+
endpoints: number;
|
|
75
|
+
cells: number;
|
|
76
|
+
covered: number;
|
|
77
|
+
partial: number;
|
|
78
|
+
uncovered: number;
|
|
79
|
+
byReason: Record<ReasonCode, number>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface CoverageMatrix {
|
|
83
|
+
rows: MatrixRow[];
|
|
84
|
+
totals: MatrixTotals;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function endpointKey(method: string, path: string): string {
|
|
88
|
+
return `${method.toUpperCase()} ${path}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function classifyStatus(code: number): StatusClass | null {
|
|
92
|
+
if (code >= 200 && code < 300) return "2xx";
|
|
93
|
+
if (code >= 400 && code < 500) return "4xx";
|
|
94
|
+
if (code >= 500 && code < 600) return "5xx";
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Build a regex that matches a concrete URL path against an OpenAPI path
|
|
100
|
+
* template. `/pets/{id}` becomes `^/pets/[^/]+$`.
|
|
101
|
+
*/
|
|
102
|
+
function specPathToRegex(specPath: string): RegExp {
|
|
103
|
+
const pattern = specPath.replace(/\{[^}]+\}/g, "[^/]+");
|
|
104
|
+
return new RegExp(`^${pattern}$`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function extractPathname(url: string | null): string | null {
|
|
108
|
+
if (!url) return null;
|
|
109
|
+
try {
|
|
110
|
+
const u = new URL(url);
|
|
111
|
+
return u.pathname;
|
|
112
|
+
} catch {
|
|
113
|
+
if (url.startsWith("/")) {
|
|
114
|
+
const q = url.indexOf("?");
|
|
115
|
+
return q === -1 ? url : url.slice(0, q);
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function matchResultToEndpoint(
|
|
122
|
+
result: StoredStepResult,
|
|
123
|
+
endpointsByKey: Map<string, EndpointInfo>,
|
|
124
|
+
endpointsByMethod: Map<string, { ep: EndpointInfo; rx: RegExp }[]>,
|
|
125
|
+
): EndpointInfo | null {
|
|
126
|
+
const provEp = (result.provenance as Record<string, unknown> | null)?.endpoint;
|
|
127
|
+
if (typeof provEp === "string") {
|
|
128
|
+
const sp = provEp.indexOf(" ");
|
|
129
|
+
const normalised = sp === -1
|
|
130
|
+
? provEp
|
|
131
|
+
: `${provEp.slice(0, sp).toUpperCase()}${provEp.slice(sp)}`;
|
|
132
|
+
const direct = endpointsByKey.get(normalised);
|
|
133
|
+
if (direct) return direct;
|
|
134
|
+
}
|
|
135
|
+
const method = result.request_method?.toUpperCase();
|
|
136
|
+
if (!method) return null;
|
|
137
|
+
const candidates = endpointsByMethod.get(method);
|
|
138
|
+
if (!candidates) return null;
|
|
139
|
+
const pathname = extractPathname(result.request_url);
|
|
140
|
+
if (!pathname) return null;
|
|
141
|
+
for (const c of candidates) if (c.rx.test(pathname)) return c.ep;
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function pathParamNames(path: string): string[] {
|
|
146
|
+
return [...path.matchAll(/\{([^}]+)\}/g)].map((m) => m[1]!);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function hasMissingPathFixtures(
|
|
150
|
+
ep: EndpointInfo,
|
|
151
|
+
fixturesAffected: Map<string, { name: string; required: boolean; source: string }[]>,
|
|
152
|
+
envVars: Set<string>,
|
|
153
|
+
): boolean {
|
|
154
|
+
const params = pathParamNames(ep.path);
|
|
155
|
+
if (params.length === 0) return false;
|
|
156
|
+
const label = endpointKey(ep.method, ep.path);
|
|
157
|
+
const declared = fixturesAffected.get(label) ?? [];
|
|
158
|
+
// Manifest entry per param? Required + missing = no-fixtures.
|
|
159
|
+
const required = declared.filter((f) => f.source === "path" && f.required);
|
|
160
|
+
for (const f of required) if (!envVars.has(f.name)) return true;
|
|
161
|
+
// Manifest may not enumerate every param when it was generated before this
|
|
162
|
+
// endpoint existed, so also require: every {param} must map to an env var.
|
|
163
|
+
for (const p of params) {
|
|
164
|
+
if (envVars.has(p)) continue;
|
|
165
|
+
const declaredForP = declared.find((f) => f.name === p);
|
|
166
|
+
if (declaredForP && envVars.has(declaredForP.name)) continue;
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function hasMissingAuthFixtures(ep: EndpointInfo, envVars: Set<string>): boolean {
|
|
173
|
+
if (ep.security.length === 0) return false;
|
|
174
|
+
// Convention: a security scheme name maps to one of `<name>`, `<name>_token`,
|
|
175
|
+
// or the lowercased `<name>_token` env var. The endpoint is satisfied if
|
|
176
|
+
// *any* of its required schemes has a configured token.
|
|
177
|
+
for (const scheme of ep.security) {
|
|
178
|
+
const variants = [scheme, `${scheme}_token`, `${scheme.toLowerCase()}_token`, "auth_token"];
|
|
179
|
+
if (variants.some((v) => envVars.has(v))) return false;
|
|
180
|
+
}
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function declaredStatusClasses(ep: EndpointInfo): Set<StatusClass> {
|
|
185
|
+
const out = new Set<StatusClass>();
|
|
186
|
+
for (const r of ep.responses) {
|
|
187
|
+
const cls = classifyStatus(r.statusCode);
|
|
188
|
+
if (cls) out.add(cls);
|
|
189
|
+
}
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function buildCoverageMatrix(input: BuildMatrixInput): CoverageMatrix {
|
|
194
|
+
const endpointsByKey = new Map<string, EndpointInfo>();
|
|
195
|
+
const endpointsByMethod = new Map<string, { ep: EndpointInfo; rx: RegExp }[]>();
|
|
196
|
+
for (const ep of input.endpoints) {
|
|
197
|
+
endpointsByKey.set(endpointKey(ep.method, ep.path), ep);
|
|
198
|
+
const method = ep.method.toUpperCase();
|
|
199
|
+
const list = endpointsByMethod.get(method) ?? [];
|
|
200
|
+
list.push({ ep, rx: specPathToRegex(ep.path) });
|
|
201
|
+
endpointsByMethod.set(method, list);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Bucket results: key = "METHOD path" → statusClass → list of refs.
|
|
205
|
+
const buckets = new Map<string, Record<StatusClass, CellResultRef[]>>();
|
|
206
|
+
for (const r of input.results) {
|
|
207
|
+
const ep = matchResultToEndpoint(r, endpointsByKey, endpointsByMethod);
|
|
208
|
+
if (!ep) continue;
|
|
209
|
+
const key = endpointKey(ep.method, ep.path);
|
|
210
|
+
let bucket = buckets.get(key);
|
|
211
|
+
if (!bucket) {
|
|
212
|
+
bucket = { "2xx": [], "4xx": [], "5xx": [] };
|
|
213
|
+
buckets.set(key, bucket);
|
|
214
|
+
}
|
|
215
|
+
if (r.response_status == null) continue;
|
|
216
|
+
const cls = classifyStatus(r.response_status);
|
|
217
|
+
if (!cls) continue;
|
|
218
|
+
bucket[cls].push({
|
|
219
|
+
resultId: r.id,
|
|
220
|
+
runId: r.run_id,
|
|
221
|
+
status: r.status,
|
|
222
|
+
responseStatus: r.response_status,
|
|
223
|
+
failureClass: r.failure_class,
|
|
224
|
+
testName: r.test_name,
|
|
225
|
+
suiteFile: r.suite_file,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const totals: MatrixTotals = {
|
|
230
|
+
endpoints: input.endpoints.length,
|
|
231
|
+
cells: 0,
|
|
232
|
+
covered: 0,
|
|
233
|
+
partial: 0,
|
|
234
|
+
uncovered: 0,
|
|
235
|
+
byReason: {
|
|
236
|
+
"covered": 0, "partial-failed": 0, "not-generated": 0, "no-spec": 0,
|
|
237
|
+
"deprecated": 0, "no-fixtures": 0, "ephemeral-only": 0,
|
|
238
|
+
"auth-scope-mismatch": 0, "tag-filtered": 0,
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const filterTags = new Set(input.tagFilter);
|
|
243
|
+
const rows: MatrixRow[] = input.endpoints.map((ep) => {
|
|
244
|
+
const key = endpointKey(ep.method, ep.path);
|
|
245
|
+
const bucket = buckets.get(key) ?? { "2xx": [], "4xx": [], "5xx": [] };
|
|
246
|
+
const declared = declaredStatusClasses(ep);
|
|
247
|
+
const tagFiltered = filterTags.size > 0 && !ep.tags.some((t) => filterTags.has(t));
|
|
248
|
+
const cells: Record<StatusClass, MatrixCell> = { "2xx": null!, "4xx": null!, "5xx": null!};
|
|
249
|
+
|
|
250
|
+
for (const cls of STATUS_CLASSES) {
|
|
251
|
+
const refs = bucket[cls];
|
|
252
|
+
const passing = refs.some((r) => r.status === "pass");
|
|
253
|
+
const failing = refs.some((r) => r.status !== "pass");
|
|
254
|
+
const reasons: ReasonCode[] = [];
|
|
255
|
+
let cellStatus: MatrixCell["status"];
|
|
256
|
+
if (passing && !failing) {
|
|
257
|
+
cellStatus = "covered";
|
|
258
|
+
reasons.push("covered");
|
|
259
|
+
} else if (passing && failing) {
|
|
260
|
+
cellStatus = "covered";
|
|
261
|
+
reasons.push("covered", "partial-failed");
|
|
262
|
+
} else if (failing) {
|
|
263
|
+
cellStatus = "partial";
|
|
264
|
+
reasons.push("partial-failed");
|
|
265
|
+
} else {
|
|
266
|
+
cellStatus = "uncovered";
|
|
267
|
+
if (!declared.has(cls)) reasons.push("no-spec");
|
|
268
|
+
if (ep.deprecated) reasons.push("deprecated");
|
|
269
|
+
if (input.ephemeralEndpoints.has(key) && input.profile === "safe") reasons.push("ephemeral-only");
|
|
270
|
+
if (tagFiltered) reasons.push("tag-filtered");
|
|
271
|
+
if (hasMissingPathFixtures(ep, input.fixturesAffected, input.envVars)) reasons.push("no-fixtures");
|
|
272
|
+
if (hasMissingAuthFixtures(ep, input.envVars)) reasons.push("auth-scope-mismatch");
|
|
273
|
+
if (reasons.length === 0) reasons.push("not-generated");
|
|
274
|
+
}
|
|
275
|
+
// Always tag deprecated even on covered cells — it's an awareness flag.
|
|
276
|
+
if (ep.deprecated && !reasons.includes("deprecated")) reasons.push("deprecated");
|
|
277
|
+
|
|
278
|
+
cells[cls] = { status: cellStatus, reasons, results: refs };
|
|
279
|
+
|
|
280
|
+
totals.cells += 1;
|
|
281
|
+
if (cellStatus === "covered") totals.covered += 1;
|
|
282
|
+
else if (cellStatus === "partial") totals.partial += 1;
|
|
283
|
+
else totals.uncovered += 1;
|
|
284
|
+
for (const code of reasons) totals.byReason[code] += 1;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
endpoint: key,
|
|
289
|
+
method: ep.method.toUpperCase(),
|
|
290
|
+
path: ep.path,
|
|
291
|
+
tags: ep.tags,
|
|
292
|
+
deprecated: !!ep.deprecated,
|
|
293
|
+
security: ep.security,
|
|
294
|
+
declaredStatuses: ep.responses.map((r) => r.statusCode).sort((a, b) => a - b),
|
|
295
|
+
cells,
|
|
296
|
+
};
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
return { rows, totals };
|
|
300
|
+
}
|
|
@@ -1,10 +1,11 @@
|
|
|
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 { statusHint, classifyFailure, envHint, envCategory, schemaHint, computeSharedEnvIssue,
|
|
5
|
-
import {
|
|
4
|
+
import { statusHint, classifyFailure, envHint, envCategory, schemaHint, computeSharedEnvIssue, clusterEnvIssues, buildEnvIssue, recommendedActionForGenerated, isGeneratedTest, softDeleteHint, type RecommendedAction, type EnvIssue } from "./failure-hints.ts";
|
|
5
|
+
import { buildSuggestedFixes, type SuggestedFix } from "./suggested-fixes.ts";
|
|
6
|
+
import { AUTH_PATH_RE } from "../runner/auth-path.ts";
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
function truncateErrorMessage(raw: string | null | undefined, verbose?: boolean): string | undefined {
|
|
8
9
|
if (!raw) return undefined;
|
|
9
10
|
if (verbose || raw.length < 500) return raw;
|
|
10
11
|
const lines = raw.split(/\r?\n/);
|
|
@@ -24,7 +25,7 @@ export function truncateErrorMessage(raw: string | null | undefined, verbose?: b
|
|
|
24
25
|
return msgLines.join("\n");
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
|
|
28
|
+
function parseBodySafe(raw: string | null | undefined): unknown {
|
|
28
29
|
if (!raw) return undefined;
|
|
29
30
|
const truncated = raw.length > 2000 ? raw.slice(0, 2000) + "\u2026[truncated]" : raw;
|
|
30
31
|
try {
|
|
@@ -40,7 +41,33 @@ const USEFUL_HEADERS = new Set([
|
|
|
40
41
|
]);
|
|
41
42
|
const USEFUL_PREFIXES = ["x-", "ratelimit"];
|
|
42
43
|
|
|
43
|
-
|
|
44
|
+
/** ARV-103 (F8): true when at least one assertion on the failing step is
|
|
45
|
+
* a schema-validation kind. `--validate-schema` annotates each violated
|
|
46
|
+
* field with `kind: "schema"` (set in src/core/runner/schema-validator.ts).
|
|
47
|
+
* The assertions column is stored as JSON in SQLite; parse defensively. */
|
|
48
|
+
function hasSchemaAssertion(raw: string | unknown[] | null | undefined): boolean {
|
|
49
|
+
if (raw === null || raw === undefined) return false;
|
|
50
|
+
let arr: unknown[];
|
|
51
|
+
if (Array.isArray(raw)) {
|
|
52
|
+
arr = raw;
|
|
53
|
+
} else if (typeof raw === "string") {
|
|
54
|
+
try {
|
|
55
|
+
const parsed = JSON.parse(raw);
|
|
56
|
+
if (!Array.isArray(parsed)) return false;
|
|
57
|
+
arr = parsed;
|
|
58
|
+
} catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
} else {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
for (const a of arr) {
|
|
65
|
+
if (a && typeof a === "object" && (a as { kind?: unknown }).kind === "schema") return true;
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function filterHeaders(raw: string | null | undefined): Record<string, string> | undefined {
|
|
44
71
|
if (!raw) return undefined;
|
|
45
72
|
try {
|
|
46
73
|
const h = JSON.parse(raw) as Record<string, string>;
|
|
@@ -145,9 +172,13 @@ export interface DiagnoseResult {
|
|
|
145
172
|
network_errors: number;
|
|
146
173
|
};
|
|
147
174
|
agent_directive?: string;
|
|
148
|
-
env_issue?:
|
|
175
|
+
env_issue?: EnvIssue;
|
|
149
176
|
auth_hint?: string;
|
|
150
177
|
cascade_skips?: CascadeSkipGroup[];
|
|
178
|
+
/** TASK-29: actionable suggestions populated from 404 placeholder
|
|
179
|
+
* detection + .env.yaml unfilled-key audit. Empty / undefined when
|
|
180
|
+
* nothing actionable was found. */
|
|
181
|
+
suggested_fixes?: SuggestedFix[];
|
|
151
182
|
failures: Array<{
|
|
152
183
|
suite_name: string;
|
|
153
184
|
test_name: string;
|
|
@@ -165,8 +196,23 @@ export interface DiagnoseResult {
|
|
|
165
196
|
response_headers?: Record<string, string>;
|
|
166
197
|
assertions: unknown;
|
|
167
198
|
duration_ms: number | null;
|
|
199
|
+
/** ARV-159: when this entry is the representative of a collapsed group
|
|
200
|
+
* (status|failure_type signature), the total size of that group. Lets
|
|
201
|
+
* consumers reading `.data.failures[]` see "this signature stands for
|
|
202
|
+
* N underlying tests" without cross-referencing `.grouped_failures[]`.
|
|
203
|
+
* Omitted when no collapsing occurred (failures ≤ 5 or
|
|
204
|
+
* --verbose). */
|
|
205
|
+
group_count?: number;
|
|
168
206
|
}>;
|
|
169
207
|
grouped_failures?: FailureGroup[];
|
|
208
|
+
/** ARV-101 (F6): top-level aggregation keyed by `recommended_action`
|
|
209
|
+
* enum so triage agents (zond-triage skill) can route on the canonical
|
|
210
|
+
* action without re-folding `failures[].recommended_action` through
|
|
211
|
+
* `jq | group_by`. Built from the *full* failure set (not the compact
|
|
212
|
+
* subset), so counts match `.summary.failed`. Each bucket carries
|
|
213
|
+
* total count + a small examples list (`<suite>/<test>`). Empty when
|
|
214
|
+
* there are no failures. */
|
|
215
|
+
by_recommended_action?: Record<string, { count: number; examples: string[] }>;
|
|
170
216
|
}
|
|
171
217
|
|
|
172
218
|
export function diagnoseRun(runId: number, verbose?: boolean, dbPath?: string, maxExamples?: number): DiagnoseResult {
|
|
@@ -191,7 +237,16 @@ export function diagnoseRun(runId: number, verbose?: boolean, dbPath?: string, m
|
|
|
191
237
|
softDeleteHint(r.response_status, r.request_method, parsedBody) ??
|
|
192
238
|
statusHint(r.response_status);
|
|
193
239
|
const failure_type = classifyFailure(r.status, r.response_status);
|
|
194
|
-
|
|
240
|
+
// ARV-42: generator-emitted suites should not route to fix_test_logic —
|
|
241
|
+
// editing the YAML gets clobbered on the next `zond audit`.
|
|
242
|
+
const generated = isGeneratedTest(r.provenance, r.suite_file);
|
|
243
|
+
// ARV-103 (F8): walk the assertions array to detect a schema-kind
|
|
244
|
+
// failure (--validate-schema annotates each assertion with its kind).
|
|
245
|
+
// When present, propagate the flag so the classifier routes to
|
|
246
|
+
// report_backend_bug — schema violations are real contract bugs, not
|
|
247
|
+
// test-logic mistakes.
|
|
248
|
+
const schema_violation = hasSchemaAssertion(r.assertions);
|
|
249
|
+
const rec_action = recommendedActionForGenerated(failure_type, r.response_status, generated, schema_violation);
|
|
195
250
|
const sHint = schemaHint(failure_type, r.response_status);
|
|
196
251
|
return {
|
|
197
252
|
suite_name: r.suite_name,
|
|
@@ -213,7 +268,56 @@ export function diagnoseRun(runId: number, verbose?: boolean, dbPath?: string, m
|
|
|
213
268
|
};
|
|
214
269
|
});
|
|
215
270
|
|
|
216
|
-
|
|
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
|
+
}
|
|
217
321
|
|
|
218
322
|
let apiErrors = 0, assertionFailures = 0, networkErrors = 0;
|
|
219
323
|
let authFailureCount = 0;
|
|
@@ -274,6 +378,37 @@ export function diagnoseRun(runId: number, verbose?: boolean, dbPath?: string, m
|
|
|
274
378
|
? { grouped_failures: undefined, compactFailures: failures }
|
|
275
379
|
: groupFailures(failures, maxExamples);
|
|
276
380
|
|
|
381
|
+
// TASK-29: surface placeholder path-params + unfilled .env.yaml keys.
|
|
382
|
+
const suggestedFixes = buildSuggestedFixes({
|
|
383
|
+
failures: failures.map(f => ({
|
|
384
|
+
response_status: f.response_status,
|
|
385
|
+
request_url: f.request_url,
|
|
386
|
+
suite_name: f.suite_name,
|
|
387
|
+
test_name: f.test_name,
|
|
388
|
+
})),
|
|
389
|
+
envFilePath,
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
// ARV-101 (F6): aggregate failures by recommended_action enum so triage
|
|
393
|
+
// agents read .data.by_recommended_action.fix_env.count instead of
|
|
394
|
+
// re-folding failures[].recommended_action through `jq | group_by`. Built
|
|
395
|
+
// from the full failure set (not compactFailures) so counts match
|
|
396
|
+
// .summary.failed. Bounded examples list (5) keeps payload small while
|
|
397
|
+
// still pointing at concrete suites the agent can open.
|
|
398
|
+
const by_recommended_action: Record<string, { count: number; examples: string[] }> = {};
|
|
399
|
+
for (const f of failures) {
|
|
400
|
+
const key = f.recommended_action;
|
|
401
|
+
let bucket = by_recommended_action[key];
|
|
402
|
+
if (!bucket) {
|
|
403
|
+
bucket = { count: 0, examples: [] };
|
|
404
|
+
by_recommended_action[key] = bucket;
|
|
405
|
+
}
|
|
406
|
+
bucket.count += 1;
|
|
407
|
+
if (bucket.examples.length < 5) {
|
|
408
|
+
bucket.examples.push(`${f.suite_name}/${f.test_name}`);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
277
412
|
return {
|
|
278
413
|
run: {
|
|
279
414
|
id: diagRun.id,
|
|
@@ -290,15 +425,17 @@ export function diagnoseRun(runId: number, verbose?: boolean, dbPath?: string, m
|
|
|
290
425
|
network_errors: networkErrors,
|
|
291
426
|
},
|
|
292
427
|
...(agent_directive ? { agent_directive } : {}),
|
|
293
|
-
...(
|
|
428
|
+
...(env_issue ? { env_issue } : {}),
|
|
294
429
|
...(auth_hint ? { auth_hint } : {}),
|
|
295
430
|
...(cascade_skips ? { cascade_skips } : {}),
|
|
431
|
+
...(suggestedFixes.length > 0 ? { suggested_fixes: suggestedFixes } : {}),
|
|
296
432
|
failures: compactFailures,
|
|
297
433
|
...(grouped_failures ? { grouped_failures } : {}),
|
|
434
|
+
...(failures.length > 0 ? { by_recommended_action } : {}),
|
|
298
435
|
};
|
|
299
436
|
}
|
|
300
437
|
|
|
301
|
-
type FailureItem = { suite_name: string; test_name: string; failure_type: string; recommended_action: RecommendedAction; hint?: string; response_status: number | null };
|
|
438
|
+
type FailureItem = { suite_name: string; test_name: string; failure_type: string; recommended_action: RecommendedAction; hint?: string; response_status: number | null; group_count?: number };
|
|
302
439
|
|
|
303
440
|
/** Group similar failures for compact output. Exported for testing. */
|
|
304
441
|
export function groupFailures<T extends FailureItem>(failures: T[], maxExamples = 2): { grouped_failures?: FailureGroup[]; compactFailures: T[] } {
|
|
@@ -352,7 +489,10 @@ export function groupFailures<T extends FailureItem>(failures: T[], maxExamples
|
|
|
352
489
|
if (isApiError) {
|
|
353
490
|
compactFailures.push(...group.items);
|
|
354
491
|
} else {
|
|
355
|
-
|
|
492
|
+
// ARV-159: tag the representative with the group size so
|
|
493
|
+
// `.data.failures[]` carries the multiplier inline.
|
|
494
|
+
const rep = { ...group.items[0]!, group_count: group.items.length };
|
|
495
|
+
compactFailures.push(rep as T);
|
|
356
496
|
}
|
|
357
497
|
}
|
|
358
498
|
|
|
@@ -0,0 +1,120 @@
|
|
|
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. Negative-probe family — distinguish "API accepted bad input" (likely_bug)
|
|
95
|
+
// from "API rejected with a different 4xx" (quirk).
|
|
96
|
+
if (generator === "negative-probe" || generator === "method-probe") {
|
|
97
|
+
const statusFail = failed.find((a) => a.field === "status");
|
|
98
|
+
if (statusFail && typeof respStatus === "number") {
|
|
99
|
+
const expected = expectedStatusList(statusFail);
|
|
100
|
+
const allExpected4xx = expected?.every((s) => s >= 400 && s < 500) ?? false;
|
|
101
|
+
if (allExpected4xx) {
|
|
102
|
+
if (respStatus >= 200 && respStatus < 300) {
|
|
103
|
+
return {
|
|
104
|
+
failure_class: "likely_bug",
|
|
105
|
+
failure_class_reason: `Negative probe expected 4xx, got ${respStatus} — API accepts invalid input`,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
if (respStatus >= 400 && respStatus < 500) {
|
|
109
|
+
return {
|
|
110
|
+
failure_class: "quirk",
|
|
111
|
+
failure_class_reason: `Negative probe expected ${expected!.join("/")}, got ${respStatus} — different 4xx code`,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Default: leave unclassified. UI / CLI render as "unclassified".
|
|
119
|
+
return null;
|
|
120
|
+
}
|