@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
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified severity matrix (ARV-250, m-21 pivot).
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for severity classification across all
|
|
5
|
+
* finding-producing subsystems (lint, checks, probes). Replaces three
|
|
6
|
+
* divergent ladders (lint 3-tier, checks 4-tier, probes 4-tier).
|
|
7
|
+
*
|
|
8
|
+
* Principle: **no evidence — no high severity**. Severity reflects
|
|
9
|
+
* proven impact, not anomaly presence. CRITICAL exists in the type
|
|
10
|
+
* but is reserved for end-to-end exploit chains; producers without
|
|
11
|
+
* such chains must NOT emit it.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type Severity = "critical" | "high" | "medium" | "low" | "info";
|
|
15
|
+
|
|
16
|
+
const SEVERITY_RANK: Record<Severity, number> = {
|
|
17
|
+
critical: 0,
|
|
18
|
+
high: 1,
|
|
19
|
+
medium: 2,
|
|
20
|
+
low: 3,
|
|
21
|
+
info: 4,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function rankSeverity(s: Severity): number {
|
|
25
|
+
return SEVERITY_RANK[s];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Empty severity-bucket map. Use as starting tally; downstream code
|
|
30
|
+
* increments per finding.
|
|
31
|
+
*/
|
|
32
|
+
export function emptySeverityBuckets(): Record<Severity, number> {
|
|
33
|
+
return { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* SARIF level mapping (sarif.ts previously hardcoded 4-tier; this
|
|
38
|
+
* keeps the same external semantics + adds 'info' → 'note').
|
|
39
|
+
*/
|
|
40
|
+
export function severityToSarifLevel(s: Severity): "error" | "warning" | "note" {
|
|
41
|
+
if (s === "critical" || s === "high") return "error";
|
|
42
|
+
if (s === "medium") return "warning";
|
|
43
|
+
return "note"; // low + info
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Console glyph for severity. Stable per-glyph keeps fb-loop diff
|
|
48
|
+
* compares clean.
|
|
49
|
+
*/
|
|
50
|
+
export function severityGlyph(s: Severity): string {
|
|
51
|
+
switch (s) {
|
|
52
|
+
case "critical": return "🚨";
|
|
53
|
+
case "high": return "🔴";
|
|
54
|
+
case "medium": return "⚠️";
|
|
55
|
+
case "low": return "ℹ️";
|
|
56
|
+
case "info": return "·";
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ARV-175: infer a JSON Schema (draft-07 subset) from a set of sample
|
|
3
|
+
* response bodies. Built-in, zero-dependency — quicktype/genson would each
|
|
4
|
+
* drag in a large dependency tree, which contradicts zond's dumb-tool /
|
|
5
|
+
* minimal-deps charter (see src/CLAUDE.md). The `--engine` flag keeps the
|
|
6
|
+
* seam open if a heavier engine is ever wanted, but `builtin` is the default
|
|
7
|
+
* and the only one wired.
|
|
8
|
+
*
|
|
9
|
+
* Strategy — structural union over the samples:
|
|
10
|
+
* - primitives → { type }
|
|
11
|
+
* - arrays → { type: "array", items: <merge of every element> }
|
|
12
|
+
* - objects → { type: "object", properties, required }
|
|
13
|
+
* `required` = keys present in EVERY object sample (an
|
|
14
|
+
* intersection — a field missing from one sample is optional).
|
|
15
|
+
* - mixed types across samples → { type: [sorted, unique] } (or a bare
|
|
16
|
+
* type when they all agree). null folds into the type list so a
|
|
17
|
+
* sometimes-null field reads as `["null","string"]`.
|
|
18
|
+
*
|
|
19
|
+
* Not a full inference engine: no format detection, no enum mining, no
|
|
20
|
+
* anyOf for heterogeneous array items beyond a type union. Good enough to
|
|
21
|
+
* seed `response_schema_conformance` on specs that declare no response
|
|
22
|
+
* schema, which is the whole point (ARV-175 goal).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export type JsonSchema = Record<string, unknown>;
|
|
26
|
+
|
|
27
|
+
type JsonType = "null" | "boolean" | "integer" | "number" | "string" | "array" | "object";
|
|
28
|
+
|
|
29
|
+
function typeOf(v: unknown): JsonType {
|
|
30
|
+
if (v === null) return "null";
|
|
31
|
+
if (Array.isArray(v)) return "array";
|
|
32
|
+
const t = typeof v;
|
|
33
|
+
if (t === "boolean") return "boolean";
|
|
34
|
+
if (t === "number") return Number.isInteger(v) ? "integer" : "number";
|
|
35
|
+
if (t === "string") return "string";
|
|
36
|
+
return "object";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Infer a schema from one or more samples of the same logical value. */
|
|
40
|
+
export function inferSchema(samples: unknown[]): JsonSchema {
|
|
41
|
+
const nonEmpty = samples.filter((s) => s !== undefined);
|
|
42
|
+
if (nonEmpty.length === 0) return {};
|
|
43
|
+
|
|
44
|
+
const types = new Set<JsonType>();
|
|
45
|
+
for (const s of nonEmpty) types.add(typeOf(s));
|
|
46
|
+
|
|
47
|
+
// Object: merge properties across every object sample.
|
|
48
|
+
if (types.has("object")) {
|
|
49
|
+
const objSamples = nonEmpty.filter((s) => typeOf(s) === "object") as Array<Record<string, unknown>>;
|
|
50
|
+
const propSamples = new Map<string, unknown[]>();
|
|
51
|
+
for (const obj of objSamples) {
|
|
52
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
53
|
+
if (!propSamples.has(k)) propSamples.set(k, []);
|
|
54
|
+
propSamples.get(k)!.push(v);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// required = keys present in ALL object samples (intersection).
|
|
58
|
+
const required = [...propSamples.keys()].filter(
|
|
59
|
+
(k) => objSamples.every((obj) => Object.prototype.hasOwnProperty.call(obj, k)),
|
|
60
|
+
);
|
|
61
|
+
const properties: JsonSchema = {};
|
|
62
|
+
for (const [k, vs] of propSamples) properties[k] = inferSchema(vs);
|
|
63
|
+
|
|
64
|
+
const schema: JsonSchema = { type: mergeType(types, "object") };
|
|
65
|
+
if (Object.keys(properties).length > 0) schema.properties = sortKeys(properties);
|
|
66
|
+
if (required.length > 0) schema.required = required.sort();
|
|
67
|
+
return schema;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Array: items schema is the union of every element across every sample.
|
|
71
|
+
if (types.has("array")) {
|
|
72
|
+
const elements: unknown[] = [];
|
|
73
|
+
for (const s of nonEmpty) if (Array.isArray(s)) elements.push(...s);
|
|
74
|
+
const schema: JsonSchema = { type: mergeType(types, "array") };
|
|
75
|
+
if (elements.length > 0) schema.items = inferSchema(elements);
|
|
76
|
+
return schema;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Primitives only.
|
|
80
|
+
return { type: mergeType(types) };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Collapse the observed type set into a single `type` value: a string when
|
|
84
|
+
* they agree (preferring `primary` if given, e.g. object/array), otherwise a
|
|
85
|
+
* sorted unique list. `integer`+`number` collapses to `number`. */
|
|
86
|
+
function mergeType(types: Set<JsonType>, primary?: JsonType): string | string[] {
|
|
87
|
+
const t = new Set(types);
|
|
88
|
+
if (t.has("number") && t.has("integer")) t.delete("integer");
|
|
89
|
+
if (primary && t.size > 1) {
|
|
90
|
+
// Object/array with a stray null etc. — keep the structural type plus null.
|
|
91
|
+
const rest = [...t].filter((x) => x !== primary);
|
|
92
|
+
if (rest.length === 1 && rest[0] === "null") return [primary, "null"].sort();
|
|
93
|
+
}
|
|
94
|
+
const arr = [...t].sort();
|
|
95
|
+
return arr.length === 1 ? arr[0]! : arr;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function sortKeys(obj: JsonSchema): JsonSchema {
|
|
99
|
+
const out: JsonSchema = {};
|
|
100
|
+
for (const k of Object.keys(obj).sort()) out[k] = obj[k];
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ARV-122 (m-19, blocker-m-18): layered spec model.
|
|
3
|
+
*
|
|
4
|
+
* Today two artifact sources contribute to the resource map under
|
|
5
|
+
* `apis/<name>/`:
|
|
6
|
+
* 1. `.api-resources.yaml` — sha-tracked, regenerated by
|
|
7
|
+
* `refresh-api` from `spec.json` (upstream OpenAPI).
|
|
8
|
+
* 2. `.api-resources.local.yaml` — ARV-111 user extension that
|
|
9
|
+
* survives `refresh-api` (write-only endpoints, common SaaS-style
|
|
10
|
+
* ingest, …).
|
|
11
|
+
*
|
|
12
|
+
* `readResourceMap` in `cli/commands/discover.ts` merges them by hand:
|
|
13
|
+
* extensions win on `resource` name collision. m-18 (quicktype-derived
|
|
14
|
+
* and mitmproxy-derived schema layers) will add two more sources with
|
|
15
|
+
* different precedence and merge semantics — doing that ad-hoc would
|
|
16
|
+
* end the way ad-hoc output-format parsing did (ARV-97 family,
|
|
17
|
+
* resolved by m-19/ARV-116).
|
|
18
|
+
*
|
|
19
|
+
* This module is the same move for spec sources: declare each source
|
|
20
|
+
* once as a `SpecLayer`, with precedence and merge policy, and feed
|
|
21
|
+
* them through `composeSpec()`. The result is a `ComposedSpec` plus a
|
|
22
|
+
* `ProvenanceMap` (`resource → layer.id`) that downstream code can
|
|
23
|
+
* consult to explain *where* a field came from (m-18 follow-ups will
|
|
24
|
+
* surface this in `doctor` / `catalog --provenance`; this task only
|
|
25
|
+
* ships the internal API).
|
|
26
|
+
*
|
|
27
|
+
* Scope of this task: ONLY the resource-map dimension (the actual
|
|
28
|
+
* `ResourceYaml` shape used by every other module). Other dimensions
|
|
29
|
+
* (raw OpenAPI document, fixture manifest) keep their own loaders —
|
|
30
|
+
* generalising further before there's a second consumer would be
|
|
31
|
+
* premature.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** How a layer's entries combine with already-resolved entries. */
|
|
35
|
+
export type MergePolicy =
|
|
36
|
+
/** Layer entries replace lower-precedence entries on key collision. */
|
|
37
|
+
| "override"
|
|
38
|
+
/** Layer entries are dropped if a lower-precedence entry already
|
|
39
|
+
* owns the key. (Useful for "default" layers that fill gaps but
|
|
40
|
+
* shouldn't shadow user input.) */
|
|
41
|
+
| "preserve"
|
|
42
|
+
/** Layer entries are appended even when a key already exists,
|
|
43
|
+
* producing duplicate keys. Reserved for future fixture layers;
|
|
44
|
+
* not used by the current two-layer resource composition. */
|
|
45
|
+
| "append";
|
|
46
|
+
|
|
47
|
+
/** Domain a layer contributes to. Today only `resources` is wired —
|
|
48
|
+
* m-18 will likely add `fixtures` (manifest merge) and `spec`
|
|
49
|
+
* (raw OpenAPI augmentation). Listed up front so layer factories
|
|
50
|
+
* carry the tag at construction. */
|
|
51
|
+
export type LayerScope = "resources" | "fixtures" | "spec";
|
|
52
|
+
|
|
53
|
+
/** A single typed source of spec data. */
|
|
54
|
+
export interface SpecLayer<T> {
|
|
55
|
+
/** Stable identifier surfaced in the provenance map (e.g.,
|
|
56
|
+
* `"upstream"`, `"extension"`, `"quicktype"`). Must be unique
|
|
57
|
+
* inside one `composeSpec` call. */
|
|
58
|
+
id: string;
|
|
59
|
+
/** Source path/URL for diagnostics. Optional — synthetic layers
|
|
60
|
+
* (in-memory test fixtures) can omit. */
|
|
61
|
+
path?: string;
|
|
62
|
+
/** Higher precedence wins on key collisions when `mergePolicy` is
|
|
63
|
+
* `"override"`. Ties: layer ordering in the input array. */
|
|
64
|
+
precedence: number;
|
|
65
|
+
/** Which composition this layer participates in. `composeSpec` does
|
|
66
|
+
* not currently route on scope — it expects callers to filter — but
|
|
67
|
+
* the field makes layer arrays self-describing. */
|
|
68
|
+
scope: LayerScope;
|
|
69
|
+
/** How this layer's entries combine with lower-precedence ones. */
|
|
70
|
+
mergePolicy: MergePolicy;
|
|
71
|
+
/** Load the layer's entries. Async to accommodate filesystem and
|
|
72
|
+
* network sources (an HTTP-fetched OpenAPI snapshot in the future
|
|
73
|
+
* could implement this directly). */
|
|
74
|
+
load: () => Promise<T[]> | T[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Function deriving the merge key for an entry. Pulled out so callers
|
|
78
|
+
* can compose resource lists (key = resource name) and, later, fixture
|
|
79
|
+
* lists (key = var name) with the same engine. */
|
|
80
|
+
export type KeyFn<T> = (entry: T) => string;
|
|
81
|
+
|
|
82
|
+
/** Output of `composeSpec`. `entries` is the merged list in stable
|
|
83
|
+
* order (lowest precedence first, with higher-precedence overrides
|
|
84
|
+
* applied in-place); `provenance` maps the merged key back to the
|
|
85
|
+
* layer id that ultimately owns it. */
|
|
86
|
+
export interface ComposedSpec<T> {
|
|
87
|
+
entries: T[];
|
|
88
|
+
provenance: Map<string, string>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Compose layers into a single entry list + provenance map.
|
|
93
|
+
*
|
|
94
|
+
* Order of operations:
|
|
95
|
+
* 1. Layers are sorted by ascending `precedence` (low → high). A
|
|
96
|
+
* stable sort is used so ties keep input order.
|
|
97
|
+
* 2. Each layer's entries are loaded in sequence (the loader is
|
|
98
|
+
* async-safe so a slow loader can't block parallel ones — but
|
|
99
|
+
* we keep ordering deterministic by awaiting in turn).
|
|
100
|
+
* 3. Entries are folded into the result Map keyed by `keyFn(entry)`:
|
|
101
|
+
* - `mergePolicy === "override"` → replace; provenance updates.
|
|
102
|
+
* - `mergePolicy === "preserve"` → only set if the key is new.
|
|
103
|
+
* - `mergePolicy === "append"` → key suffixed with `#<n>` to
|
|
104
|
+
* avoid collision; provenance still recorded.
|
|
105
|
+
*
|
|
106
|
+
* The result preserves insertion order of the underlying Map, which
|
|
107
|
+
* is the natural "lowest-precedence-first" view: callers that need a
|
|
108
|
+
* different ordering re-sort downstream.
|
|
109
|
+
*/
|
|
110
|
+
export async function composeSpec<T>(
|
|
111
|
+
layers: SpecLayer<T>[],
|
|
112
|
+
keyFn: KeyFn<T>,
|
|
113
|
+
): Promise<ComposedSpec<T>> {
|
|
114
|
+
const ordered = [...layers].sort((a, b) => a.precedence - b.precedence);
|
|
115
|
+
|
|
116
|
+
const seenIds = new Set<string>();
|
|
117
|
+
for (const l of ordered) {
|
|
118
|
+
if (seenIds.has(l.id)) {
|
|
119
|
+
throw new Error(`composeSpec: duplicate layer id "${l.id}"`);
|
|
120
|
+
}
|
|
121
|
+
seenIds.add(l.id);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const merged = new Map<string, T>();
|
|
125
|
+
const provenance = new Map<string, string>();
|
|
126
|
+
let appendCounter = 0;
|
|
127
|
+
|
|
128
|
+
for (const layer of ordered) {
|
|
129
|
+
const entries = await layer.load();
|
|
130
|
+
for (const entry of entries) {
|
|
131
|
+
const key = keyFn(entry);
|
|
132
|
+
switch (layer.mergePolicy) {
|
|
133
|
+
case "override":
|
|
134
|
+
merged.set(key, entry);
|
|
135
|
+
provenance.set(key, layer.id);
|
|
136
|
+
break;
|
|
137
|
+
case "preserve":
|
|
138
|
+
if (!merged.has(key)) {
|
|
139
|
+
merged.set(key, entry);
|
|
140
|
+
provenance.set(key, layer.id);
|
|
141
|
+
}
|
|
142
|
+
break;
|
|
143
|
+
case "append": {
|
|
144
|
+
const tagged = `${key}#${appendCounter++}`;
|
|
145
|
+
merged.set(tagged, entry);
|
|
146
|
+
provenance.set(tagged, layer.id);
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return { entries: Array.from(merged.values()), provenance };
|
|
154
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic union of two or more dereferenced OpenAPI documents into
|
|
3
|
+
* one (ARV-375). Motivation: an org running multiple API versions
|
|
4
|
+
* side-by-side (v1/v2, deprecated-but-live + current) wants combined
|
|
5
|
+
* coverage instead of re-scanning each version and reconciling reports by
|
|
6
|
+
* hand. Before this, that merge was a one-off python script per session.
|
|
7
|
+
*
|
|
8
|
+
* Policy — pure, deterministic, no judgment (belongs in zond core per the
|
|
9
|
+
* litmus test):
|
|
10
|
+
* - `paths`: union; on a path-key collision the LATER spec wins, and the
|
|
11
|
+
* collision is recorded so the caller can warn (silent shadowing of a
|
|
12
|
+
* real endpoint is a correctness trap).
|
|
13
|
+
* - `components.*`: union per sub-bucket (schemas, securitySchemes,
|
|
14
|
+
* parameters, responses, …); later wins. A component-name collision
|
|
15
|
+
* whose two definitions DIFFER (deep-unequal) is reported separately —
|
|
16
|
+
* that is the dangerous case (same name, different shape).
|
|
17
|
+
* - `servers`, `security`, `tags`: unioned & de-duped by value/name.
|
|
18
|
+
* - `info`: taken from the first spec; `version` becomes the unique
|
|
19
|
+
* source versions joined with `+` so the merged target is self-labelling.
|
|
20
|
+
*
|
|
21
|
+
* Inputs are assumed already dereferenced (readOpenApiSpec output), so path
|
|
22
|
+
* operations are self-contained — no cross-spec $ref resolution needed.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { OpenAPIV3 } from "openapi-types";
|
|
26
|
+
|
|
27
|
+
export interface MergeInput {
|
|
28
|
+
/** Source label (path or URL) for the merge summary. */
|
|
29
|
+
source: string;
|
|
30
|
+
doc: OpenAPIV3.Document;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface MergeSummary {
|
|
34
|
+
sources: { source: string; paths: number }[];
|
|
35
|
+
totalPaths: number;
|
|
36
|
+
/** Path keys declared by more than one spec (later-wins applied). */
|
|
37
|
+
pathCollisions: string[];
|
|
38
|
+
/** `components.<bucket>.<name>` whose definition differs across specs. */
|
|
39
|
+
schemaConflicts: string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface MergeResult {
|
|
43
|
+
merged: OpenAPIV3.Document;
|
|
44
|
+
summary: MergeSummary;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const COMPONENT_BUCKETS = [
|
|
48
|
+
"schemas",
|
|
49
|
+
"responses",
|
|
50
|
+
"parameters",
|
|
51
|
+
"examples",
|
|
52
|
+
"requestBodies",
|
|
53
|
+
"headers",
|
|
54
|
+
"securitySchemes",
|
|
55
|
+
"links",
|
|
56
|
+
"callbacks",
|
|
57
|
+
] as const;
|
|
58
|
+
|
|
59
|
+
function deepEqual(a: unknown, b: unknown): boolean {
|
|
60
|
+
// Cheap structural compare — specs are plain JSON after dereference.
|
|
61
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function mergeOpenApiDocs(inputs: MergeInput[]): MergeResult {
|
|
65
|
+
if (inputs.length === 0) throw new Error("mergeOpenApiDocs: no specs to merge");
|
|
66
|
+
const first = inputs[0]!.doc;
|
|
67
|
+
|
|
68
|
+
const paths: NonNullable<OpenAPIV3.Document["paths"]> = {};
|
|
69
|
+
const pathCollisions: string[] = [];
|
|
70
|
+
const sources: MergeSummary["sources"] = [];
|
|
71
|
+
|
|
72
|
+
for (const { source, doc } of inputs) {
|
|
73
|
+
const specPaths = doc.paths ?? {};
|
|
74
|
+
let count = 0;
|
|
75
|
+
for (const [p, item] of Object.entries(specPaths)) {
|
|
76
|
+
if (p in paths) pathCollisions.push(p);
|
|
77
|
+
paths[p] = item as OpenAPIV3.PathItemObject;
|
|
78
|
+
count++;
|
|
79
|
+
}
|
|
80
|
+
sources.push({ source, paths: count });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Merge components bucket-by-bucket; flag same-name-different-shape.
|
|
84
|
+
const schemaConflicts: string[] = [];
|
|
85
|
+
const components: Record<string, Record<string, unknown>> = {};
|
|
86
|
+
for (const { doc } of inputs) {
|
|
87
|
+
const comps = (doc.components ?? {}) as Record<string, Record<string, unknown> | undefined>;
|
|
88
|
+
for (const bucket of COMPONENT_BUCKETS) {
|
|
89
|
+
const incoming = comps[bucket];
|
|
90
|
+
if (!incoming) continue;
|
|
91
|
+
const target = (components[bucket] ??= {});
|
|
92
|
+
for (const [name, def] of Object.entries(incoming)) {
|
|
93
|
+
if (name in target && !deepEqual(target[name], def)) {
|
|
94
|
+
schemaConflicts.push(`${bucket}.${name}`);
|
|
95
|
+
}
|
|
96
|
+
target[name] = def;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Union servers by url, security by value, tags by name.
|
|
102
|
+
const servers = dedupeBy(
|
|
103
|
+
inputs.flatMap((i) => i.doc.servers ?? []),
|
|
104
|
+
(s) => s.url,
|
|
105
|
+
);
|
|
106
|
+
const security = dedupeBy(
|
|
107
|
+
inputs.flatMap((i) => i.doc.security ?? []),
|
|
108
|
+
(s) => JSON.stringify(s),
|
|
109
|
+
);
|
|
110
|
+
const tags = dedupeBy(
|
|
111
|
+
inputs.flatMap((i) => i.doc.tags ?? []),
|
|
112
|
+
(t) => t.name,
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const versions = dedupe(inputs.map((i) => i.doc.info?.version).filter(Boolean) as string[]);
|
|
116
|
+
|
|
117
|
+
const merged: OpenAPIV3.Document = {
|
|
118
|
+
...first,
|
|
119
|
+
openapi: first.openapi ?? "3.0.0",
|
|
120
|
+
info: {
|
|
121
|
+
...first.info,
|
|
122
|
+
version: versions.join("+") || first.info?.version || "merged",
|
|
123
|
+
},
|
|
124
|
+
paths,
|
|
125
|
+
...(Object.keys(components).length > 0 ? { components: components as OpenAPIV3.ComponentsObject } : {}),
|
|
126
|
+
...(servers.length > 0 ? { servers } : {}),
|
|
127
|
+
...(security.length > 0 ? { security } : {}),
|
|
128
|
+
...(tags.length > 0 ? { tags } : {}),
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
merged,
|
|
133
|
+
summary: {
|
|
134
|
+
sources,
|
|
135
|
+
totalPaths: Object.keys(paths).length,
|
|
136
|
+
pathCollisions: dedupe(pathCollisions),
|
|
137
|
+
schemaConflicts: dedupe(schemaConflicts),
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function dedupe<T>(arr: T[]): T[] {
|
|
143
|
+
return [...new Set(arr)];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function dedupeBy<T>(arr: T[], key: (x: T) => string): T[] {
|
|
147
|
+
const seen = new Set<string>();
|
|
148
|
+
const out: T[] = [];
|
|
149
|
+
for (const x of arr) {
|
|
150
|
+
const k = key(x);
|
|
151
|
+
if (seen.has(k)) continue;
|
|
152
|
+
seen.add(k);
|
|
153
|
+
out.push(x);
|
|
154
|
+
}
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ARV-175: extract 2xx response bodies from a persisted run and infer a
|
|
3
|
+
* JSON Schema per (endpoint, status). The output `patch.schema.json` is the
|
|
4
|
+
* input to `refresh-api --merge-schema` (ARV-176), which folds it into the
|
|
5
|
+
* spec overlay so `response_schema_conformance` has something to check on
|
|
6
|
+
* APIs whose upstream spec declares no response schemas.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { inferSchema, type JsonSchema } from "./infer-schema.ts";
|
|
10
|
+
import { specPathToRegex, normalizePath } from "../generator/coverage-scanner.ts";
|
|
11
|
+
import type { EndpointInfo } from "../generator/types.ts";
|
|
12
|
+
|
|
13
|
+
export interface SchemaFromRunsResult {
|
|
14
|
+
/** endpoint label (`METHOD /path/{tpl}`) → status code → inferred schema. */
|
|
15
|
+
patch: Record<string, Record<string, JsonSchema>>;
|
|
16
|
+
/** Per-group accounting for the CLI to report. */
|
|
17
|
+
groups: Array<{
|
|
18
|
+
endpoint: string;
|
|
19
|
+
status: string;
|
|
20
|
+
samples: number;
|
|
21
|
+
emitted: boolean;
|
|
22
|
+
reason?: string;
|
|
23
|
+
}>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ResultRow {
|
|
27
|
+
request_method: string | null;
|
|
28
|
+
request_url: string | null;
|
|
29
|
+
response_status: number | null;
|
|
30
|
+
response_body: string | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Strip base URL, query, and trailing slash from a concrete request URL,
|
|
34
|
+
* leaving just the path for spec-template matching. */
|
|
35
|
+
function pathOf(rawUrl: string): string {
|
|
36
|
+
let p = rawUrl;
|
|
37
|
+
// Drop scheme+host if present.
|
|
38
|
+
const schemeIdx = p.indexOf("://");
|
|
39
|
+
if (schemeIdx !== -1) {
|
|
40
|
+
const afterScheme = p.slice(schemeIdx + 3);
|
|
41
|
+
const slash = afterScheme.indexOf("/");
|
|
42
|
+
p = slash === -1 ? "/" : afterScheme.slice(slash);
|
|
43
|
+
}
|
|
44
|
+
const q = p.indexOf("?");
|
|
45
|
+
if (q !== -1) p = p.slice(0, q);
|
|
46
|
+
return p.replace(/\/+$/, "") || "/";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Match a concrete path to the most specific spec endpoint (fewest params
|
|
50
|
+
* wins, so `/users/me` beats `/users/{id}`). Returns the endpoint label. */
|
|
51
|
+
function matchEndpoint(
|
|
52
|
+
method: string,
|
|
53
|
+
concretePath: string,
|
|
54
|
+
endpoints: EndpointInfo[],
|
|
55
|
+
): string | null {
|
|
56
|
+
const norm = normalizePath(concretePath);
|
|
57
|
+
const candidates = endpoints
|
|
58
|
+
.filter((e) => e.method.toUpperCase() === method.toUpperCase())
|
|
59
|
+
.filter((e) => specPathToRegex(e.path).test(norm))
|
|
60
|
+
.sort((a, b) => paramCount(a.path) - paramCount(b.path));
|
|
61
|
+
const best = candidates[0];
|
|
62
|
+
return best ? `${best.method.toUpperCase()} ${best.path}` : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function paramCount(path: string): number {
|
|
66
|
+
return (path.match(/\{[^}]+\}/g) ?? []).length;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface SchemaFromRunsOptions {
|
|
70
|
+
results: ResultRow[];
|
|
71
|
+
endpoints: EndpointInfo[];
|
|
72
|
+
/** Minimum 2xx samples per (endpoint, status) group to emit a schema. */
|
|
73
|
+
minSamples: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function schemaFromRuns(opts: SchemaFromRunsOptions): SchemaFromRunsResult {
|
|
77
|
+
const { results, endpoints, minSamples } = opts;
|
|
78
|
+
// (endpoint → status → parsed bodies)
|
|
79
|
+
const buckets = new Map<string, Map<string, unknown[]>>();
|
|
80
|
+
// Preserve unmatched URLs so the CLI can hint at spec/base-url drift.
|
|
81
|
+
for (const r of results) {
|
|
82
|
+
if (r.response_status == null || r.response_status < 200 || r.response_status >= 300) continue;
|
|
83
|
+
if (!r.response_body || !r.request_url || !r.request_method) continue;
|
|
84
|
+
let body: unknown;
|
|
85
|
+
try {
|
|
86
|
+
body = JSON.parse(r.response_body);
|
|
87
|
+
} catch {
|
|
88
|
+
continue; // non-JSON 2xx body — nothing to infer for application/json
|
|
89
|
+
}
|
|
90
|
+
const endpoint = matchEndpoint(r.request_method, pathOf(r.request_url), endpoints);
|
|
91
|
+
if (!endpoint) continue;
|
|
92
|
+
const status = String(r.response_status);
|
|
93
|
+
if (!buckets.has(endpoint)) buckets.set(endpoint, new Map());
|
|
94
|
+
const byStatus = buckets.get(endpoint)!;
|
|
95
|
+
if (!byStatus.has(status)) byStatus.set(status, []);
|
|
96
|
+
byStatus.get(status)!.push(body);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const patch: SchemaFromRunsResult["patch"] = {};
|
|
100
|
+
const groups: SchemaFromRunsResult["groups"] = [];
|
|
101
|
+
// Deterministic order: sort by endpoint then status.
|
|
102
|
+
for (const endpoint of [...buckets.keys()].sort()) {
|
|
103
|
+
const byStatus = buckets.get(endpoint)!;
|
|
104
|
+
for (const status of [...byStatus.keys()].sort()) {
|
|
105
|
+
const samples = byStatus.get(status)!;
|
|
106
|
+
if (samples.length < minSamples) {
|
|
107
|
+
groups.push({ endpoint, status, samples: samples.length, emitted: false, reason: `<${minSamples} samples` });
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const schema = inferSchema(samples);
|
|
111
|
+
if (!patch[endpoint]) patch[endpoint] = {};
|
|
112
|
+
patch[endpoint]![status] = schema;
|
|
113
|
+
groups.push({ endpoint, status, samples: samples.length, emitted: true });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return { patch, groups };
|
|
117
|
+
}
|