@kirrosh/zond 0.22.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +648 -0
- package/README.md +58 -6
- package/package.json +9 -6
- package/src/cli/argv.ts +122 -0
- package/src/cli/commands/add-api.ts +134 -0
- package/src/cli/commands/api/annotate/idempotency.ts +59 -0
- package/src/cli/commands/api/annotate/index.ts +525 -0
- package/src/cli/commands/api/annotate/lifecycle.ts +74 -0
- package/src/cli/commands/api/annotate/overlay.ts +206 -0
- package/src/cli/commands/api/annotate/pagination.ts +60 -0
- package/src/cli/commands/api/annotate/prompts.ts +183 -0
- package/src/cli/commands/api/annotate/readback.ts +58 -0
- package/src/cli/commands/api/annotate/resources.ts +91 -0
- package/src/cli/commands/api/annotate/seed-bodies.ts +61 -0
- package/src/cli/commands/audit.ts +480 -0
- package/src/cli/commands/bootstrap.ts +710 -0
- package/src/cli/commands/catalog.ts +35 -0
- package/src/cli/commands/check.ts +348 -0
- package/src/cli/commands/checks.ts +756 -0
- package/src/cli/commands/ci-init.ts +43 -0
- package/src/cli/commands/clean.ts +212 -0
- package/src/cli/commands/cleanup.ts +262 -0
- package/src/cli/commands/completions.ts +16 -0
- package/src/cli/commands/coverage.ts +605 -132
- package/src/cli/commands/db.ts +178 -7
- package/src/cli/commands/describe.ts +37 -2
- package/src/cli/commands/discover.ts +1236 -0
- package/src/cli/commands/doctor.ts +607 -0
- package/src/cli/commands/fixtures.ts +402 -0
- package/src/cli/commands/generate.ts +420 -46
- package/src/cli/commands/init/bootstrap.ts +30 -1
- package/src/cli/commands/{init.ts → init/index.ts} +99 -5
- package/src/cli/commands/init/skills.ts +56 -3
- package/src/cli/commands/init/templates/agents.md +65 -61
- package/src/cli/commands/init/templates/skills/zond-checks.md +397 -0
- package/src/cli/commands/init/templates/skills/zond-triage.md +210 -0
- package/src/cli/commands/init/templates/skills/zond.md +592 -125
- package/src/cli/commands/init/templates/zond-config.yml +8 -9
- package/src/cli/commands/prepare-fixtures.ts +135 -0
- package/src/cli/commands/probe/mass-assignment.ts +503 -0
- package/src/cli/commands/probe/security.ts +454 -0
- package/src/cli/commands/probe/static.ts +255 -0
- package/src/cli/commands/probe/webhooks.ts +161 -0
- package/src/cli/commands/probe.ts +459 -0
- package/src/cli/commands/reference.ts +87 -0
- package/src/cli/commands/refresh-api.ts +169 -0
- package/src/cli/commands/remove-api.ts +150 -0
- package/src/cli/commands/report-bundle.ts +318 -0
- package/src/cli/commands/report.ts +241 -0
- package/src/cli/commands/request.ts +379 -4
- package/src/cli/commands/run.ts +842 -53
- package/src/cli/commands/session.ts +244 -0
- package/src/cli/commands/use.ts +18 -1
- package/src/cli/index.ts +20 -3
- package/src/cli/json-envelope.ts +112 -3
- package/src/cli/json-schemas.ts +263 -0
- package/src/cli/program.ts +198 -635
- package/src/cli/resolve.ts +105 -0
- package/src/cli/status-filter.ts +124 -0
- package/src/cli/util/api-context.ts +85 -0
- package/src/cli/version.ts +5 -0
- package/src/core/anti-fp/bootstrap.ts +34 -0
- package/src/core/anti-fp/index.ts +33 -0
- package/src/core/anti-fp/registry.ts +44 -0
- package/src/core/anti-fp/rules/baseline-echo.ts +74 -0
- package/src/core/anti-fp/rules/schemathesis/body_negation_becomes_valid.ts +52 -0
- package/src/core/anti-fp/rules/schemathesis/coverage_phase_boundary_positive.ts +38 -0
- package/src/core/anti-fp/rules/schemathesis/has_unverifiable_mutations.ts +35 -0
- package/src/core/anti-fp/rules/schemathesis/index.ts +24 -0
- package/src/core/anti-fp/rules/schemathesis/string_type_mutation_becomes_valid.ts +53 -0
- package/src/core/anti-fp/rules/subscription-gated/index.ts +11 -0
- package/src/core/anti-fp/rules/subscription-gated/paid-plan-403.ts +75 -0
- package/src/core/anti-fp/types.ts +68 -0
- package/src/core/checks/checks/_crud-helpers.ts +133 -0
- package/src/core/checks/checks/_negative_mutator.ts +133 -0
- package/src/core/checks/checks/_readback-helpers.ts +133 -0
- package/src/core/checks/checks/content_type_conformance.ts +39 -0
- package/src/core/checks/checks/cross_call_references.ts +134 -0
- package/src/core/checks/checks/ensure_resource_availability.ts +62 -0
- package/src/core/checks/checks/idempotency_replay.ts +246 -0
- package/src/core/checks/checks/ignored_auth.ts +211 -0
- package/src/core/checks/checks/index.ts +65 -0
- package/src/core/checks/checks/lifecycle_transitions.ts +273 -0
- package/src/core/checks/checks/missing_required_header.ts +40 -0
- package/src/core/checks/checks/negative_data_rejection.ts +45 -0
- package/src/core/checks/checks/not_a_server_error.ts +27 -0
- package/src/core/checks/checks/open_cors_on_sensitive.ts +131 -0
- package/src/core/checks/checks/pagination_invariants.ts +238 -0
- package/src/core/checks/checks/positive_data_acceptance.ts +36 -0
- package/src/core/checks/checks/rate_limit_headers_absent.ts +77 -0
- package/src/core/checks/checks/response_headers_conformance.ts +74 -0
- package/src/core/checks/checks/response_schema_conformance.ts +30 -0
- package/src/core/checks/checks/status_code_conformance.ts +61 -0
- package/src/core/checks/checks/unsupported_method.ts +63 -0
- package/src/core/checks/checks/use_after_free.ts +78 -0
- package/src/core/checks/index.ts +30 -0
- package/src/core/checks/mode.ts +79 -0
- package/src/core/checks/recommended-action.ts +64 -0
- package/src/core/checks/registry.ts +78 -0
- package/src/core/checks/runner.ts +874 -0
- package/src/core/checks/sarif.ts +230 -0
- package/src/core/checks/stateful.ts +121 -0
- package/src/core/checks/types.ts +189 -0
- package/src/core/classifier/recommended-action.ts +222 -0
- package/src/core/context/current.ts +22 -6
- package/src/core/context/session.ts +78 -0
- package/src/core/coverage/loader.ts +185 -0
- package/src/core/coverage/reasons.ts +300 -0
- package/src/core/diagnostics/db-analysis.ts +151 -11
- package/src/core/diagnostics/failure-class.ts +120 -0
- package/src/core/diagnostics/failure-hints.ts +212 -9
- package/src/core/diagnostics/spec-pointer.ts +99 -0
- package/src/core/diagnostics/suggested-fixes.ts +156 -0
- package/src/core/exporter/case-study/index.ts +270 -0
- package/src/core/exporter/curl.ts +40 -0
- package/src/core/exporter/exporter.ts +48 -0
- package/src/core/exporter/html-report/escape.ts +24 -0
- package/src/core/exporter/html-report/index.ts +479 -0
- package/src/core/exporter/html-report/script.ts +100 -0
- package/src/core/exporter/html-report/styles.ts +408 -0
- package/src/core/generator/chunker.ts +42 -16
- package/src/core/generator/coverage-phase.ts +0 -0
- package/src/core/generator/create-body.ts +89 -0
- package/src/core/generator/data-factory.ts +445 -19
- package/src/core/generator/describe.ts +1 -1
- package/src/core/generator/fixtures-builder.ts +325 -0
- package/src/core/generator/index.ts +7 -5
- package/src/core/generator/openapi-reader.ts +37 -3
- package/src/core/generator/path-param-disambig.ts +114 -0
- package/src/core/generator/resources-builder.ts +648 -0
- package/src/core/generator/schema-utils.ts +11 -3
- package/src/core/generator/serializer.ts +103 -13
- package/src/core/generator/suite-generator.ts +419 -111
- package/src/core/generator/types.ts +8 -0
- package/src/core/identity/identity-file.ts +129 -0
- package/src/core/lint/affects.ts +28 -0
- package/src/core/lint/config.ts +96 -0
- package/src/core/lint/format.ts +42 -0
- package/src/core/lint/index.ts +94 -0
- package/src/core/lint/reporter.ts +128 -0
- package/src/core/lint/rules/consistency.ts +158 -0
- package/src/core/lint/rules/heuristics.ts +97 -0
- package/src/core/lint/rules/strictness.ts +109 -0
- package/src/core/lint/types.ts +96 -0
- package/src/core/lint/walker.ts +248 -0
- package/src/core/meta/meta-store.ts +6 -73
- package/src/core/output/README.md +91 -0
- package/src/core/output/index.ts +13 -0
- package/src/core/output/run.ts +126 -0
- package/src/core/output/types.ts +129 -0
- package/src/core/parser/env-interpolation.ts +104 -0
- package/src/core/parser/filter.ts +57 -0
- package/src/core/parser/schema.ts +129 -4
- package/src/core/parser/types.ts +19 -1
- package/src/core/parser/variables.ts +0 -0
- package/src/core/parser/yaml-parser.ts +58 -12
- package/src/core/probe/bootstrap.ts +34 -0
- package/src/core/probe/dry-run-envelope.ts +57 -0
- package/src/core/probe/mass-assignment-probe-class.ts +198 -0
- package/src/core/probe/mass-assignment-probe.ts +1122 -0
- package/src/core/probe/mass-assignment-template.ts +212 -0
- package/src/core/probe/method-probe.ts +43 -76
- package/src/core/probe/method-shared.ts +69 -0
- package/src/core/probe/negative-probe.ts +183 -149
- package/src/core/probe/orphan-tracker.ts +188 -0
- package/src/core/probe/path-discovery.ts +440 -0
- package/src/core/probe/probe-harness.ts +120 -0
- package/src/core/probe/registry.ts +89 -0
- package/src/core/probe/runner.ts +136 -0
- package/src/core/probe/security-probe-class.ts +201 -0
- package/src/core/probe/security-probe.ts +1453 -0
- package/src/core/probe/shared.ts +505 -0
- package/src/core/probe/static-probe-class.ts +125 -0
- package/src/core/probe/types.ts +165 -0
- package/src/core/probe/verdict-aggregator.ts +33 -0
- package/src/core/probe/webhooks-probe.ts +284 -0
- package/src/core/reporter/console.ts +41 -2
- package/src/core/reporter/index.ts +2 -3
- package/src/core/reporter/json.ts +11 -1
- package/src/core/reporter/junit.ts +27 -12
- package/src/core/reporter/ndjson.ts +37 -0
- package/src/core/reporter/types.ts +3 -0
- package/src/core/runner/assertions.ts +58 -1
- package/src/core/runner/async-pool.ts +108 -0
- package/src/core/runner/auth-path.ts +8 -0
- package/src/core/runner/ci-context.ts +72 -0
- package/src/core/runner/executor.ts +264 -20
- package/src/core/runner/form-encode.ts +51 -0
- package/src/core/runner/http-client.ts +75 -2
- package/src/core/runner/learn-drift.ts +293 -0
- package/src/core/runner/preflight-vars.ts +149 -0
- package/src/core/runner/progress-tracker.ts +73 -0
- package/src/core/runner/rate-limiter.ts +89 -17
- package/src/core/runner/run-kind.ts +39 -0
- package/src/core/runner/schema-validator.ts +312 -0
- package/src/core/runner/send-request.ts +153 -20
- package/src/core/runner/types.ts +38 -0
- package/src/core/secrets/registry.ts +164 -0
- package/src/core/secrets/secrets-file.ts +115 -0
- package/src/core/selectors/operation-filter.ts +144 -0
- package/src/core/setup-api.ts +415 -16
- package/src/core/severity/category.ts +94 -0
- package/src/core/severity/index.ts +121 -0
- package/src/core/spec/layers.ts +154 -0
- package/src/core/util/format-eta.ts +21 -0
- package/src/core/utils.ts +5 -1
- package/src/core/workspace/config.ts +129 -0
- package/src/core/workspace/manifest.ts +283 -0
- package/src/core/workspace/output-rotation.ts +62 -0
- package/src/core/workspace/triage-path.ts +87 -0
- package/src/db/lint-runs.ts +47 -0
- package/src/db/migrate.ts +126 -0
- package/src/db/migrations/0001_run_kind.sql +25 -0
- package/src/db/migrations/sql.d.ts +4 -0
- package/src/db/queries/collections.ts +133 -0
- package/src/db/queries/coverage.ts +9 -0
- package/src/db/queries/dashboard.ts +59 -0
- package/src/db/queries/results.ts +128 -0
- package/src/db/queries/runs.ts +235 -0
- package/src/db/queries/sessions.ts +42 -0
- package/src/db/queries/settings.ts +28 -0
- package/src/db/queries/types.ts +172 -0
- package/src/db/queries.ts +72 -802
- package/src/db/schema.ts +178 -50
- package/src/cli/commands/export.ts +0 -144
- package/src/cli/commands/guide.ts +0 -127
- package/src/cli/commands/init/templates/skills/scenarios.md +0 -97
- package/src/cli/commands/probe-methods.ts +0 -108
- package/src/cli/commands/probe-validation.ts +0 -124
- package/src/cli/commands/serve.ts +0 -114
- package/src/cli/commands/sync.ts +0 -268
- package/src/cli/commands/update.ts +0 -189
- package/src/cli/commands/validate.ts +0 -34
- package/src/core/diagnostics/render-md.ts +0 -112
- package/src/core/exporter/postman.ts +0 -963
- package/src/core/generator/guide-builder.ts +0 -253
- package/src/core/meta/types.ts +0 -19
- package/src/core/parser/index.ts +0 -21
- package/src/core/runner/execute-run.ts +0 -132
- package/src/core/runner/index.ts +0 -12
- package/src/core/sync/spec-differ.ts +0 -38
- package/src/web/data/collection-state.ts +0 -362
- package/src/web/routes/api.ts +0 -314
- package/src/web/routes/dashboard.ts +0 -350
- package/src/web/routes/runs.ts +0 -64
- package/src/web/schemas.ts +0 -121
- package/src/web/server.ts +0 -134
- package/src/web/static/htmx.min.cjs +0 -1
- package/src/web/static/style.css +0 -1148
- package/src/web/views/endpoints-tab.ts +0 -174
- package/src/web/views/explorer-tab.ts +0 -402
- package/src/web/views/health-strip.ts +0 -92
- package/src/web/views/layout.ts +0 -48
- package/src/web/views/results.ts +0 -210
- package/src/web/views/runs-tab.ts +0 -126
- package/src/web/views/suites-tab.ts +0 -181
|
@@ -0,0 +1,648 @@
|
|
|
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 } 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/GitHub style: caller passes a cursor (e.g.
|
|
82
|
+
* `starting_after=<id>`) derived from the last item of the
|
|
83
|
+
* previous page. The check is built around this pattern.
|
|
84
|
+
* • `page` and `offset` — declared for forward compatibility; the
|
|
85
|
+
* check currently skips with a "pagination type not implemented"
|
|
86
|
+
* reason so the yaml block stays a stable schema.
|
|
87
|
+
*
|
|
88
|
+
* Auto-detect fallback: if the list endpoint declares `starting_after`
|
|
89
|
+
* / `cursor` / `page_token` query parameters in the spec, the check
|
|
90
|
+
* uses sensible defaults (cursor_field=`id`, items_field=`data` →
|
|
91
|
+
* `items` → `results`, has_more_field=`has_more`). Explicit yaml is
|
|
92
|
+
* preferred — it documents intent and survives spec changes that
|
|
93
|
+
* rename query params.
|
|
94
|
+
*/
|
|
95
|
+
/**
|
|
96
|
+
* ARV-172 (m-20 lifecycle-transitions): declared state machine for a
|
|
97
|
+
* resource. Used by the `lifecycle_transitions` stateful check to
|
|
98
|
+
* verify that documented actions (cancel / archive / publish / ...)
|
|
99
|
+
* move a resource between declared states and that double-invoking an
|
|
100
|
+
* action either 4xx's or stays idempotent (no state regression).
|
|
101
|
+
*
|
|
102
|
+
* The yaml block has three parts:
|
|
103
|
+
* • `field` + `states` — name of the response field carrying the
|
|
104
|
+
* state, plus the closed enum of legal values.
|
|
105
|
+
* • `transitions` — a from→to graph; the check uses it to flag
|
|
106
|
+
* forbidden transitions (cancelled → active) when an action lands
|
|
107
|
+
* a resource somewhere the graph doesn't allow.
|
|
108
|
+
* • `actions` — POST endpoints that should drive a transition.
|
|
109
|
+
* `expected_state` is the state the resource must be in after a
|
|
110
|
+
* successful action call.
|
|
111
|
+
*
|
|
112
|
+
* Manifest validation runs at load time and surfaces obvious
|
|
113
|
+
* authoring bugs (unreachable states, missing terminal, action
|
|
114
|
+
* referencing an undeclared state) before any HTTP call goes out.
|
|
115
|
+
*/
|
|
116
|
+
export interface LifecycleAction {
|
|
117
|
+
/** Endpoint label, e.g. "POST /v1/subscriptions/{id}/cancel". The
|
|
118
|
+
* `{id}` placeholder is substituted with the created resource id. */
|
|
119
|
+
endpoint: string;
|
|
120
|
+
/** State the resource must be in after this action lands. */
|
|
121
|
+
expectedState: string;
|
|
122
|
+
/** Optional request body sent with the action POST. Most lifecycle
|
|
123
|
+
* actions are body-less (cancel, archive, publish); leave empty
|
|
124
|
+
* when not needed. Serialised as JSON or form depending on the
|
|
125
|
+
* endpoint's declared content type. */
|
|
126
|
+
body?: Record<string, unknown>;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface LifecycleConfig {
|
|
130
|
+
/** Response field name carrying the state (e.g. `status`). */
|
|
131
|
+
field: string;
|
|
132
|
+
/** Closed enum of legal state values. Any state observed on the
|
|
133
|
+
* wire that isn't in this list is a finding. */
|
|
134
|
+
states: string[];
|
|
135
|
+
/** Allowed from→to graph. States not listed as `from` are assumed
|
|
136
|
+
* terminal (no outgoing transition). States not listed as `to` of
|
|
137
|
+
* any transition are starting-only (unreachable post-create). */
|
|
138
|
+
transitions: { from: string; to: string[] }[];
|
|
139
|
+
/** Named actions keyed by action name (cancel / archive / publish).
|
|
140
|
+
* The check runs through them in object-key order. */
|
|
141
|
+
actions: Record<string, LifecycleAction>;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Static validation of a lifecycle manifest. Returns the list of
|
|
146
|
+
* authoring bugs without throwing — callers decide whether to fail
|
|
147
|
+
* the run or just warn. Empty array = clean manifest.
|
|
148
|
+
*/
|
|
149
|
+
export function validateLifecycleManifest(cfg: LifecycleConfig): string[] {
|
|
150
|
+
const errors: string[] = [];
|
|
151
|
+
if (!cfg.field || cfg.field.length === 0) errors.push("lifecycle.field is empty");
|
|
152
|
+
if (!cfg.states || cfg.states.length === 0) errors.push("lifecycle.states is empty");
|
|
153
|
+
const stateSet = new Set(cfg.states ?? []);
|
|
154
|
+
for (const t of cfg.transitions ?? []) {
|
|
155
|
+
if (!stateSet.has(t.from)) errors.push(`transitions: unknown "from" state "${t.from}"`);
|
|
156
|
+
for (const to of t.to) {
|
|
157
|
+
if (!stateSet.has(to)) errors.push(`transitions[${t.from}]: unknown "to" state "${to}"`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// At least one terminal — a state with no outgoing transition (or
|
|
161
|
+
// an explicit `to: []`). A graph with every state having outgoing
|
|
162
|
+
// edges is suspicious (no end-of-life, infinite churn).
|
|
163
|
+
const hasOutgoing = new Set((cfg.transitions ?? []).filter((t) => t.to.length > 0).map((t) => t.from));
|
|
164
|
+
const terminals = (cfg.states ?? []).filter((s) => !hasOutgoing.has(s));
|
|
165
|
+
if (terminals.length === 0) errors.push("no terminal state — every declared state has outgoing transitions");
|
|
166
|
+
// Actions must reference declared states.
|
|
167
|
+
for (const [name, a] of Object.entries(cfg.actions ?? {})) {
|
|
168
|
+
if (!stateSet.has(a.expectedState)) {
|
|
169
|
+
errors.push(`actions.${name}.expected_state "${a.expectedState}" is not in states[]`);
|
|
170
|
+
}
|
|
171
|
+
if (!a.endpoint || a.endpoint.length === 0) {
|
|
172
|
+
errors.push(`actions.${name}.endpoint is empty`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return errors;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export interface PaginationConfig {
|
|
179
|
+
/** Pagination flavor. Default `cursor`. */
|
|
180
|
+
type?: "cursor" | "page" | "offset" | "token";
|
|
181
|
+
/** Query-param name that takes the cursor value. Default `starting_after`. */
|
|
182
|
+
cursorParam?: string;
|
|
183
|
+
/** Response field on each item that becomes the next cursor. Default `id`. */
|
|
184
|
+
cursorField?: string;
|
|
185
|
+
/** Response field that signals "more pages remain". Default `has_more`. */
|
|
186
|
+
hasMoreField?: string;
|
|
187
|
+
/** Query-param name for page size. Default `limit`. */
|
|
188
|
+
limitParam?: string;
|
|
189
|
+
/** Probe page-size. Default 2 (small enough to land two replies fast). */
|
|
190
|
+
defaultLimit?: number;
|
|
191
|
+
/** Response field carrying the array of items. Default `data` (Stripe);
|
|
192
|
+
* falls back to `items` / `results` when missing. */
|
|
193
|
+
itemsField?: string;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** ARV-187: LLM-authored example POST body. Stateful checks prefer this
|
|
197
|
+
* over generateFromSchema(create) when present. */
|
|
198
|
+
export interface SeedBodyConfig {
|
|
199
|
+
/** Defaults to the create endpoint's requestBodyContentType. */
|
|
200
|
+
contentType?: string;
|
|
201
|
+
body: Record<string, unknown>;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export interface ApiResourceEntry {
|
|
205
|
+
resource: string;
|
|
206
|
+
basePath: string;
|
|
207
|
+
itemPath: string;
|
|
208
|
+
idParam: string;
|
|
209
|
+
/** What field on the create response carries the new id (typically `id`). */
|
|
210
|
+
captureField: string;
|
|
211
|
+
/** True when the resource exposes List + Create + Read at minimum. */
|
|
212
|
+
hasFullCrud: boolean;
|
|
213
|
+
endpoints: {
|
|
214
|
+
list?: string; // "GET /audiences"
|
|
215
|
+
create?: string;
|
|
216
|
+
read?: string;
|
|
217
|
+
update?: string;
|
|
218
|
+
delete?: string;
|
|
219
|
+
};
|
|
220
|
+
/** Update/Delete demand If-Match? (heuristic: 412 in spec or ETag in headers). */
|
|
221
|
+
requiresEtag?: boolean;
|
|
222
|
+
/** Heuristic: read-after-delete returns 200 instead of 404 (filled at runtime, default false). */
|
|
223
|
+
softDelete?: boolean;
|
|
224
|
+
/** Other resources whose ids this resource consumes (FK chain). */
|
|
225
|
+
fkDependencies: ResourceFkRef[];
|
|
226
|
+
/** ARV-169: optional cross-call-drift overrides. */
|
|
227
|
+
readbackDiff?: ReadbackDiffConfig;
|
|
228
|
+
/** ARV-170: opt-in idempotency-replay probe. */
|
|
229
|
+
idempotency?: IdempotencyConfig;
|
|
230
|
+
/** ARV-171: pagination-invariants probe. */
|
|
231
|
+
pagination?: PaginationConfig;
|
|
232
|
+
/** ARV-172: state-machine for the resource. */
|
|
233
|
+
lifecycle?: LifecycleConfig;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export interface ApiResourceMap {
|
|
237
|
+
generatedAt: string;
|
|
238
|
+
specHash: string;
|
|
239
|
+
resourceCount: number;
|
|
240
|
+
resources: ApiResourceEntry[];
|
|
241
|
+
/** Endpoints that didn't fit any CRUD group (action endpoints, RPC-style). */
|
|
242
|
+
orphanEndpoints: string[];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function epLabel(ep: EndpointInfo): string {
|
|
246
|
+
return `${ep.method.toUpperCase()} ${ep.path}`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function pathStripSlash(p: string): string {
|
|
250
|
+
return p.length > 1 && p.endsWith("/") ? p.slice(0, -1) : p;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function isParamSeg(seg: string | undefined): boolean {
|
|
254
|
+
return !!seg && /^\{[^}]+\}$/.test(seg);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function getCaptureField(create: EndpointInfo): string {
|
|
258
|
+
// Look at the create endpoint's success response schema for an `id`-ish
|
|
259
|
+
// field. Falls back to "id" — the universal default.
|
|
260
|
+
const success = create.responses.find(r => r.statusCode >= 200 && r.statusCode < 300);
|
|
261
|
+
const schema = success?.schema as OpenAPIV3.SchemaObject | undefined;
|
|
262
|
+
if (schema?.properties) {
|
|
263
|
+
const props = schema.properties as Record<string, OpenAPIV3.SchemaObject>;
|
|
264
|
+
for (const candidate of ["id", "uuid", "key", "code"]) {
|
|
265
|
+
if (props[candidate]) return candidate;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return "id";
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Structurally infer the list-endpoint that owns each path-parameter by
|
|
273
|
+
* walking the actual URL graph in the spec. Beats name-stemming because
|
|
274
|
+
*
|
|
275
|
+
* • `_id_or_slug`, `_or_name`, non-English plurals, weird casing — all
|
|
276
|
+
* transparent: we only look at segment positions, not at param names.
|
|
277
|
+
* • Returns the *exact* GET path to call, not a guessed resource name we
|
|
278
|
+
* later have to hope is wired up correctly.
|
|
279
|
+
* • Two-strategy lookup so it survives both canonical nesting
|
|
280
|
+
* (`/orgs/{org}/projects/{proj}/...` — prev seg `projects` is a list)
|
|
281
|
+
* and common SaaS-style sibling-param chains
|
|
282
|
+
* (`/projects/{org}/{proj}/...` — prev seg is itself a param;
|
|
283
|
+
* we walk back to the nearest non-param segment and search for any
|
|
284
|
+
* GET path ending with that hint).
|
|
285
|
+
*/
|
|
286
|
+
export function resolveOwnerListPaths(endpoints: EndpointInfo[]): Map<string, string> {
|
|
287
|
+
const getPathSet = new Set<string>();
|
|
288
|
+
const getPathsByLastSeg = new Map<string, string[]>();
|
|
289
|
+
for (const ep of endpoints) {
|
|
290
|
+
if (ep.method.toUpperCase() !== "GET" || ep.deprecated) continue;
|
|
291
|
+
const path = pathStripSlash(ep.path);
|
|
292
|
+
getPathSet.add(path);
|
|
293
|
+
const segs = path.split("/").filter(Boolean);
|
|
294
|
+
const last = segs[segs.length - 1];
|
|
295
|
+
if (last && !isParamSeg(last)) {
|
|
296
|
+
const arr = getPathsByLastSeg.get(last) ?? [];
|
|
297
|
+
arr.push(path);
|
|
298
|
+
getPathsByLastSeg.set(last, arr);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const result = new Map<string, string>();
|
|
303
|
+
const consider = (param: string, candidate: string) => {
|
|
304
|
+
const existing = result.get(param);
|
|
305
|
+
// Prefer shorter (more canonical/top-level) list path.
|
|
306
|
+
if (!existing || candidate.length < existing.length) result.set(param, candidate);
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
for (const ep of endpoints) {
|
|
310
|
+
if (ep.deprecated) continue;
|
|
311
|
+
const segs = pathStripSlash(ep.path).split("/");
|
|
312
|
+
for (let i = 0; i < segs.length; i++) {
|
|
313
|
+
const seg = segs[i]!;
|
|
314
|
+
const m = /^\{([^}]+)\}$/.exec(seg);
|
|
315
|
+
if (!m) continue;
|
|
316
|
+
const param = m[1]!;
|
|
317
|
+
const prevSeg = segs[i - 1];
|
|
318
|
+
|
|
319
|
+
// Strategy 1 (canonical): prev seg is a non-param noun and the
|
|
320
|
+
// prefix up to (but not including) `{param}` is a GET endpoint.
|
|
321
|
+
if (prevSeg && !isParamSeg(prevSeg)) {
|
|
322
|
+
const prefix = segs.slice(0, i).join("/");
|
|
323
|
+
if (getPathSet.has(prefix)) {
|
|
324
|
+
consider(param, prefix);
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Strategy 2 (sibling-param chain): walk back to the nearest
|
|
330
|
+
// non-param segment, then look for *any* GET path that terminates
|
|
331
|
+
// with that segment. Pick the shortest match.
|
|
332
|
+
let hint: string | undefined;
|
|
333
|
+
for (let j = i - 1; j >= 0; j--) {
|
|
334
|
+
const s = segs[j]!;
|
|
335
|
+
if (!isParamSeg(s) && s !== "") {
|
|
336
|
+
hint = s;
|
|
337
|
+
break;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if (!hint) continue;
|
|
341
|
+
const candidates = getPathsByLastSeg.get(hint);
|
|
342
|
+
if (!candidates || candidates.length === 0) continue;
|
|
343
|
+
const shortest = candidates.reduce((a, b) => (a.length <= b.length ? a : b));
|
|
344
|
+
consider(param, shortest);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return result;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function listPathToResourceName(listPath: string): string {
|
|
352
|
+
const segs = pathStripSlash(listPath).split("/").filter(Boolean);
|
|
353
|
+
for (let i = segs.length - 1; i >= 0; i--) {
|
|
354
|
+
if (!isParamSeg(segs[i])) return segs[i]!;
|
|
355
|
+
}
|
|
356
|
+
return "resource";
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Body-FK fallback. Used only when a body field's name doesn't appear
|
|
361
|
+
* as a path-param anywhere (so the structural resolver has nothing to
|
|
362
|
+
* say). Cheap heuristic — kept narrow on purpose.
|
|
363
|
+
*/
|
|
364
|
+
function inferFkOwnerByName(paramName: string, allResources: string[]): string | null {
|
|
365
|
+
const stem = paramName
|
|
366
|
+
.replace(/_id_or_slug$|_id_or_name$|_or_slug$|_or_name$/, "")
|
|
367
|
+
.replace(/_id$|Id$|_uuid$|_slug$/, "")
|
|
368
|
+
.toLowerCase();
|
|
369
|
+
if (!stem) return null;
|
|
370
|
+
for (const res of allResources) {
|
|
371
|
+
const r = res.toLowerCase();
|
|
372
|
+
if (r === stem || r === `${stem}s` || `${r}s` === stem || r.replace(/s$/, "") === stem) {
|
|
373
|
+
return res;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function collectPathFkDeps(
|
|
380
|
+
basePath: string,
|
|
381
|
+
idParam: string,
|
|
382
|
+
ownerListPaths: Map<string, string>,
|
|
383
|
+
resourceByListPath: Map<string, string>,
|
|
384
|
+
): ResourceFkRef[] {
|
|
385
|
+
const deps: ResourceFkRef[] = [];
|
|
386
|
+
const seen = new Set<string>();
|
|
387
|
+
const pathParamRe = /\{([^}]+)\}/g;
|
|
388
|
+
let match: RegExpExecArray | null;
|
|
389
|
+
while ((match = pathParamRe.exec(basePath)) !== null) {
|
|
390
|
+
const param = match[1]!;
|
|
391
|
+
if (param === idParam) continue;
|
|
392
|
+
if (seen.has(param)) continue;
|
|
393
|
+
seen.add(param);
|
|
394
|
+
const listPath = ownerListPaths.get(param);
|
|
395
|
+
const ownerResource = listPath ? (resourceByListPath.get(listPath) ?? null) : null;
|
|
396
|
+
deps.push({ var: param, param, in: "path", ownerResource });
|
|
397
|
+
}
|
|
398
|
+
return deps;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function collectBodyFkDeps(
|
|
402
|
+
group: CrudGroup,
|
|
403
|
+
ownerListPaths: Map<string, string>,
|
|
404
|
+
resourceByListPath: Map<string, string>,
|
|
405
|
+
allResources: string[],
|
|
406
|
+
): ResourceFkRef[] {
|
|
407
|
+
const deps: ResourceFkRef[] = [];
|
|
408
|
+
if (!group.create?.requestBodySchema) return deps;
|
|
409
|
+
const schema = group.create.requestBodySchema as OpenAPIV3.SchemaObject;
|
|
410
|
+
const props = (schema.properties ?? {}) as Record<string, OpenAPIV3.SchemaObject>;
|
|
411
|
+
const required = new Set(schema.required ?? []);
|
|
412
|
+
for (const [name] of Object.entries(props)) {
|
|
413
|
+
if (!/_id$|Id$|_uuid$/.test(name)) continue;
|
|
414
|
+
if (!required.has(name)) continue;
|
|
415
|
+
// Try structural resolution first (the body field name often matches a
|
|
416
|
+
// path-param elsewhere — `audience_id` body field, `audience_id` path
|
|
417
|
+
// param both point at /audiences/). Fall back to name-stemming.
|
|
418
|
+
let ownerResource: string | null = null;
|
|
419
|
+
const listPath = ownerListPaths.get(name);
|
|
420
|
+
if (listPath) ownerResource = resourceByListPath.get(listPath) ?? null;
|
|
421
|
+
if (!ownerResource) ownerResource = inferFkOwnerByName(name, allResources);
|
|
422
|
+
deps.push({ var: name, param: name, in: "body", ownerResource });
|
|
423
|
+
}
|
|
424
|
+
return deps;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export interface BuildResourcesParams {
|
|
428
|
+
endpoints: EndpointInfo[];
|
|
429
|
+
specHash: string;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export function buildApiResourceMap(params: BuildResourcesParams): ApiResourceMap {
|
|
433
|
+
const groups = detectCrudGroups(params.endpoints);
|
|
434
|
+
const ownerListPaths = resolveOwnerListPaths(params.endpoints);
|
|
435
|
+
|
|
436
|
+
// Index CRUD-group list paths by normalised path so the FK resolver can
|
|
437
|
+
// hand back the resource name a structural lookup pointed at.
|
|
438
|
+
const resourceByListPath = new Map<string, string>();
|
|
439
|
+
for (const g of groups) {
|
|
440
|
+
if (g.list) resourceByListPath.set(pathStripSlash(g.list.path), g.resource);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Imp resources: any list path that path-FKs point at structurally but
|
|
444
|
+
// no CRUD group claims (top-level GET-only collections like
|
|
445
|
+
// `/api/0/organizations/`, nested list-only collections, etc.). Without
|
|
446
|
+
// these, every FK that depends on a non-CRUD parent ends up with
|
|
447
|
+
// `ownerResource: null` and `discover` skips them — the actual root
|
|
448
|
+
// cause of the "discover --apply is a no-op" symptom.
|
|
449
|
+
const implicitResources: ApiResourceEntry[] = [];
|
|
450
|
+
const seenImplicit = new Set<string>();
|
|
451
|
+
for (const [, listPath] of ownerListPaths) {
|
|
452
|
+
if (resourceByListPath.has(listPath)) continue;
|
|
453
|
+
if (seenImplicit.has(listPath)) continue;
|
|
454
|
+
seenImplicit.add(listPath);
|
|
455
|
+
const listEp = params.endpoints.find(
|
|
456
|
+
e =>
|
|
457
|
+
e.method.toUpperCase() === "GET" &&
|
|
458
|
+
!e.deprecated &&
|
|
459
|
+
pathStripSlash(e.path) === listPath,
|
|
460
|
+
);
|
|
461
|
+
if (!listEp) continue;
|
|
462
|
+
const name = listPathToResourceName(listPath);
|
|
463
|
+
implicitResources.push({
|
|
464
|
+
resource: name,
|
|
465
|
+
basePath: listPath,
|
|
466
|
+
itemPath: "",
|
|
467
|
+
idParam: "",
|
|
468
|
+
captureField: "id",
|
|
469
|
+
hasFullCrud: false,
|
|
470
|
+
endpoints: { list: epLabel(listEp) },
|
|
471
|
+
fkDependencies: [],
|
|
472
|
+
});
|
|
473
|
+
resourceByListPath.set(listPath, name);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const resourceNames = [
|
|
477
|
+
...groups.map(g => g.resource),
|
|
478
|
+
...implicitResources.map(r => r.resource),
|
|
479
|
+
];
|
|
480
|
+
|
|
481
|
+
const crudResources: ApiResourceEntry[] = groups.map(g => {
|
|
482
|
+
const captureField = g.create ? getCaptureField(g.create) : "id";
|
|
483
|
+
const requiresEtag = !!(g.update?.requiresEtag || g.delete?.requiresEtag);
|
|
484
|
+
return {
|
|
485
|
+
resource: g.resource,
|
|
486
|
+
basePath: g.basePath,
|
|
487
|
+
itemPath: g.itemPath,
|
|
488
|
+
idParam: g.idParam,
|
|
489
|
+
captureField,
|
|
490
|
+
hasFullCrud: !!(g.list && g.create && g.read),
|
|
491
|
+
endpoints: {
|
|
492
|
+
...(g.list ? { list: epLabel(g.list) } : {}),
|
|
493
|
+
...(g.create ? { create: epLabel(g.create) } : {}),
|
|
494
|
+
...(g.read ? { read: epLabel(g.read) } : {}),
|
|
495
|
+
...(g.update ? { update: epLabel(g.update) } : {}),
|
|
496
|
+
...(g.delete ? { delete: epLabel(g.delete) } : {}),
|
|
497
|
+
},
|
|
498
|
+
...(requiresEtag ? { requiresEtag: true } : {}),
|
|
499
|
+
fkDependencies: [
|
|
500
|
+
...collectPathFkDeps(g.basePath, g.idParam, ownerListPaths, resourceByListPath),
|
|
501
|
+
...collectBodyFkDeps(g, ownerListPaths, resourceByListPath, resourceNames),
|
|
502
|
+
],
|
|
503
|
+
};
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
// Implicit resources also chain — `/orgs/{org}/projects/` lists projects
|
|
507
|
+
// but needs `organization_id_or_slug` set to call. Surface that so
|
|
508
|
+
// `discover` knows to fetch the parent first.
|
|
509
|
+
for (const r of implicitResources) {
|
|
510
|
+
r.fkDependencies = collectPathFkDeps(r.basePath, "", ownerListPaths, resourceByListPath);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const resources = [...crudResources, ...implicitResources];
|
|
514
|
+
|
|
515
|
+
// Endpoints that aren't in any CRUD group — RPC-style actions, webhook
|
|
516
|
+
// accept-only routes, etc. Implicit-list endpoints stay in orphans
|
|
517
|
+
// because they're not full CRUD; they're surfaced through resources for
|
|
518
|
+
// discovery purposes only.
|
|
519
|
+
const claimedEps = new Set<string>();
|
|
520
|
+
for (const g of groups) {
|
|
521
|
+
if (g.list) claimedEps.add(epLabel(g.list));
|
|
522
|
+
if (g.create) claimedEps.add(epLabel(g.create));
|
|
523
|
+
if (g.read) claimedEps.add(epLabel(g.read));
|
|
524
|
+
if (g.update) claimedEps.add(epLabel(g.update));
|
|
525
|
+
if (g.delete) claimedEps.add(epLabel(g.delete));
|
|
526
|
+
}
|
|
527
|
+
const orphanEndpoints = params.endpoints
|
|
528
|
+
.filter(ep => !claimedEps.has(epLabel(ep)))
|
|
529
|
+
.map(epLabel);
|
|
530
|
+
|
|
531
|
+
return {
|
|
532
|
+
generatedAt: new Date().toISOString(),
|
|
533
|
+
specHash: params.specHash,
|
|
534
|
+
resourceCount: resources.length,
|
|
535
|
+
resources,
|
|
536
|
+
orphanEndpoints,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// ── YAML serialization (minimal, no dep on yaml lib for the workspace) ──
|
|
541
|
+
|
|
542
|
+
function escape(s: string): string {
|
|
543
|
+
if (/[:#\[\]{}&*!|>'"@`,%]/.test(s) || s.includes("\n") || s === "") {
|
|
544
|
+
return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
545
|
+
}
|
|
546
|
+
return s;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
export function serializeApiResourceMap(m: ApiResourceMap): string {
|
|
550
|
+
const lines: string[] = [];
|
|
551
|
+
lines.push("# Auto-generated by zond. Do not edit by hand.");
|
|
552
|
+
lines.push("# Regenerate via: zond refresh-api <name>");
|
|
553
|
+
lines.push(`generatedAt: ${escape(m.generatedAt)}`);
|
|
554
|
+
lines.push(`specHash: ${escape(m.specHash)}`);
|
|
555
|
+
lines.push(`resourceCount: ${m.resourceCount}`);
|
|
556
|
+
if (m.resources.length === 0) {
|
|
557
|
+
lines.push("resources: []");
|
|
558
|
+
} else {
|
|
559
|
+
lines.push("resources:");
|
|
560
|
+
}
|
|
561
|
+
for (const r of m.resources) {
|
|
562
|
+
lines.push(` - resource: ${escape(r.resource)}`);
|
|
563
|
+
lines.push(` basePath: ${escape(r.basePath)}`);
|
|
564
|
+
lines.push(` itemPath: ${escape(r.itemPath)}`);
|
|
565
|
+
lines.push(` idParam: ${escape(r.idParam)}`);
|
|
566
|
+
lines.push(` captureField: ${escape(r.captureField)}`);
|
|
567
|
+
lines.push(` hasFullCrud: ${r.hasFullCrud}`);
|
|
568
|
+
if (r.requiresEtag) lines.push(` requiresEtag: true`);
|
|
569
|
+
lines.push(` endpoints:`);
|
|
570
|
+
for (const [k, v] of Object.entries(r.endpoints)) {
|
|
571
|
+
lines.push(` ${k}: ${escape(v as string)}`);
|
|
572
|
+
}
|
|
573
|
+
if (r.fkDependencies.length === 0) {
|
|
574
|
+
lines.push(` fkDependencies: []`);
|
|
575
|
+
} else {
|
|
576
|
+
lines.push(` fkDependencies:`);
|
|
577
|
+
for (const d of r.fkDependencies) {
|
|
578
|
+
lines.push(` - var: ${escape(d.var)}`);
|
|
579
|
+
lines.push(` param: ${escape(d.param)}`);
|
|
580
|
+
lines.push(` in: ${d.in}`);
|
|
581
|
+
lines.push(` ownerResource: ${d.ownerResource ? escape(d.ownerResource) : "null"}`);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
if (r.readbackDiff) {
|
|
585
|
+
lines.push(` readback_diff:`);
|
|
586
|
+
const ig = r.readbackDiff.ignoreFields ?? [];
|
|
587
|
+
if (ig.length > 0) {
|
|
588
|
+
lines.push(` ignore_fields:`);
|
|
589
|
+
for (const f of ig) lines.push(` - ${escape(f)}`);
|
|
590
|
+
}
|
|
591
|
+
const map = r.readbackDiff.writeToReadMap ?? {};
|
|
592
|
+
const mapKeys = Object.keys(map);
|
|
593
|
+
if (mapKeys.length > 0) {
|
|
594
|
+
lines.push(` write_to_read_map:`);
|
|
595
|
+
for (const k of mapKeys) lines.push(` ${escape(k)}: ${escape(map[k]!)}`);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
if (r.idempotency) {
|
|
599
|
+
lines.push(` idempotency:`);
|
|
600
|
+
if (r.idempotency.header) lines.push(` header: ${escape(r.idempotency.header)}`);
|
|
601
|
+
if (r.idempotency.scope) lines.push(` scope: ${r.idempotency.scope}`);
|
|
602
|
+
const ig = r.idempotency.ignoreResponseFields ?? [];
|
|
603
|
+
if (ig.length > 0) {
|
|
604
|
+
lines.push(` ignore_response_fields:`);
|
|
605
|
+
for (const f of ig) lines.push(` - ${escape(f)}`);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
if (r.pagination) {
|
|
609
|
+
lines.push(` pagination:`);
|
|
610
|
+
if (r.pagination.type) lines.push(` type: ${r.pagination.type}`);
|
|
611
|
+
if (r.pagination.cursorParam) lines.push(` cursor_param: ${escape(r.pagination.cursorParam)}`);
|
|
612
|
+
if (r.pagination.cursorField) lines.push(` cursor_field: ${escape(r.pagination.cursorField)}`);
|
|
613
|
+
if (r.pagination.hasMoreField) lines.push(` has_more_field: ${escape(r.pagination.hasMoreField)}`);
|
|
614
|
+
if (r.pagination.limitParam) lines.push(` limit_param: ${escape(r.pagination.limitParam)}`);
|
|
615
|
+
if (r.pagination.defaultLimit != null) lines.push(` default_limit: ${r.pagination.defaultLimit}`);
|
|
616
|
+
if (r.pagination.itemsField) lines.push(` items_field: ${escape(r.pagination.itemsField)}`);
|
|
617
|
+
}
|
|
618
|
+
if (r.lifecycle) {
|
|
619
|
+
lines.push(` lifecycle:`);
|
|
620
|
+
lines.push(` field: ${escape(r.lifecycle.field)}`);
|
|
621
|
+
lines.push(` states:`);
|
|
622
|
+
for (const s of r.lifecycle.states) lines.push(` - ${escape(s)}`);
|
|
623
|
+
lines.push(` transitions:`);
|
|
624
|
+
for (const t of r.lifecycle.transitions) {
|
|
625
|
+
lines.push(` - from: ${escape(t.from)}`);
|
|
626
|
+
if (t.to.length === 0) {
|
|
627
|
+
lines.push(` to: []`);
|
|
628
|
+
} else {
|
|
629
|
+
lines.push(` to:`);
|
|
630
|
+
for (const to of t.to) lines.push(` - ${escape(to)}`);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
lines.push(` actions:`);
|
|
634
|
+
for (const [name, a] of Object.entries(r.lifecycle.actions)) {
|
|
635
|
+
lines.push(` ${escape(name)}:`);
|
|
636
|
+
lines.push(` endpoint: ${escape(a.endpoint)}`);
|
|
637
|
+
lines.push(` expected_state: ${escape(a.expectedState)}`);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
if (m.orphanEndpoints.length === 0) {
|
|
642
|
+
lines.push("orphanEndpoints: []");
|
|
643
|
+
} else {
|
|
644
|
+
lines.push("orphanEndpoints:");
|
|
645
|
+
for (const e of m.orphanEndpoints) lines.push(` - ${escape(e)}`);
|
|
646
|
+
}
|
|
647
|
+
return lines.join("\n") + "\n";
|
|
648
|
+
}
|
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import type { OpenAPIV3 } from "openapi-types";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Deep-clone an object, replacing circular references with
|
|
5
|
-
* Uses WeakSet to track visited objects.
|
|
4
|
+
* Deep-clone an object, replacing circular references with the vendor-extension
|
|
5
|
+
* sentinel `{ "x-circular": true }`. Uses WeakSet to track visited objects.
|
|
6
|
+
*
|
|
7
|
+
* Why a vendor extension and not `$ref`: the decycled doc is now written to
|
|
8
|
+
* disk (apis/<name>/spec.json) and re-read by `@readme/openapi-parser` in
|
|
9
|
+
* downstream commands (check spec, describe, generate). If the sentinel
|
|
10
|
+
* carried a `$ref` field, the parser would try to resolve its value as a
|
|
11
|
+
* JSON pointer / file path — e.g. `apis/stripe/[Circular]` — and fail
|
|
12
|
+
* (ARV-146). `x-*` keys are explicitly reserved for vendor extensions in
|
|
13
|
+
* OpenAPI 3.x and pass through every parser untouched.
|
|
6
14
|
*/
|
|
7
15
|
export function decycleSchema(obj: unknown): unknown {
|
|
8
16
|
const seen = new WeakSet<object>();
|
|
@@ -16,7 +24,7 @@ export function decycleSchema(obj: unknown): unknown {
|
|
|
16
24
|
|
|
17
25
|
const obj = value as Record<string, unknown>;
|
|
18
26
|
if (seen.has(obj)) {
|
|
19
|
-
return {
|
|
27
|
+
return { "x-circular": true };
|
|
20
28
|
}
|
|
21
29
|
seen.add(obj);
|
|
22
30
|
|