@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,898 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build `.api-resources.yaml` — the CRUD-chain map of an API.
|
|
3
|
+
*
|
|
4
|
+
* Purpose: skill code (scenario authoring, audit setup) reads this instead
|
|
5
|
+
* of grep'ing the OpenAPI spec to answer "what resources can I CRUD, what
|
|
6
|
+
* field captures the id, are there ETag / soft-delete pitfalls". The
|
|
7
|
+
* extended form also lists FK dependencies so a scenario can plan a
|
|
8
|
+
* setup chain (audience → contact requires audience_id, etc.).
|
|
9
|
+
*
|
|
10
|
+
* The file is git-trackable evidence of the API's surface; regenerated
|
|
11
|
+
* by `zond add api` and (later) `zond refresh-api`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { EndpointInfo, CrudGroup } from "./types.ts";
|
|
15
|
+
import type { OpenAPIV3 } from "openapi-types";
|
|
16
|
+
import { detectCrudGroups, singularizeResource, stripTrailingVersionSegments } from "./suite-generator.ts";
|
|
17
|
+
|
|
18
|
+
export interface ResourceFkRef {
|
|
19
|
+
/** Variable name expected in `.env.yaml` to satisfy the FK (e.g. `audience_id`). */
|
|
20
|
+
var: string;
|
|
21
|
+
/** Path-parameter or body-field name that consumes the FK in the API. */
|
|
22
|
+
param: string;
|
|
23
|
+
/** Where the value gets injected: path | body. */
|
|
24
|
+
in: "path" | "body";
|
|
25
|
+
/** Resource name we believe owns this id (best-effort, may be null). */
|
|
26
|
+
ownerResource: string | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* ARV-169 (m-20 cross-call drift): per-resource overrides for the
|
|
31
|
+
* POST→GET shape-diff probe. All fields optional — when absent the
|
|
32
|
+
* check falls back to `DEFAULT_READBACK_IGNORE` (timestamp / etag /
|
|
33
|
+
* envelope quirks) so a probe works on a stock spec without yaml work.
|
|
34
|
+
* Authored by `zond api annotate --readback` (ARV-187) or by hand.
|
|
35
|
+
*/
|
|
36
|
+
export interface ReadbackDiffConfig {
|
|
37
|
+
/** Field names dropped before diff. Suppresses known API-quirks
|
|
38
|
+
* (Stripe `metadata` stripping, livemode, object discriminators)
|
|
39
|
+
* so they don't drown out real drift. */
|
|
40
|
+
ignoreFields?: string[];
|
|
41
|
+
/** Write-shape → read-shape rename. Stripe takes `tax_id_data` on
|
|
42
|
+
* create but exposes it as `tax_ids` on read; without this the
|
|
43
|
+
* field looks like state-not-persisted on every probe. */
|
|
44
|
+
writeToReadMap?: Record<string, string>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* ARV-170 (m-20 idempotency-replay): per-resource declaration that the
|
|
49
|
+
* create endpoint honors an Idempotency-Key header. When present, the
|
|
50
|
+
* `idempotency_replay` stateful check sends POST twice with the same
|
|
51
|
+
* key and asserts (a) no duplicate resource is created and (b) the two
|
|
52
|
+
* responses are bit-identical modulo `ignoreResponseFields`.
|
|
53
|
+
*
|
|
54
|
+
* Auto-detect fallback: if `idempotency:` is absent from yaml but the
|
|
55
|
+
* create endpoint declares an `Idempotency-Key` header parameter in
|
|
56
|
+
* the spec, the check still runs with `header="Idempotency-Key"` and
|
|
57
|
+
* the default ignore list. Explicit yaml is preferred — it documents
|
|
58
|
+
* intent and lets the user customise the ignore list per API quirks
|
|
59
|
+
* (Stripe `request_id`, Resend `retry_after`).
|
|
60
|
+
*/
|
|
61
|
+
export interface IdempotencyConfig {
|
|
62
|
+
/** Header that carries the key. Default `Idempotency-Key`. */
|
|
63
|
+
header?: string;
|
|
64
|
+
/** Informational. `endpoint` = key scoped per-endpoint (Stripe).
|
|
65
|
+
* `global` = same key replays across endpoints. Today the check
|
|
66
|
+
* uses the same flow either way; field is read for diagnostics. */
|
|
67
|
+
scope?: "endpoint" | "global";
|
|
68
|
+
/** Response-body field names stripped before the R1==R2 compare.
|
|
69
|
+
* Defaults to a baseline list shared with readback-diff
|
|
70
|
+
* (timestamps, request_id, etag) when omitted. */
|
|
71
|
+
ignoreResponseFields?: string[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* ARV-171 (m-20 pagination-invariants): per-list-endpoint declaration
|
|
76
|
+
* of the pagination strategy. The `pagination_invariants` stateful
|
|
77
|
+
* check uses this to ask for two consecutive pages and assert
|
|
78
|
+
* disjointness + has_more consistency.
|
|
79
|
+
*
|
|
80
|
+
* Supported types in this milestone:
|
|
81
|
+
* • `cursor` — Stripe-style: caller passes a cursor (e.g.
|
|
82
|
+
* `starting_after=<id>`) derived from the last item of the
|
|
83
|
+
* previous page.
|
|
84
|
+
* • `page` — page-number style (GitHub, GitLab, Atlassian, Notion,
|
|
85
|
+
* Linear): `?page=N&per_page=M`. ARV-220 enabled this in m-21.
|
|
86
|
+
* • `offset` and `token` — declared for forward compatibility; the
|
|
87
|
+
* check currently skips with a "pagination type not implemented"
|
|
88
|
+
* reason so the yaml block stays a stable schema.
|
|
89
|
+
*
|
|
90
|
+
* Auto-detect fallback: if the list endpoint declares `starting_after`
|
|
91
|
+
* / `cursor` / `page_token` query parameters in the spec, the check
|
|
92
|
+
* uses sensible defaults (cursor_field=`id`, items_field=`data` →
|
|
93
|
+
* `items` → `results`, has_more_field=`has_more`). Explicit yaml is
|
|
94
|
+
* preferred — it documents intent and survives spec changes that
|
|
95
|
+
* rename query params.
|
|
96
|
+
*/
|
|
97
|
+
/**
|
|
98
|
+
* ARV-172 (m-20 lifecycle-transitions): declared state machine for a
|
|
99
|
+
* resource. Used by the `lifecycle_transitions` stateful check to
|
|
100
|
+
* verify that documented actions (cancel / archive / publish / ...)
|
|
101
|
+
* move a resource between declared states and that double-invoking an
|
|
102
|
+
* action either 4xx's or stays idempotent (no state regression).
|
|
103
|
+
*
|
|
104
|
+
* The yaml block has three parts:
|
|
105
|
+
* • `field` + `states` — name of the response field carrying the
|
|
106
|
+
* state, plus the closed enum of legal values.
|
|
107
|
+
* • `transitions` — a from→to graph; the check uses it to flag
|
|
108
|
+
* forbidden transitions (cancelled → active) when an action lands
|
|
109
|
+
* a resource somewhere the graph doesn't allow.
|
|
110
|
+
* • `actions` — POST endpoints that should drive a transition.
|
|
111
|
+
* `expected_state` is the state the resource must be in after a
|
|
112
|
+
* successful action call.
|
|
113
|
+
*
|
|
114
|
+
* Manifest validation runs at load time and surfaces obvious
|
|
115
|
+
* authoring bugs (unreachable states, missing terminal, action
|
|
116
|
+
* referencing an undeclared state) before any HTTP call goes out.
|
|
117
|
+
*/
|
|
118
|
+
export interface LifecycleAction {
|
|
119
|
+
/** Endpoint label, e.g. "POST /v1/subscriptions/{id}/cancel". The
|
|
120
|
+
* `{id}` placeholder is substituted with the created resource id. */
|
|
121
|
+
endpoint: string;
|
|
122
|
+
/** State the resource must be in after this action lands. */
|
|
123
|
+
expectedState: string;
|
|
124
|
+
/** Optional request body sent with the action POST. Most lifecycle
|
|
125
|
+
* actions are body-less (cancel, archive, publish); leave empty
|
|
126
|
+
* when not needed. Serialised as JSON or form depending on the
|
|
127
|
+
* endpoint's declared content type. */
|
|
128
|
+
body?: Record<string, unknown>;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface LifecycleConfig {
|
|
132
|
+
/** Response field name carrying the state (e.g. `status`). */
|
|
133
|
+
field: string;
|
|
134
|
+
/** Closed enum of legal state values. Any state observed on the
|
|
135
|
+
* wire that isn't in this list is a finding. */
|
|
136
|
+
states: string[];
|
|
137
|
+
/** Allowed from→to graph. States not listed as `from` are assumed
|
|
138
|
+
* terminal (no outgoing transition). States not listed as `to` of
|
|
139
|
+
* any transition are starting-only (unreachable post-create). */
|
|
140
|
+
transitions: { from: string; to: string[] }[];
|
|
141
|
+
/** Named actions keyed by action name (cancel / archive / publish).
|
|
142
|
+
* The check runs through them in object-key order. */
|
|
143
|
+
actions: Record<string, LifecycleAction>;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Static validation of a lifecycle manifest. Returns the list of
|
|
148
|
+
* authoring bugs without throwing — callers decide whether to fail
|
|
149
|
+
* the run or just warn. Empty array = clean manifest.
|
|
150
|
+
*/
|
|
151
|
+
export function validateLifecycleManifest(cfg: LifecycleConfig): string[] {
|
|
152
|
+
const errors: string[] = [];
|
|
153
|
+
if (!cfg.field || cfg.field.length === 0) errors.push("lifecycle.field is empty");
|
|
154
|
+
if (!cfg.states || cfg.states.length === 0) errors.push("lifecycle.states is empty");
|
|
155
|
+
const stateSet = new Set(cfg.states ?? []);
|
|
156
|
+
for (const t of cfg.transitions ?? []) {
|
|
157
|
+
if (!stateSet.has(t.from)) errors.push(`transitions: unknown "from" state "${t.from}"`);
|
|
158
|
+
for (const to of t.to) {
|
|
159
|
+
if (!stateSet.has(to)) errors.push(`transitions[${t.from}]: unknown "to" state "${to}"`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// At least one terminal — a state with no outgoing transition (or
|
|
163
|
+
// an explicit `to: []`). A graph with every state having outgoing
|
|
164
|
+
// edges is suspicious (no end-of-life, infinite churn).
|
|
165
|
+
const hasOutgoing = new Set((cfg.transitions ?? []).filter((t) => t.to.length > 0).map((t) => t.from));
|
|
166
|
+
const terminals = (cfg.states ?? []).filter((s) => !hasOutgoing.has(s));
|
|
167
|
+
if (terminals.length === 0) errors.push("no terminal state — every declared state has outgoing transitions");
|
|
168
|
+
// Actions must reference declared states.
|
|
169
|
+
for (const [name, a] of Object.entries(cfg.actions ?? {})) {
|
|
170
|
+
if (!stateSet.has(a.expectedState)) {
|
|
171
|
+
errors.push(`actions.${name}.expected_state "${a.expectedState}" is not in states[]`);
|
|
172
|
+
}
|
|
173
|
+
if (!a.endpoint || a.endpoint.length === 0) {
|
|
174
|
+
errors.push(`actions.${name}.endpoint is empty`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return errors;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export interface PaginationConfig {
|
|
181
|
+
/** Pagination flavor. Default `cursor`. */
|
|
182
|
+
type?: "cursor" | "page" | "offset" | "token";
|
|
183
|
+
/** Query-param name that takes the cursor value. Default `starting_after`.
|
|
184
|
+
* Only used when `type: cursor`. */
|
|
185
|
+
cursorParam?: string;
|
|
186
|
+
/** Response field on each item that becomes the next cursor (cursor-style)
|
|
187
|
+
* and the dedupe key when comparing pages (both styles). Default `id`. */
|
|
188
|
+
cursorField?: string;
|
|
189
|
+
/** Response field that signals "more pages remain". Default `has_more`.
|
|
190
|
+
* Only consulted for `type: cursor` — page-style APIs typically rely on
|
|
191
|
+
* Link headers or `total_pages` instead, so the field is ignored there. */
|
|
192
|
+
hasMoreField?: string;
|
|
193
|
+
/** Query-param name for page size. Default `limit` for cursor-style,
|
|
194
|
+
* `per_page` for page-style. */
|
|
195
|
+
limitParam?: string;
|
|
196
|
+
/** Probe page-size. Default 2 (small enough to land two replies fast). */
|
|
197
|
+
defaultLimit?: number;
|
|
198
|
+
/** Response field carrying the array of items. Default `data` (Stripe);
|
|
199
|
+
* falls back to `items` / `results` when missing. */
|
|
200
|
+
itemsField?: string;
|
|
201
|
+
/** Page-number query-param name. Default `page`. Only used when `type: page`. */
|
|
202
|
+
pageParam?: string;
|
|
203
|
+
/** First page number (1-based on GitHub/GitLab, 0-based on some custom APIs).
|
|
204
|
+
* Default 1. Only used when `type: page`. */
|
|
205
|
+
startPage?: number;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** ARV-187: LLM-authored example POST body. Stateful checks prefer this
|
|
209
|
+
* over generateFromSchema(create) when present. */
|
|
210
|
+
export interface SeedBodyConfig {
|
|
211
|
+
/** Defaults to the create endpoint's requestBodyContentType. */
|
|
212
|
+
contentType?: string;
|
|
213
|
+
body: Record<string, unknown>;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export interface ApiResourceEntry {
|
|
217
|
+
resource: string;
|
|
218
|
+
basePath: string;
|
|
219
|
+
itemPath: string;
|
|
220
|
+
idParam: string;
|
|
221
|
+
/** What field on the create response carries the new id (typically `id`). */
|
|
222
|
+
captureField: string;
|
|
223
|
+
/** True when the resource exposes List + Create + Read at minimum. */
|
|
224
|
+
hasFullCrud: boolean;
|
|
225
|
+
endpoints: {
|
|
226
|
+
list?: string; // "GET /audiences"
|
|
227
|
+
create?: string;
|
|
228
|
+
read?: string;
|
|
229
|
+
update?: string;
|
|
230
|
+
delete?: string;
|
|
231
|
+
};
|
|
232
|
+
/** Update/Delete demand If-Match? (heuristic: 412 in spec or ETag in headers). */
|
|
233
|
+
requiresEtag?: boolean;
|
|
234
|
+
/** Heuristic: read-after-delete returns 200 instead of 404 (filled at runtime, default false). */
|
|
235
|
+
softDelete?: boolean;
|
|
236
|
+
/** Other resources whose ids this resource consumes (FK chain). */
|
|
237
|
+
fkDependencies: ResourceFkRef[];
|
|
238
|
+
/** ARV-169: optional cross-call-drift overrides. */
|
|
239
|
+
readbackDiff?: ReadbackDiffConfig;
|
|
240
|
+
/** ARV-170: opt-in idempotency-replay probe. */
|
|
241
|
+
idempotency?: IdempotencyConfig;
|
|
242
|
+
/** ARV-171: pagination-invariants probe. */
|
|
243
|
+
pagination?: PaginationConfig;
|
|
244
|
+
/** ARV-172: state-machine for the resource. */
|
|
245
|
+
lifecycle?: LifecycleConfig;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export interface ApiResourceMap {
|
|
249
|
+
generatedAt: string;
|
|
250
|
+
specHash: string;
|
|
251
|
+
resourceCount: number;
|
|
252
|
+
resources: ApiResourceEntry[];
|
|
253
|
+
/** Endpoints that didn't fit any CRUD group (action endpoints, RPC-style). */
|
|
254
|
+
orphanEndpoints: string[];
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function epLabel(ep: EndpointInfo): string {
|
|
258
|
+
return `${ep.method.toUpperCase()} ${ep.path}`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function pathStripSlash(p: string): string {
|
|
262
|
+
return p.length > 1 && p.endsWith("/") ? p.slice(0, -1) : p;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function isParamSeg(seg: string | undefined): boolean {
|
|
266
|
+
return !!seg && /^\{[^}]+\}$/.test(seg);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function escapeRegex(s: string): string {
|
|
270
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* ARV-134 (resource-builder fix #1): when two CRUD groups would collide on
|
|
275
|
+
* the same `resource` name (e.g. `POST /segments` and `POST /contacts/
|
|
276
|
+
* {contact_id}/segments` both produce `resource: "segments"`), keep the
|
|
277
|
+
* canonical (shortest basePath) entry and rename the nested ones to
|
|
278
|
+
* `<parent-noun>_<resource>` — `contact_segments` here. Without this,
|
|
279
|
+
* `.api-resources.yaml` ends up with duplicate entries that break
|
|
280
|
+
* map-by-name lookups (annotate overlay, refresh-api idempotence,
|
|
281
|
+
* stateful-check resource configs).
|
|
282
|
+
*/
|
|
283
|
+
function disambiguateResourceCollisions(groups: CrudGroup[]): CrudGroup[] {
|
|
284
|
+
const byName = new Map<string, CrudGroup[]>();
|
|
285
|
+
for (const g of groups) {
|
|
286
|
+
const arr = byName.get(g.resource) ?? [];
|
|
287
|
+
arr.push(g);
|
|
288
|
+
byName.set(g.resource, arr);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const renames = new Map<CrudGroup, string>();
|
|
292
|
+
const usedNames = new Set<string>(byName.keys());
|
|
293
|
+
|
|
294
|
+
for (const [name, members] of byName) {
|
|
295
|
+
if (members.length < 2) continue;
|
|
296
|
+
// Canonical name goes to the entry with the *strictly* shortest
|
|
297
|
+
// basePath (the top-level collection in the typical /segments vs
|
|
298
|
+
// /contacts/{id}/segments case). If two members tie for shortest,
|
|
299
|
+
// there is no obvious winner — rename all of them so the yaml never
|
|
300
|
+
// silently picks one arbitrary entry as canonical.
|
|
301
|
+
const sorted = [...members].sort((a, b) => a.basePath.length - b.basePath.length);
|
|
302
|
+
const shortest = sorted[0]!.basePath.length;
|
|
303
|
+
const tiedAtShortest = sorted.filter(g => g.basePath.length === shortest).length;
|
|
304
|
+
const startFromIndex = tiedAtShortest === 1 ? 1 : 0;
|
|
305
|
+
|
|
306
|
+
for (let i = startFromIndex; i < sorted.length; i++) {
|
|
307
|
+
const g = sorted[i]!;
|
|
308
|
+
const prefix = parentNounForBasePath(g.basePath);
|
|
309
|
+
const singularPrefix = prefix ? singularizeResource(prefix) : null;
|
|
310
|
+
let candidate = singularPrefix ? `${singularPrefix}_${name}` : `${name}_${i + 1}`;
|
|
311
|
+
let n = 2;
|
|
312
|
+
while (usedNames.has(candidate)) {
|
|
313
|
+
candidate = singularPrefix ? `${singularPrefix}_${name}_${n++}` : `${name}_${n++}`;
|
|
314
|
+
}
|
|
315
|
+
usedNames.add(candidate);
|
|
316
|
+
renames.set(g, candidate);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (renames.size === 0) return groups;
|
|
321
|
+
return groups.map(g => (renames.has(g) ? { ...g, resource: renames.get(g)! } : g));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* ARV-134 follow-up: same rename strategy as `disambiguateResourceCollisions`,
|
|
326
|
+
* but operating on the final `ApiResourceEntry[]` so CRUD-vs-implicit
|
|
327
|
+
* name clashes also get resolved. Keeps the implementation parallel so
|
|
328
|
+
* the behaviour stays consistent (strictly-shortest basePath keeps the
|
|
329
|
+
* canonical name; ties get all-renamed; suffix-bumping on hash
|
|
330
|
+
* collision).
|
|
331
|
+
*/
|
|
332
|
+
function disambiguateEntryCollisions(entries: ApiResourceEntry[]): ApiResourceEntry[] {
|
|
333
|
+
const byName = new Map<string, ApiResourceEntry[]>();
|
|
334
|
+
for (const e of entries) {
|
|
335
|
+
const arr = byName.get(e.resource) ?? [];
|
|
336
|
+
arr.push(e);
|
|
337
|
+
byName.set(e.resource, arr);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const renames = new Map<ApiResourceEntry, string>();
|
|
341
|
+
const usedNames = new Set<string>(byName.keys());
|
|
342
|
+
|
|
343
|
+
for (const [name, members] of byName) {
|
|
344
|
+
if (members.length < 2) continue;
|
|
345
|
+
const sorted = [...members].sort((a, b) => a.basePath.length - b.basePath.length);
|
|
346
|
+
const shortest = sorted[0]!.basePath.length;
|
|
347
|
+
const tiedAtShortest = sorted.filter(e => e.basePath.length === shortest).length;
|
|
348
|
+
const startFromIndex = tiedAtShortest === 1 ? 1 : 0;
|
|
349
|
+
|
|
350
|
+
for (let i = startFromIndex; i < sorted.length; i++) {
|
|
351
|
+
const e = sorted[i]!;
|
|
352
|
+
const prefix = parentNounForBasePath(e.basePath);
|
|
353
|
+
const singularPrefix = prefix ? singularizeResource(prefix) : null;
|
|
354
|
+
let candidate = singularPrefix ? `${singularPrefix}_${name}` : `${name}_${i + 1}`;
|
|
355
|
+
let n = 2;
|
|
356
|
+
while (usedNames.has(candidate)) {
|
|
357
|
+
candidate = singularPrefix ? `${singularPrefix}_${name}_${n++}` : `${name}_${n++}`;
|
|
358
|
+
}
|
|
359
|
+
usedNames.add(candidate);
|
|
360
|
+
renames.set(e, candidate);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (renames.size === 0) return entries;
|
|
365
|
+
return entries.map(e => (renames.has(e) ? { ...e, resource: renames.get(e)! } : e));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function parentNounForBasePath(basePath: string): string | null {
|
|
369
|
+
const segs = pathStripSlash(basePath).split("/").filter(Boolean);
|
|
370
|
+
// Skip the last segment (the resource itself); walk back to the nearest
|
|
371
|
+
// non-param noun. `/contacts/{contact_id}/segments` → "contacts".
|
|
372
|
+
for (let i = segs.length - 2; i >= 0; i--) {
|
|
373
|
+
if (!isParamSeg(segs[i])) return segs[i]!;
|
|
374
|
+
}
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* ARV-134 (resource-builder fix #2): for an implicit list-only resource
|
|
380
|
+
* (no CRUD group), look for a GET-by-id companion endpoint that gives us
|
|
381
|
+
* a real idParam + itemPath. Resend's `/logs/{log_id}` and `/automations/
|
|
382
|
+
* {automation_id}/runs/{run_id}` were getting `idParam: ""` because the
|
|
383
|
+
* implicit-resource constructor never inspected the spec for their item
|
|
384
|
+
* endpoints — `prepare-fixtures` then skipped them.
|
|
385
|
+
*/
|
|
386
|
+
function findItemEndpointForListPath(
|
|
387
|
+
listPath: string,
|
|
388
|
+
endpoints: EndpointInfo[],
|
|
389
|
+
): EndpointInfo | null {
|
|
390
|
+
const itemRe = new RegExp(`^${escapeRegex(listPath)}/\\{([^}]+)\\}/?$`);
|
|
391
|
+
for (const ep of endpoints) {
|
|
392
|
+
if (ep.method.toUpperCase() !== "GET" || ep.deprecated) continue;
|
|
393
|
+
if (itemRe.test(pathStripSlash(ep.path))) return ep;
|
|
394
|
+
}
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function getCaptureField(create: EndpointInfo): string {
|
|
399
|
+
// Look at the create endpoint's success response schema for an `id`-ish
|
|
400
|
+
// field. Falls back to "id" — the universal default.
|
|
401
|
+
const success = create.responses.find(r => r.statusCode >= 200 && r.statusCode < 300);
|
|
402
|
+
const schema = success?.schema as OpenAPIV3.SchemaObject | undefined;
|
|
403
|
+
if (schema?.properties) {
|
|
404
|
+
const props = schema.properties as Record<string, OpenAPIV3.SchemaObject>;
|
|
405
|
+
for (const candidate of ["id", "uuid", "key", "code"]) {
|
|
406
|
+
if (props[candidate]) return candidate;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return "id";
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Structurally infer the list-endpoint that owns each path-parameter by
|
|
414
|
+
* walking the actual URL graph in the spec. Beats name-stemming because
|
|
415
|
+
*
|
|
416
|
+
* • `_id_or_slug`, `_or_name`, non-English plurals, weird casing — all
|
|
417
|
+
* transparent: we only look at segment positions, not at param names.
|
|
418
|
+
* • Returns the *exact* GET path to call, not a guessed resource name we
|
|
419
|
+
* later have to hope is wired up correctly.
|
|
420
|
+
* • Two-strategy lookup so it survives both canonical nesting
|
|
421
|
+
* (`/orgs/{org}/projects/{proj}/...` — prev seg `projects` is a list)
|
|
422
|
+
* and common SaaS-style sibling-param chains
|
|
423
|
+
* (`/projects/{org}/{proj}/...` — prev seg is itself a param;
|
|
424
|
+
* we walk back to the nearest non-param segment and search for any
|
|
425
|
+
* GET path ending with that hint).
|
|
426
|
+
*/
|
|
427
|
+
// ARV-376: list-shaped verb suffixes. `/api/business-segment20/list` is the
|
|
428
|
+
// collection listing for stem `/api/business-segment20`, even though the
|
|
429
|
+
// stem itself is not a GET endpoint. Same for `/search` and `/find`.
|
|
430
|
+
const LIST_VERB_SUFFIXES = new Set(["list", "search", "find"]);
|
|
431
|
+
|
|
432
|
+
export function resolveOwnerListPaths(endpoints: EndpointInfo[]): Map<string, string> {
|
|
433
|
+
const getPathSet = new Set<string>();
|
|
434
|
+
const getPathsByLastSeg = new Map<string, string[]>();
|
|
435
|
+
// ARV-376: a `/list`-style endpoint indexed by its collection stem
|
|
436
|
+
// (path minus the verb suffix) and by that stem's last noun segment, so a
|
|
437
|
+
// read-by-id sibling (`/{code}`, `/byid/{id}`) can find its listing even
|
|
438
|
+
// though the bare collection path has no GET.
|
|
439
|
+
const listByStem = new Map<string, string>();
|
|
440
|
+
const listByStemLastSeg = new Map<string, string[]>();
|
|
441
|
+
const considerShortest = (map: Map<string, string>, key: string, path: string) => {
|
|
442
|
+
const cur = map.get(key);
|
|
443
|
+
if (!cur || path.length < cur.length) map.set(key, path);
|
|
444
|
+
};
|
|
445
|
+
for (const ep of endpoints) {
|
|
446
|
+
if (ep.method.toUpperCase() !== "GET" || ep.deprecated) continue;
|
|
447
|
+
const path = pathStripSlash(ep.path);
|
|
448
|
+
getPathSet.add(path);
|
|
449
|
+
const segs = path.split("/").filter(Boolean);
|
|
450
|
+
const last = segs[segs.length - 1];
|
|
451
|
+
if (last && !isParamSeg(last)) {
|
|
452
|
+
const arr = getPathsByLastSeg.get(last) ?? [];
|
|
453
|
+
arr.push(path);
|
|
454
|
+
getPathsByLastSeg.set(last, arr);
|
|
455
|
+
}
|
|
456
|
+
// Index list-verb endpoints against their collection stem. The stem key
|
|
457
|
+
// is the leading-slash path form the resolution loop below builds its
|
|
458
|
+
// prefixes in (NOT the filtered form) so the two sides compare equal.
|
|
459
|
+
if (last && LIST_VERB_SUFFIXES.has(last.toLowerCase()) && segs.length >= 2) {
|
|
460
|
+
const stemLast = segs[segs.length - 2];
|
|
461
|
+
if (stemLast && !isParamSeg(stemLast)) {
|
|
462
|
+
const lastSlash = path.lastIndexOf("/");
|
|
463
|
+
const stemPath = lastSlash > 0 ? path.slice(0, lastSlash) : "";
|
|
464
|
+
considerShortest(listByStem, stemPath, path);
|
|
465
|
+
const arr = listByStemLastSeg.get(stemLast) ?? [];
|
|
466
|
+
arr.push(path);
|
|
467
|
+
listByStemLastSeg.set(stemLast, arr);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const result = new Map<string, string>();
|
|
473
|
+
const consider = (param: string, candidate: string) => {
|
|
474
|
+
const existing = result.get(param);
|
|
475
|
+
// Prefer shorter (more canonical/top-level) list path.
|
|
476
|
+
if (!existing || candidate.length < existing.length) result.set(param, candidate);
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
for (const ep of endpoints) {
|
|
480
|
+
if (ep.deprecated) continue;
|
|
481
|
+
const segs = pathStripSlash(ep.path).split("/");
|
|
482
|
+
for (let i = 0; i < segs.length; i++) {
|
|
483
|
+
const seg = segs[i]!;
|
|
484
|
+
const m = /^\{([^}]+)\}$/.exec(seg);
|
|
485
|
+
if (!m) continue;
|
|
486
|
+
const param = m[1]!;
|
|
487
|
+
|
|
488
|
+
// Strategy 1 (canonical + ARV-376): walk back over the run of
|
|
489
|
+
// consecutive non-param segments before `{param}`; for each candidate
|
|
490
|
+
// prefix match either a collection GET or a `/list`-style endpoint.
|
|
491
|
+
// Longest (most specific) prefix wins. Stops at the first param
|
|
492
|
+
// segment so we never attribute a child id to a grandparent list. This
|
|
493
|
+
// tolerates read-by-id marker segments (`/byid/{id}` → owner is the
|
|
494
|
+
// `/business-segment20/list` two segments up).
|
|
495
|
+
let matched = false;
|
|
496
|
+
for (let k = i; k >= 1; k--) {
|
|
497
|
+
if (isParamSeg(segs[k - 1]!)) break; // hit a param boundary — stop
|
|
498
|
+
const prefix = segs.slice(0, k).join("/");
|
|
499
|
+
if (getPathSet.has(prefix)) { consider(param, prefix); matched = true; break; }
|
|
500
|
+
const listPath = listByStem.get(prefix);
|
|
501
|
+
if (listPath) { consider(param, listPath); matched = true; break; }
|
|
502
|
+
}
|
|
503
|
+
if (matched) continue;
|
|
504
|
+
|
|
505
|
+
// Strategy 2 (sibling-param chain): walk back to the nearest
|
|
506
|
+
// non-param segment, then look for *any* GET path that terminates
|
|
507
|
+
// with that segment (or a `/list`-style endpoint for that stem).
|
|
508
|
+
let hint: string | undefined;
|
|
509
|
+
for (let j = i - 1; j >= 0; j--) {
|
|
510
|
+
const s = segs[j]!;
|
|
511
|
+
if (!isParamSeg(s) && s !== "") {
|
|
512
|
+
hint = s;
|
|
513
|
+
break;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
if (!hint) continue;
|
|
517
|
+
const candidates = getPathsByLastSeg.get(hint) ?? listByStemLastSeg.get(hint);
|
|
518
|
+
if (!candidates || candidates.length === 0) continue;
|
|
519
|
+
const shortest = candidates.reduce((a, b) => (a.length <= b.length ? a : b));
|
|
520
|
+
consider(param, shortest);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
return result;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function listPathToResourceName(listPath: string): string {
|
|
528
|
+
// ARV-372/ARV-369 follow-up: strip trailing version segments (`v30`)
|
|
529
|
+
// before scanning backward, same as the CRUD-group resource-name
|
|
530
|
+
// derivation in suite-generator.ts — otherwise list-only resources on a
|
|
531
|
+
// spec shaped `/api/<resource>/v{N}` all collapse to "v30".
|
|
532
|
+
const segs = stripTrailingVersionSegments(pathStripSlash(listPath).split("/").filter(Boolean));
|
|
533
|
+
for (let i = segs.length - 1; i >= 0; i--) {
|
|
534
|
+
if (!isParamSeg(segs[i])) return segs[i]!;
|
|
535
|
+
}
|
|
536
|
+
return "resource";
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Body-FK fallback. Used only when a body field's name doesn't appear
|
|
541
|
+
* as a path-param anywhere (so the structural resolver has nothing to
|
|
542
|
+
* say). Cheap heuristic — kept narrow on purpose.
|
|
543
|
+
*/
|
|
544
|
+
function inferFkOwnerByName(paramName: string, allResources: string[]): string | null {
|
|
545
|
+
const stem = paramName
|
|
546
|
+
.replace(/_id_or_slug$|_id_or_name$|_or_slug$|_or_name$/, "")
|
|
547
|
+
.replace(/_id$|Id$|_uuid$|_slug$/, "")
|
|
548
|
+
.toLowerCase();
|
|
549
|
+
if (!stem) return null;
|
|
550
|
+
for (const res of allResources) {
|
|
551
|
+
const r = res.toLowerCase();
|
|
552
|
+
if (r === stem || r === `${stem}s` || `${r}s` === stem || r.replace(/s$/, "") === stem) {
|
|
553
|
+
return res;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
return null;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function collectPathFkDeps(
|
|
560
|
+
basePath: string,
|
|
561
|
+
idParam: string,
|
|
562
|
+
ownerListPaths: Map<string, string>,
|
|
563
|
+
resourceByListPath: Map<string, string>,
|
|
564
|
+
): ResourceFkRef[] {
|
|
565
|
+
const deps: ResourceFkRef[] = [];
|
|
566
|
+
const seen = new Set<string>();
|
|
567
|
+
const pathParamRe = /\{([^}]+)\}/g;
|
|
568
|
+
let match: RegExpExecArray | null;
|
|
569
|
+
while ((match = pathParamRe.exec(basePath)) !== null) {
|
|
570
|
+
const param = match[1]!;
|
|
571
|
+
if (param === idParam) continue;
|
|
572
|
+
if (seen.has(param)) continue;
|
|
573
|
+
seen.add(param);
|
|
574
|
+
const listPath = ownerListPaths.get(param);
|
|
575
|
+
const ownerResource = listPath ? (resourceByListPath.get(listPath) ?? null) : null;
|
|
576
|
+
deps.push({ var: param, param, in: "path", ownerResource });
|
|
577
|
+
}
|
|
578
|
+
return deps;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function collectBodyFkDeps(
|
|
582
|
+
group: CrudGroup,
|
|
583
|
+
ownerListPaths: Map<string, string>,
|
|
584
|
+
resourceByListPath: Map<string, string>,
|
|
585
|
+
allResources: string[],
|
|
586
|
+
): ResourceFkRef[] {
|
|
587
|
+
const deps: ResourceFkRef[] = [];
|
|
588
|
+
if (!group.create?.requestBodySchema) return deps;
|
|
589
|
+
const schema = group.create.requestBodySchema as OpenAPIV3.SchemaObject;
|
|
590
|
+
const props = (schema.properties ?? {}) as Record<string, OpenAPIV3.SchemaObject>;
|
|
591
|
+
const required = new Set(schema.required ?? []);
|
|
592
|
+
for (const [name] of Object.entries(props)) {
|
|
593
|
+
if (!/_id$|Id$|_uuid$/.test(name)) continue;
|
|
594
|
+
if (!required.has(name)) continue;
|
|
595
|
+
// Try structural resolution first (the body field name often matches a
|
|
596
|
+
// path-param elsewhere — `audience_id` body field, `audience_id` path
|
|
597
|
+
// param both point at /audiences/). Fall back to name-stemming.
|
|
598
|
+
let ownerResource: string | null = null;
|
|
599
|
+
const listPath = ownerListPaths.get(name);
|
|
600
|
+
if (listPath) ownerResource = resourceByListPath.get(listPath) ?? null;
|
|
601
|
+
if (!ownerResource) ownerResource = inferFkOwnerByName(name, allResources);
|
|
602
|
+
deps.push({ var: name, param: name, in: "body", ownerResource });
|
|
603
|
+
}
|
|
604
|
+
return deps;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
export interface BuildResourcesParams {
|
|
608
|
+
endpoints: EndpointInfo[];
|
|
609
|
+
specHash: string;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
export function buildApiResourceMap(params: BuildResourcesParams): ApiResourceMap {
|
|
613
|
+
const groups = disambiguateResourceCollisions(detectCrudGroups(params.endpoints));
|
|
614
|
+
const ownerListPaths = resolveOwnerListPaths(params.endpoints);
|
|
615
|
+
|
|
616
|
+
// ARV-134: reverse-index ownerListPaths so implicit resources can fall
|
|
617
|
+
// back to the FK param name when no direct GET-by-id companion exists.
|
|
618
|
+
const paramsByListPath = new Map<string, string[]>();
|
|
619
|
+
for (const [param, listPath] of ownerListPaths) {
|
|
620
|
+
const arr = paramsByListPath.get(listPath) ?? [];
|
|
621
|
+
arr.push(param);
|
|
622
|
+
paramsByListPath.set(listPath, arr);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Index CRUD-group list paths by normalised path so the FK resolver can
|
|
626
|
+
// hand back the resource name a structural lookup pointed at.
|
|
627
|
+
const resourceByListPath = new Map<string, string>();
|
|
628
|
+
for (const g of groups) {
|
|
629
|
+
if (g.list) resourceByListPath.set(pathStripSlash(g.list.path), g.resource);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Imp resources: any list path that path-FKs point at structurally but
|
|
633
|
+
// no CRUD group claims (top-level GET-only collections like
|
|
634
|
+
// `/api/0/organizations/`, nested list-only collections, etc.). Without
|
|
635
|
+
// these, every FK that depends on a non-CRUD parent ends up with
|
|
636
|
+
// `ownerResource: null` and `discover` skips them — the actual root
|
|
637
|
+
// cause of the "discover --apply is a no-op" symptom.
|
|
638
|
+
const implicitResources: ApiResourceEntry[] = [];
|
|
639
|
+
const seenImplicit = new Set<string>();
|
|
640
|
+
for (const [, listPath] of ownerListPaths) {
|
|
641
|
+
if (resourceByListPath.has(listPath)) continue;
|
|
642
|
+
if (seenImplicit.has(listPath)) continue;
|
|
643
|
+
seenImplicit.add(listPath);
|
|
644
|
+
const listEp = params.endpoints.find(
|
|
645
|
+
e =>
|
|
646
|
+
e.method.toUpperCase() === "GET" &&
|
|
647
|
+
!e.deprecated &&
|
|
648
|
+
pathStripSlash(e.path) === listPath,
|
|
649
|
+
);
|
|
650
|
+
if (!listEp) continue;
|
|
651
|
+
const name = listPathToResourceName(listPath);
|
|
652
|
+
|
|
653
|
+
// ARV-134: try to recover idParam + itemPath from a GET-by-id companion
|
|
654
|
+
// (e.g. implicit `logs` at `/logs` + `GET /logs/{log_id}` → log_id).
|
|
655
|
+
// Falls back to the reverse-indexed ownerListPaths param when no direct
|
|
656
|
+
// item endpoint exists — preferring a name that matches the resource's
|
|
657
|
+
// singular form so e.g. `attachment_id` wins over a sibling FK that
|
|
658
|
+
// happens to point at the same list path.
|
|
659
|
+
const itemEp = findItemEndpointForListPath(listPath, params.endpoints);
|
|
660
|
+
let idParam = "";
|
|
661
|
+
let itemPath = "";
|
|
662
|
+
const endpoints: ApiResourceEntry["endpoints"] = { list: epLabel(listEp) };
|
|
663
|
+
if (itemEp) {
|
|
664
|
+
const m = pathStripSlash(itemEp.path).match(/\{([^}]+)\}\/?$/);
|
|
665
|
+
if (m) {
|
|
666
|
+
idParam = m[1]!;
|
|
667
|
+
itemPath = itemEp.path;
|
|
668
|
+
endpoints.read = epLabel(itemEp);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
if (!idParam) {
|
|
672
|
+
const candidates = paramsByListPath.get(listPath) ?? [];
|
|
673
|
+
if (candidates.length > 0) {
|
|
674
|
+
const singular = singularizeResource(name).toLowerCase();
|
|
675
|
+
const preferred = candidates.find(p => {
|
|
676
|
+
const lower = p.toLowerCase();
|
|
677
|
+
return lower === singular || lower.startsWith(`${singular}_`);
|
|
678
|
+
});
|
|
679
|
+
idParam = preferred ?? candidates[0]!;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
implicitResources.push({
|
|
684
|
+
resource: name,
|
|
685
|
+
basePath: listPath,
|
|
686
|
+
itemPath,
|
|
687
|
+
idParam,
|
|
688
|
+
captureField: "id",
|
|
689
|
+
hasFullCrud: false,
|
|
690
|
+
endpoints,
|
|
691
|
+
fkDependencies: [],
|
|
692
|
+
});
|
|
693
|
+
resourceByListPath.set(listPath, name);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const resourceNames = [
|
|
697
|
+
...groups.map(g => g.resource),
|
|
698
|
+
...implicitResources.map(r => r.resource),
|
|
699
|
+
];
|
|
700
|
+
|
|
701
|
+
const crudResources: ApiResourceEntry[] = groups.map(g => {
|
|
702
|
+
const captureField = g.create ? getCaptureField(g.create) : "id";
|
|
703
|
+
const requiresEtag = !!(g.update?.requiresEtag || g.delete?.requiresEtag);
|
|
704
|
+
return {
|
|
705
|
+
resource: g.resource,
|
|
706
|
+
basePath: g.basePath,
|
|
707
|
+
itemPath: g.itemPath,
|
|
708
|
+
idParam: g.idParam,
|
|
709
|
+
captureField,
|
|
710
|
+
hasFullCrud: !!(g.list && g.create && g.read),
|
|
711
|
+
endpoints: {
|
|
712
|
+
...(g.list ? { list: epLabel(g.list) } : {}),
|
|
713
|
+
...(g.create ? { create: epLabel(g.create) } : {}),
|
|
714
|
+
...(g.read ? { read: epLabel(g.read) } : {}),
|
|
715
|
+
...(g.update ? { update: epLabel(g.update) } : {}),
|
|
716
|
+
...(g.delete ? { delete: epLabel(g.delete) } : {}),
|
|
717
|
+
},
|
|
718
|
+
...(requiresEtag ? { requiresEtag: true } : {}),
|
|
719
|
+
fkDependencies: [
|
|
720
|
+
...collectPathFkDeps(g.basePath, g.idParam, ownerListPaths, resourceByListPath),
|
|
721
|
+
...collectBodyFkDeps(g, ownerListPaths, resourceByListPath, resourceNames),
|
|
722
|
+
],
|
|
723
|
+
};
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
// Implicit resources also chain — `/orgs/{org}/projects/` lists projects
|
|
727
|
+
// but needs `organization_id_or_slug` set to call. Surface that so
|
|
728
|
+
// `discover` knows to fetch the parent first.
|
|
729
|
+
for (const r of implicitResources) {
|
|
730
|
+
r.fkDependencies = collectPathFkDeps(r.basePath, "", ownerListPaths, resourceByListPath);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
// ARV-134 (follow-up): the early disambiguation pass only operated on
|
|
735
|
+
// CRUD groups, but collisions also fire CRUD-vs-implicit (`/repos/
|
|
736
|
+
// {owner}/{repo}/check-runs` is CRUD, `/repos/{owner}/{repo}/commits/
|
|
737
|
+
// {ref}/check-runs` is implicit-list-only — both end up named
|
|
738
|
+
// `check-runs`). Run the same prefix-rename here on the combined list
|
|
739
|
+
// so the final yaml never carries duplicate `resource:` lines.
|
|
740
|
+
const resources = disambiguateEntryCollisions([...crudResources, ...implicitResources]);
|
|
741
|
+
|
|
742
|
+
// ARV-376: surface a resource's OWN secondary lookup keys (e.g. the
|
|
743
|
+
// `/{code}` read-by-code param, or a `/byid/{id}` param that isn't the
|
|
744
|
+
// canonical idParam) as fillable targets. resolveOwnerListPaths already
|
|
745
|
+
// links each such param to this resource's `/list` endpoint; expose it as a
|
|
746
|
+
// self-referential fkDependency so prepare-fixtures reports a candidate
|
|
747
|
+
// (GET the list, pick a value) instead of `miss-no-list`. Runs after
|
|
748
|
+
// disambiguateEntryCollisions so it keys off the FINAL unique resource
|
|
749
|
+
// names (pre-disambig implicit names collide on `list`).
|
|
750
|
+
const byOwnListPath = new Map<string, ApiResourceEntry>();
|
|
751
|
+
for (const r of resources) {
|
|
752
|
+
const label = r.endpoints.list;
|
|
753
|
+
if (!label) continue;
|
|
754
|
+
byOwnListPath.set(pathStripSlash(label.slice(label.indexOf(" ") + 1)), r);
|
|
755
|
+
}
|
|
756
|
+
for (const [param, listPath] of ownerListPaths) {
|
|
757
|
+
const r = byOwnListPath.get(pathStripSlash(listPath));
|
|
758
|
+
if (!r || param === r.idParam) continue;
|
|
759
|
+
if ((r.fkDependencies ?? []).some(d => d.var === param)) continue;
|
|
760
|
+
r.fkDependencies.push({ var: param, param, in: "path", ownerResource: r.resource });
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// Endpoints that aren't in any CRUD group — RPC-style actions, webhook
|
|
764
|
+
// accept-only routes, etc. Implicit-list endpoints stay in orphans
|
|
765
|
+
// because they're not full CRUD; they're surfaced through resources for
|
|
766
|
+
// discovery purposes only.
|
|
767
|
+
const claimedEps = new Set<string>();
|
|
768
|
+
for (const g of groups) {
|
|
769
|
+
if (g.list) claimedEps.add(epLabel(g.list));
|
|
770
|
+
if (g.create) claimedEps.add(epLabel(g.create));
|
|
771
|
+
if (g.read) claimedEps.add(epLabel(g.read));
|
|
772
|
+
if (g.update) claimedEps.add(epLabel(g.update));
|
|
773
|
+
if (g.delete) claimedEps.add(epLabel(g.delete));
|
|
774
|
+
}
|
|
775
|
+
const orphanEndpoints = params.endpoints
|
|
776
|
+
.filter(ep => !claimedEps.has(epLabel(ep)))
|
|
777
|
+
.map(epLabel);
|
|
778
|
+
|
|
779
|
+
return {
|
|
780
|
+
generatedAt: new Date().toISOString(),
|
|
781
|
+
specHash: params.specHash,
|
|
782
|
+
resourceCount: resources.length,
|
|
783
|
+
resources,
|
|
784
|
+
orphanEndpoints,
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// ── YAML serialization (minimal, no dep on yaml lib for the workspace) ──
|
|
789
|
+
|
|
790
|
+
function escape(s: string): string {
|
|
791
|
+
if (/[:#\[\]{}&*!|>'"@`,%]/.test(s) || s.includes("\n") || s === "") {
|
|
792
|
+
return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
793
|
+
}
|
|
794
|
+
return s;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
export function serializeApiResourceMap(m: ApiResourceMap): string {
|
|
798
|
+
const lines: string[] = [];
|
|
799
|
+
lines.push("# Auto-generated by zond. Do not edit by hand.");
|
|
800
|
+
lines.push("# Regenerate via: zond refresh-api <name>");
|
|
801
|
+
lines.push(`generatedAt: ${escape(m.generatedAt)}`);
|
|
802
|
+
lines.push(`specHash: ${escape(m.specHash)}`);
|
|
803
|
+
lines.push(`resourceCount: ${m.resourceCount}`);
|
|
804
|
+
if (m.resources.length === 0) {
|
|
805
|
+
lines.push("resources: []");
|
|
806
|
+
} else {
|
|
807
|
+
lines.push("resources:");
|
|
808
|
+
}
|
|
809
|
+
for (const r of m.resources) {
|
|
810
|
+
lines.push(` - resource: ${escape(r.resource)}`);
|
|
811
|
+
lines.push(` basePath: ${escape(r.basePath)}`);
|
|
812
|
+
lines.push(` itemPath: ${escape(r.itemPath)}`);
|
|
813
|
+
lines.push(` idParam: ${escape(r.idParam)}`);
|
|
814
|
+
lines.push(` captureField: ${escape(r.captureField)}`);
|
|
815
|
+
lines.push(` hasFullCrud: ${r.hasFullCrud}`);
|
|
816
|
+
if (r.requiresEtag) lines.push(` requiresEtag: true`);
|
|
817
|
+
lines.push(` endpoints:`);
|
|
818
|
+
for (const [k, v] of Object.entries(r.endpoints)) {
|
|
819
|
+
lines.push(` ${k}: ${escape(v as string)}`);
|
|
820
|
+
}
|
|
821
|
+
if (r.fkDependencies.length === 0) {
|
|
822
|
+
lines.push(` fkDependencies: []`);
|
|
823
|
+
} else {
|
|
824
|
+
lines.push(` fkDependencies:`);
|
|
825
|
+
for (const d of r.fkDependencies) {
|
|
826
|
+
lines.push(` - var: ${escape(d.var)}`);
|
|
827
|
+
lines.push(` param: ${escape(d.param)}`);
|
|
828
|
+
lines.push(` in: ${d.in}`);
|
|
829
|
+
lines.push(` ownerResource: ${d.ownerResource ? escape(d.ownerResource) : "null"}`);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
if (r.readbackDiff) {
|
|
833
|
+
lines.push(` readback_diff:`);
|
|
834
|
+
const ig = r.readbackDiff.ignoreFields ?? [];
|
|
835
|
+
if (ig.length > 0) {
|
|
836
|
+
lines.push(` ignore_fields:`);
|
|
837
|
+
for (const f of ig) lines.push(` - ${escape(f)}`);
|
|
838
|
+
}
|
|
839
|
+
const map = r.readbackDiff.writeToReadMap ?? {};
|
|
840
|
+
const mapKeys = Object.keys(map);
|
|
841
|
+
if (mapKeys.length > 0) {
|
|
842
|
+
lines.push(` write_to_read_map:`);
|
|
843
|
+
for (const k of mapKeys) lines.push(` ${escape(k)}: ${escape(map[k]!)}`);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
if (r.idempotency) {
|
|
847
|
+
lines.push(` idempotency:`);
|
|
848
|
+
if (r.idempotency.header) lines.push(` header: ${escape(r.idempotency.header)}`);
|
|
849
|
+
if (r.idempotency.scope) lines.push(` scope: ${r.idempotency.scope}`);
|
|
850
|
+
const ig = r.idempotency.ignoreResponseFields ?? [];
|
|
851
|
+
if (ig.length > 0) {
|
|
852
|
+
lines.push(` ignore_response_fields:`);
|
|
853
|
+
for (const f of ig) lines.push(` - ${escape(f)}`);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
if (r.pagination) {
|
|
857
|
+
lines.push(` pagination:`);
|
|
858
|
+
if (r.pagination.type) lines.push(` type: ${r.pagination.type}`);
|
|
859
|
+
if (r.pagination.cursorParam) lines.push(` cursor_param: ${escape(r.pagination.cursorParam)}`);
|
|
860
|
+
if (r.pagination.cursorField) lines.push(` cursor_field: ${escape(r.pagination.cursorField)}`);
|
|
861
|
+
if (r.pagination.hasMoreField) lines.push(` has_more_field: ${escape(r.pagination.hasMoreField)}`);
|
|
862
|
+
if (r.pagination.limitParam) lines.push(` limit_param: ${escape(r.pagination.limitParam)}`);
|
|
863
|
+
if (r.pagination.defaultLimit != null) lines.push(` default_limit: ${r.pagination.defaultLimit}`);
|
|
864
|
+
if (r.pagination.itemsField) lines.push(` items_field: ${escape(r.pagination.itemsField)}`);
|
|
865
|
+
if (r.pagination.pageParam) lines.push(` page_param: ${escape(r.pagination.pageParam)}`);
|
|
866
|
+
if (r.pagination.startPage != null) lines.push(` start_page: ${r.pagination.startPage}`);
|
|
867
|
+
}
|
|
868
|
+
if (r.lifecycle) {
|
|
869
|
+
lines.push(` lifecycle:`);
|
|
870
|
+
lines.push(` field: ${escape(r.lifecycle.field)}`);
|
|
871
|
+
lines.push(` states:`);
|
|
872
|
+
for (const s of r.lifecycle.states) lines.push(` - ${escape(s)}`);
|
|
873
|
+
lines.push(` transitions:`);
|
|
874
|
+
for (const t of r.lifecycle.transitions) {
|
|
875
|
+
lines.push(` - from: ${escape(t.from)}`);
|
|
876
|
+
if (t.to.length === 0) {
|
|
877
|
+
lines.push(` to: []`);
|
|
878
|
+
} else {
|
|
879
|
+
lines.push(` to:`);
|
|
880
|
+
for (const to of t.to) lines.push(` - ${escape(to)}`);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
lines.push(` actions:`);
|
|
884
|
+
for (const [name, a] of Object.entries(r.lifecycle.actions)) {
|
|
885
|
+
lines.push(` ${escape(name)}:`);
|
|
886
|
+
lines.push(` endpoint: ${escape(a.endpoint)}`);
|
|
887
|
+
lines.push(` expected_state: ${escape(a.expectedState)}`);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
if (m.orphanEndpoints.length === 0) {
|
|
892
|
+
lines.push("orphanEndpoints: []");
|
|
893
|
+
} else {
|
|
894
|
+
lines.push("orphanEndpoints:");
|
|
895
|
+
for (const e of m.orphanEndpoints) lines.push(` - ${escape(e)}`);
|
|
896
|
+
}
|
|
897
|
+
return lines.join("\n") + "\n";
|
|
898
|
+
}
|