diff --git a/.changeset/tree-command.md b/.changeset/tree-command.md new file mode 100644 index 0000000000..633e501230 --- /dev/null +++ b/.changeset/tree-command.md @@ -0,0 +1,8 @@ +--- +'@redocly/openapi-core': minor +'@redocly/cli': minor +--- + +Added the experimental `tree` command: it prints the structure of an API description — paths, operations, and the `$ref` dependency chains between them — with every node attributed to the file that defines it, and runs impact analysis with `--uses` (which paths and operations use a given component or file). +For LLM agents and tooling, `--format=json` prints a hierarchical index with stable semantic ids, JSON pointers, source files, line ranges, and summaries taken from the description itself; `--node` returns one node (a branch as a sub-index, a leaf as its raw source lines with resolved `$ref`s), and `--with-deps` appends the node's transitive `$ref` closure. +The underlying engines live in `@redocly/openapi-core`'s new `api-graph` module (`analyzeApi`, `buildApiIndex`, `buildNodeEnvelope`, `appendDepsClosure`). diff --git a/docs/@v2/commands/index.md b/docs/@v2/commands/index.md index 3d4107239e..fe42625a82 100644 --- a/docs/@v2/commands/index.md +++ b/docs/@v2/commands/index.md @@ -19,6 +19,7 @@ API management commands: - [`score`](score.md) Score an API for integration simplicity and AI agent readiness. - [`split`](split.md) Split API description into a multi-file structure. - [`stats`](stats.md) Gather statistics for a document. +- [`tree`](tree.md) Display the structure of an API description as a tree. Linting commands: diff --git a/docs/@v2/commands/tree.md b/docs/@v2/commands/tree.md new file mode 100644 index 0000000000..8f19d0c16c --- /dev/null +++ b/docs/@v2/commands/tree.md @@ -0,0 +1,583 @@ +# `tree` + +## Introduction + +The `tree` command prints the structure of an API description: its paths, operations, and the component dependency chains between them through `$ref`. +The structure view walks the original files, so every node belongs to the file that defines it — a multi-file API shows which file each path, operation, and component lives in. +The command works fully with OpenAPI 2.0 and 3.x. +AsyncAPI and Arazzo descriptions are supported too, but render as a flat list of their top-level referenced (`$ref`) components rather than a paths and operations tree. + +Use `tree` to: + +- Get quick orientation in any API, whether single-file or multi-file. +- Run impact analysis with `--uses` — which paths and operations use a given component or file. + This analysis is useful in CI and automated code review. +- Produce machine-readable JSON, a Mermaid diagram, or a Graphviz DOT graph with `--format`. +- View the file-level `$ref` graph with `--files`. + +## Usage + +```bash +redocly tree +redocly tree +redocly tree [--format=] [--uses=] [--level=] [--operations] [--output=] [--config=] +redocly tree --format=json [--group-by=] [--level=] +redocly tree --node= [--with-deps] +redocly tree --files [apis...] +``` + +With no API argument, the command takes the API from the Redocly configuration file. +The default structure view displays one API at a time. +Use `--files` for the multi-API file graph. + +## Options + +| Option | Type | Description | +| ------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| apis | [string] | In default mode, exactly one API description file or alias. In `--files` mode, one or more files or aliases. Defaults to APIs from the Redocly configuration file. | +| --config | string | Specify the path to the [Redocly configuration file](../configuration/index.md). | +| --files | boolean | Display the file-level `$ref` graph instead of the document structure. | +| --format | string | Output format: `stylish` (default, tree view), `json`, `mermaid`, or `dot`. For OpenAPI descriptions, `json` prints the machine-readable index (see _The agent index_ below); for other specification types it prints the dependency graph as nodes and links. | +| --group-by | string | Group operations in the JSON index by `tags` (default) or by `paths`. | +| --help | boolean | Display help. | +| --level | number | Limit the displayed depth of the tree. Level 1 shows the paths, level 2 adds the operations, and deeper levels add the component chains. Branches cut by the limit end with `…`. | +| --lint-config | string | Specify the severity level for the configuration file. **Possible values:** `warn`, `error`, `off`. Default value is `warn`. | +| --node | string | Print one JSON-index node instead of the tree: a branch returns its sub-index, a leaf returns its raw source lines and the `$ref`s it uses. Accepts a semantic id (`GET /orders`, `schemas/Order`, a tag name) or `#`. Structure view only. | +| --operations | boolean | Display only the API surface — paths, operations, and webhooks — without component chains. Operations show their `operationId` in parentheses. Not available with `--files`. | +| --output, -o | string | Write the output to a file instead of `stdout`. | +| --uses | [string] | Display only the part of the tree that uses (depends on) the given components, paths, or files. The default view accepts a JSON pointer, shorthand pointer, bare component name, or file path; `*` and `?` wildcards match node ids. `--files` mode accepts file paths only. Repeat the option to pass several values. | +| --version | boolean | Display version number. | +| --with-deps | boolean | With `--node` on a leaf: append the transitive `$ref` closure as `deps`, capped at 64 KB with a `truncated` marker. | + +## Examples + +### Print the structure of an API description + +```bash +redocly tree cafe.yaml +``` + +```treeview +cafe.yaml +├── /menu +│ ├── GET +│ │ ├── parameters/After +│ │ ├── parameters/Before +│ │ ├── parameters/Filter +│ │ ├── parameters/Limit +│ │ ├── parameters/Search +│ │ ├── parameters/Sort +│ │ ├── responses/BadRequest +│ │ │ └── schemas/Error +│ │ ├── responses/InternalServerError +│ │ │ └── schemas/Error +│ │ └── schemas/MenuItemList +│ │ ├── schemas/MenuItem +│ │ │ ├── schemas/Beverage +│ │ │ │ └── schemas/MenuBaseItem +│ │ │ └── schemas/Dessert +│ │ │ └── schemas/MenuBaseItem +│ │ └── schemas/Page +│ └── POST +│ ├── responses/BadRequest +│ │ └── schemas/Error +│ ├── responses/Conflict +│ │ └── schemas/Error +│ ├── responses/Forbidden +│ │ └── schemas/Error +│ ├── responses/InternalServerError +│ │ └── schemas/Error +│ ├── responses/Unauthorized +│ │ └── schemas/Error +│ └── schemas/MenuItem +│ ├── schemas/Beverage +│ │ └── schemas/MenuBaseItem +│ └── schemas/Dessert +│ └── schemas/MenuBaseItem +├── /menu-item-images/{menuItemId} +│ ├── GET +│ │ ├── parameters/PhotoSize +│ │ ├── responses/InternalServerError +│ │ │ └── schemas/Error +│ │ └── responses/NotFound +│ │ └── schemas/Error +│ └── parameters/MenuItemId +└── … (other paths) +``` + +The tree above is truncated for readability (`… (other paths)`); the full output lists every path. +An operation is shown as the method only (`GET`) under its path, since the path is its parent. + +Markers legend: + +- `🔁` — a cycle: the node references one of its ancestors (a recursive schema). It is marked and not expanded further, so traversal terminates. A node that simply appears in more than one place (fan-in, without forming a cycle) is shown without a marker and expanded under each parent. +- `❌` — an unresolvable `$ref` (in the structure view it also prints a warning to stderr, see _Invalid descriptions_ below) +- `🔗` — a reference to a URL + +A recursive schema produces the `🔁` marker: + +{% tabs %} +{% tab label="API description" %} + +```yaml +# menu.yaml +openapi: 3.2.0 +info: + title: Cafe menu + version: 1.0.0 +paths: + /menu: + get: + responses: + '200': + description: A menu section with nested subsections. + content: + application/json: + schema: + $ref: '#/components/schemas/MenuSection' +components: + schemas: + MenuSection: + type: object + properties: + name: + type: string + subsections: + type: array + items: + $ref: '#/components/schemas/MenuSection' +``` + +{% /tab %} +{% tab label="Output" %} + +```treeview +menu.yaml +└── /menu + └── GET + └── schemas/MenuSection + └── schemas/MenuSection 🔁 +``` + +`MenuSection` references itself, so the cycle is marked `🔁` and not expanded again. + +{% /tab %} +{% /tabs %} + +`❌` and `🔗` appear in both views. +This example uses `--files` to show the file-level graph: + +{% tabs %} +{% tab label="API description" %} + +```yaml +# openapi.yaml — has a missing-file ref and an unreachable URL ref +openapi: 3.2.0 +info: + title: Cafe + version: 1.0.0 +paths: + /orders: + get: + responses: + '200': + description: An order. + content: + application/json: + schema: + $ref: './schemas/Order.yaml' + '500': + description: Shared remote error. + content: + application/json: + schema: + $ref: 'https://example.com/schemas/Error.yaml' +``` + +{% /tab %} +{% tab label="Output" %} + +```bash +redocly tree openapi.yaml --files +``` + +```treeview +openapi.yaml +├── https://example.com/schemas/Error.yaml 🔗 ❌ +└── schemas/Order.yaml ❌ +``` + +`schemas/Order.yaml` does not exist, so it is `❌`. The URL is `🔗`; here it is also unreachable, so it is `❌` too. + +{% /tab %} +{% /tabs %} + +The structure view walks the original files, so a multi-file API shows the files that define its parts. +Components that live in their own files appear as file nodes with real paths, and every path and operation reports its defining file. + +### Limit the depth + +```bash +redocly tree cafe.yaml --level 1 +``` + +```treeview +cafe.yaml +├── /menu … +├── /menu-item-images/{menuItemId} … +├── /menu/{menuItemId} … +├── /oauth2/register … +├── /order-items … +├── /orders … +├── /orders/{orderId} … +├── /revenue … +└── webhooks/order-notification … +``` + +`--level 1` shows the paths, `--level 2` adds the operations, and deeper levels add the component chains. +A branch cut by the limit ends with `…`. +In machine-readable formats (`json`, `mermaid`, `dot`), `--level` keeps the nodes within that many steps of the root. + +### Show only the API surface + +```bash +redocly tree cafe.yaml --operations +``` + +```treeview +cafe.yaml +├── /menu +│ ├── GET (listMenuItems) +│ └── POST (createMenuItem) +├── /menu-item-images/{menuItemId} +│ └── GET (getMenuItemPhoto) +├── /menu/{menuItemId} +│ └── DELETE (deleteMenuItem) +├── /oauth2/register +│ └── POST (registerOAuth2Client) +├── /order-items +│ └── GET (listOrderItems) +├── /orders +│ ├── GET (listOrders) +│ └── POST (createOrder) +├── /orders/{orderId} +│ ├── DELETE (deleteOrder) +│ ├── GET (getOrderById) +│ └── PATCH (updateOrder) +├── /revenue +│ └── GET (getRevenue) +└── webhooks/order-notification +``` + +`--operations` displays every path with all of its operations and the webhook entries, hiding the component chains. +Each operation that defines an `operationId` shows it in parentheses. +Unlike `--level 2`, the output never includes path-level parameters or other components. +The option applies to the structure view and cannot be combined with `--files`. + +### Find what uses a component, path, or file + +Pass one or more components, paths, or files to `--uses` to see only the part of the tree that depends on them: + +```bash +redocly tree cafe.yaml --uses schemas/Order +``` + +```treeview +cafe.yaml +├── /orders +│ ├── GET +│ │ └── schemas/OrderList +│ │ └── schemas/Order +│ └── POST +│ └── schemas/Order +└── /orders/{orderId} + ├── GET + │ └── schemas/Order + └── PATCH + └── schemas/Order + +4 of 12 operations affected · affected paths: /orders, /orders/{orderId} +``` + +`--uses` accepts several input forms: + +- full JSON pointer: `#/components/schemas/Order` +- shorthand pointer (the node id): `schemas/Order` +- bare component name: `Order` — ambiguous bare names match all candidates and print a note to `stderr` +- a wildcard pattern: `schemas/Order*` — `*` and `?` match against node ids (file ids in `--files` mode) +- a file path: `components/schemas/Order.yaml` — addresses every node the file defines (in `--files` mode, the file node itself) +- the root file itself: the whole tree is affected + +Components that the root document declares (`components: {schemas: {Order: {$ref: ./Order.yaml}}}`) keep their canonical `schemas/Order` id even when they live in their own file, +so every form above works the same for single-file and multi-file APIs. +A component that no root entry declares — for example in `redocly split` output, where operation files reference component files directly — is addressed by its file path instead. + +Examples of the different input forms: + +```bash +# full JSON pointer +redocly tree cafe.yaml --uses '#/components/schemas/Order' + +# shorthand pointer (the node id) +redocly tree cafe.yaml --uses schemas/Order + +# bare component name — matches any component with that name +redocly tree cafe.yaml --uses Order + +# several values at once — repeat the flag +redocly tree cafe.yaml --uses schemas/Order --uses schemas/MenuItem + +# wildcard — every component whose id starts with schemas/Order +redocly tree cafe.yaml --uses 'schemas/Order*' + +# file-level: which files depend on a given file +redocly tree cafe.yaml --files --uses components/schemas/Order.yaml +``` + +The summary line reports how many operations are affected. +A change that only affects path-level parameters can report `0 of N operations affected` while still listing the affected path: the path itself is impacted, not its operations. +For AsyncAPI or Arazzo descriptions, which have no operation nodes, the summary counts nodes instead — for example, `5 of 8 nodes affected`. + +An unknown `--uses` value (a typo, or a component that no longer exists) prints a warning and still exits with code `0`, so a stale query never fails a CI run. +A file path that matches nothing also points you to `--files`. + +### Machine-readable output + +`--format` produces output for other tools: `json`, `mermaid`, or `dot`. + +{% tabs %} +{% tab label="API description" %} + +```yaml +# orders.yaml +openapi: 3.2.0 +info: + title: Cafe orders + version: 1.0.0 +paths: + /orders: + get: + responses: + '200': + description: An order. + content: + application/json: + schema: + $ref: '#/components/schemas/Order' +components: + schemas: + Order: + type: object + properties: + id: + type: string + total: + type: number +``` + +{% /tab %} +{% tab label="json" %} + +The graph in the common `nodes`/`links` shape (compatible with D3, force-graph, and similar tools). +Every node carries `resolved` and `external`; `kind` and `file` are present in the default view, and operation nodes carry `operationId` when it is defined. +Each link carries the exact `$ref` strings. + +```json +{ + "nodes": [ + { "id": "/orders", "resolved": true, "kind": "path", "file": "orders.yaml" }, + { "id": "GET /orders", "resolved": true, "kind": "operation", "file": "orders.yaml" }, + { "id": "orders.yaml", "resolved": true, "kind": "root", "file": "orders.yaml", "root": true }, + { "id": "schemas/Order", "resolved": true, "kind": "component", "file": "orders.yaml" } + ], + "links": [ + { "source": "/orders", "target": "GET /orders", "refs": [] }, + { "source": "GET /orders", "target": "schemas/Order", "refs": ["#/components/schemas/Order"] }, + { "source": "orders.yaml", "target": "/orders", "refs": [] } + ] +} +``` + +{% /tab %} +{% tab label="mermaid" %} + +A [Mermaid](https://mermaid.js.org/) `flowchart` definition. It renders as: + +```mermaid +flowchart LR + n0["/orders"] + n1["GET /orders"] + n2["orders.yaml"]:::root + n3["schemas/Order"] + n0 --> n1 + n1 --> n3 + n2 --> n0 + classDef root font-weight:bold +``` + +{% /tab %} +{% tab label="dot" %} + +A [DOT](https://graphviz.org/doc/info/lang.html) `digraph`, consumable by Graphviz and most graph-drawing tools. + +```text +digraph tree { + "/orders"; + "GET /orders"; + "orders.yaml" [shape=box, style=bold]; + "schemas/Order"; + "/orders" -> "GET /orders"; + "GET /orders" -> "schemas/Order"; + "orders.yaml" -> "/orders"; +} +``` + +{% /tab %} +{% /tabs %} + +### Write the output to a file + +Use `--output` (`-o`) to write any format to a file instead of `stdout`: + +```bash +redocly tree cafe.yaml --format=mermaid --output cafe.md +``` + +### Invalid descriptions + +In the structure view an unresolvable `$ref` appears as an unresolved node marked ❌, and the command prints a warning to stderr for each one. + +{% tabs %} +{% tab label="API description" %} + +```yaml +# openapi.yaml +openapi: 3.2.0 +info: + title: Test API + version: 1.0.0 +paths: + /items: + get: + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: './schemas/Item.yaml' + '500': + description: Error + content: + application/json: + schema: + $ref: 'https://example.com/error.yaml' +``` + +{% /tab %} +{% tab label="Output" %} + +```bash +redocly tree openapi.yaml +``` + +``` +Could not resolve https://example.com/error.yaml — shown as unresolved (❌). +Could not resolve schemas/Item.yaml — shown as unresolved (❌). +openapi.yaml +└── /items + └── GET + ├── https://example.com/error.yaml 🔗 ❌ + └── schemas/Item.yaml ❌ +``` + +The unresolvable references are shown in the tree and printed as warnings to stderr, allowing you to see the partial structure even when some `$ref`s cannot be resolved. + +{% /tab %} +{% /tabs %} + +### File-level graph + +`--files` shows how a description is split across files, so the examples below use a multi-file version of the API. +A single bundled file has no file-level `$ref`s, so its `--files` graph is just the root. + +```bash +redocly tree cafe.yaml --files +``` + +```treeview +cafe.yaml +├── paths/menu.yaml +│ ├── components/parameters/Limit.yaml +│ ├── components/responses/BadRequest.yaml +│ │ └── components/schemas/Error.yaml +│ └── components/schemas/MenuItemList.yaml +│ └── components/schemas/MenuItem.yaml +└── paths/orders.yaml + └── components/schemas/OrderList.yaml + └── components/schemas/Order.yaml +``` + +The tree above is abbreviated; the real output lists every file. +`--files` displays only which files reference other files — not the paths, operations, and components inside them. +Paths are shown relative to the directory of the root description, so the folder you run the command from does not appear as a prefix. +The default view already traverses those elements, following `$ref`s across files. +`--files` also accepts multiple APIs in one run, merging their graphs. +In this mode, `--uses` takes file paths. +They are matched relative to the API root — the same way they appear in the output — and paths relative to your current working directory also work. +The summary counts affected files and roots. + +### Combine `--files`, `--uses`, and `--format` + +The flags compose. For example, render just the files that depend on `Order.yaml` as a Mermaid graph: + +```bash +redocly tree cafe.yaml --files --uses components/schemas/Order.yaml --format=mermaid +``` + +```mermaid +flowchart LR + n0["cafe.yaml"]:::root + n1["components/schemas/Order.yaml"] + n2["components/schemas/OrderList.yaml"] + n3["paths/orders.yaml"] + n4["paths/orders_{orderId}.yaml"] + n0 --> n3 + n0 --> n4 + n2 --> n1 + n3 --> n1 + n3 --> n2 + n4 --> n1 + classDef root font-weight:bold +``` + +## The agent index + +Large API descriptions do not fit in an LLM's context window. +Instead of feeding the whole file to a model, generate a compact index of it and let the agent navigate in bounded steps. +The index is generated deterministically from the document structure — no AI calls or API keys are needed. +It is available for OpenAPI descriptions; `--node`, `--with-deps`, and `--group-by` report an error for other specification types. +For a measured comparison of how much context this saves — on GitHub's 9.8 MB REST API description, where the whole file is 1.9 million tokens — see [Agent context savings with tree](../guides/tree-agent-index-benchmark.md). + +1. Get the map: `redocly tree openapi.yaml --format=json --level 2` prints the sections, tags, and counts — a few kilobytes for any spec size. +2. Drill into a branch the agent picked: `redocly tree openapi.yaml --node Tickets` returns that tag's operations with summaries, files, and line ranges. +3. Fetch a leaf with everything it needs: `redocly tree openapi.yaml --node 'GET /orders' --with-deps` returns the operation's raw source lines, its resolved `$ref`s, and the transitive dependency closure as `deps` — a self-contained slice for generating a client call, writing a contract test, or reviewing the endpoint. + +Every index node carries a stable semantic id, a JSON pointer, the defining `file`, its `start_line`/`end_line` range, and a `summary` taken from the description itself, +so an agent can also read the exact lines directly with plain file tools instead of calling the CLI again: + +```json +{ + "id": "POST /tickets", + "title": "POST /tickets — Buy museum tickets", + "operationId": "buyMuseumTickets", + "pointer": "#/paths/~1tickets/post", + "file": "openapi.yaml", + "start_line": 22, + "end_line": 31, + "summary": "Buy museum tickets" +} +``` + +Ids for operations and components are stable across groupings; group nodes (tag names, path prefixes) depend on the selected `--group-by`, +so pass the same `--group-by` value when addressing a group by id, or use the grouping-independent `#` form. diff --git a/docs/@v2/guides/index.md b/docs/@v2/guides/index.md index dc22d0b87b..c7667f8ee5 100644 --- a/docs/@v2/guides/index.md +++ b/docs/@v2/guides/index.md @@ -97,6 +97,12 @@ Authenticate, handle errors, and compose middleware with a client from `generate Pre-configure publisher defaults and write custom client generators. {% /card %} +{% card title="Agent context savings with tree" + to="./tree-agent-index-benchmark" + %} +Measured token counts for exploring GitHub's 9.8 MB REST API description with the `tree` index instead of reading the whole file. +{% /card %} + {% card title="Set up tab completion" to="./autocomplete" %} diff --git a/docs/@v2/guides/tree-agent-index-benchmark.md b/docs/@v2/guides/tree-agent-index-benchmark.md new file mode 100644 index 0000000000..f656483404 --- /dev/null +++ b/docs/@v2/guides/tree-agent-index-benchmark.md @@ -0,0 +1,215 @@ +# How much context the `tree` index saves an agent + +The [`tree`](../commands/tree.md) command's JSON index lets an AI agent work with an API description that does not fit in its context window. +This guide measures that on the largest well-known public API description: GitHub's official REST API description, 9.8 MB of OpenAPI. +For the command reference, see [`tree`](../commands/tree.md). + +Every number below comes from a real command run against that file, tokenized with a BPE tokenizer (`gpt-tokenizer`, o200k family; other model families tokenize slightly differently, with the same order of magnitude). +The description is public, so the whole experiment is reproducible: + +```bash +curl -O https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.yaml +``` + +## The setup + +- **Description:** `api.github.com.yaml` from [`github/rest-api-description`](https://github.com/github/rest-api-description) — 9.8 MB, OpenAPI 3.0.3, 47 tags, 1,216 operations, 1,766 components. + This is the first-party description GitHub's own SDKs are generated from, not a conversion or a sample. +- **Agent task:** _"Create a repository for the authenticated user."_ +- **Agent constraints:** a 200,000-token context window; the agent starts knowing nothing about the description. +- **What the agent is told up front:** a short instruction naming the three commands (index → branch → leaf-with-deps) and the id forms — **114 tokens**, measured. + The agent decides _which_ branch and operation to open by reasoning over titles and summaries; it does not discover the commands themselves. + That one-time cost is about 0.2% of the chain below and appears as a separate line in the totals. + +## Without the index + +The agent's only option is to read the description: + +| Input | Tokens | +| --------------------------------- | ------------: | +| `api.github.com.yaml`, whole file | **1,946,991** | + +At 1,946,991 tokens the file is roughly ten times a 200,000-token window, and still twice a 1,000,000-token one. +No amount of "read a bit more" helps here. +Searching the file by text instead is unreliable: it does not reveal the structure, does not follow `$ref` chains, and gives no bound on how much context the agent ends up reading. + +## Why the index has to be hierarchical + +At this size, a flat index does not solve the problem either: + +| Input | Tokens | Nodes | +| ------------------------------------------------ | ----------: | ----: | +| `redocly tree api.github.com.yaml --format=json` | **306,494** | 3,038 | + +The complete index of every tag, operation, and component is itself larger than the context window. +This is what the `--level` and `--node` options are for: the agent never asks for the whole index, only for one level or one branch at a time. +On this description the hierarchy is not an optimization — it is the only way an agent can work with the file at all. + +## With the index + +The agent walks the hierarchy in bounded steps, paying only for the path it chooses: + +| Step | Command | Output size | Tokens | +| ------------------------------------------------ | ------------------------------------------------------------------------ | ----------: | ---------: | +| 1. Map the spec — 4 sections, 47 tags | `redocly tree api.github.com.yaml --format=json --level 2` | 14.6 KB | 3,647 | +| 2. Open the branch it picked — 203 operations | `redocly tree api.github.com.yaml --node repos` | 101.0 KB | 27,017 | +| 3. Fetch the target with its full `$ref` closure | `redocly tree api.github.com.yaml --node 'POST /user/repos' --with-deps` | 80.4 KB | 18,946 | +| **Total** | | | **49,610** | + +Step 3 returns a _self-contained_ slice: the operation's raw source lines (8.3 KB) plus the 14 components it transitively references — the `full-repository` schema and everything under it, the seven shared error responses, the response example — in dependency order. +That fills 63.6 KB of the 64 KB closure cap, so the response stays bounded no matter how deep the schema graph goes; anything beyond the cap stays one `--node` call away. + +The most expensive step is not the largest file, it is the largest branch: `repos` is GitHub's biggest tag, and listing its 203 operations costs more than the operation and all its schemas combined. +An agent that already knows the tag can start from `--level 1` (286 tokens) and skip straight to it. + +## What the agent actually sees + +Step 1 is small enough to show in full — this is the entire map of a 9.8 MB API in 286 tokens: + +```json +{ + "docName": "api.github.com.yaml", + "spec": "oas3_0", + "docDescription": "GitHub v3 REST API — GitHub's v3 REST API.", + "structure": [ + { + "id": "Overview", + "title": "Overview", + "pointer": "#/info", + "file": "api.github.com.yaml", + "start_line": 4, + "end_line": 14, + "summary": "GitHub's v3 REST API." + }, + { + "id": "Servers", + "title": "Servers", + "pointer": "#/servers", + "file": "api.github.com.yaml", + "start_line": 116, + "end_line": 116, + "summary": "https://api.github.com" + }, + { + "id": "Operations", + "title": "Operations", + "pointer": "#/paths", + "file": "api.github.com.yaml", + "start_line": 121, + "end_line": 67148 + }, + { + "id": "Components", + "title": "Components", + "pointer": "#/components", + "file": "api.github.com.yaml", + "start_line": 85076, + "end_line": 261104 + } + ] +} +``` + +Step 2 opens one branch and returns its operations, each with the summary the agent reasons over and the exact lines it can read directly: + +```json +{ + "structure": [ + { + "id": "repos", + "title": "repos", + "pointer": "#/tags/25", + "file": "api.github.com.yaml", + "start_line": 66, + "end_line": 67, + "summary": "Interact with GitHub Repos.", + "nodes": [ + { + "id": "POST /user/repos", + "title": "POST /user/repos — Create a repository for the authenticated user", + "operationId": "repos/create-for-authenticated-user", + "pointer": "#/paths/~1user~1repos/post", + "file": "api.github.com.yaml", + "start_line": 62491, + "end_line": 62697, + "summary": "Create a repository for the authenticated user" + } + ] + } + ] +} +``` + +Step 3 returns the leaf envelope: raw source lines, the `$ref`s found inside them resolved to real locations, and the transitive closure under `deps`: + +```json +{ + "id": "POST /user/repos", + "pointer": "#/paths/~1user~1repos/post", + "file": "api.github.com.yaml", + "start_line": 62491, + "end_line": 62697, + "content": "summary: Create a repository for the authenticated user\ndescription: Creates a new repository for the authenticated user.\ntags:\n - repos\noperationId: repos/create-for-authenticated-user\n…", + "refs": [ + { + "ref": "#/components/responses/bad_request", + "resolved": true, + "file": "api.github.com.yaml", + "pointer": "#/components/responses/bad_request" + } + ], + "deps": [ + { "id": "schemas/full-repository", "file": "api.github.com.yaml", "content": "…" }, + { "id": "schemas/nullable-repository", "file": "api.github.com.yaml", "content": "…" }, + { "id": "responses/validation_failed", "file": "api.github.com.yaml", "content": "…" } + ] +} +``` + +The 14 ids returned in the closure: `schemas/full-repository`, `schemas/nullable-repository`, `schemas/nullable-license-simple`, `schemas/code-of-conduct-simple`, `schemas/basic-error`, `schemas/scim-error`, `schemas/validation-error`, `examples/full-repository`, and the `responses/*` entries for the seven documented error codes. + +## The same task on a split (multi-file) layout + +The same description was run through [`redocly split`](../commands/split.md), producing **2,842 files**, and the identical chain was repeated against `openapi.yaml` in that directory: + +| Step | Single file | Split (2,842 files) | +| ------------------------------------------ | ----------: | ------------------: | +| 1. `--format=json --level 2` | 3,647 | 3,436 | +| 2. `--node repos` | 27,017 | 23,709 | +| 3. `--node 'POST /user/repos' --with-deps` | 18,946 | 18,807 | +| **Chain total** | **49,610** | **45,952** | + +The split chain is slightly cheaper, because pointers inside small files are short. +Both layouts list the same 203 operations under `repos`, and operation ids are identical (`POST /user/repos`), so the same agent instructions work unchanged. + +Component ids differ between the layouts, and it is worth knowing why. +In the single file, components are declared under `components`, so they get canonical ids: `schemas/full-repository`. +`redocly split` does not keep a component registry in the root document — operation files reference component files directly — so in that layout the same schema is identified by its path: `components/schemas/full-repository.yaml`. +Canonical ids appear in a split layout too, as long as the root document declares the component (`components: {schemas: {Name: {$ref: ./file.yaml}}}`), which is what a hand-maintained multi-file description usually does. +Either way the closure is retrieved by one command: here it pulled 15 components from 15 separate files and returned them as a single envelope — the case where an agent without an index would have to hand-walk `$ref`s across a 2,842-file tree without knowing which ones matter. + +## The difference + +| | Tokens | vs. whole file | +| ---------------------------------- | ------------------------: | ---------------: | +| Whole file | 1,946,991 | — (does not fit) | +| Full index, unfiltered | 306,494 | — (does not fit) | +| Index chain | 49,610 (+114 instruction) | **~39× less** | +| Index chain, starting from level 1 | 46,249 (+114 instruction) | **~42× less** | +| Index chain on the split layout | 45,952 (+114 instruction) | **~42× less** | + +The ratio matters less than the shape of the curve. +The chain's cost is bounded by the _largest branch_ and the _deepest single closure_, not by the size of the description: on a 1.3 MB description the same three steps cost 12,000 to 25,000 tokens, and on this 9.8 MB one they cost about 50,000. +The description grew by a factor of 7.5; the chain roughly doubled. + +For descriptions that fit the context window, the index saves tokens. +Past the window size, it is the difference between an impossible task and a routine one — here the agent solves a task against a two-million-token API while using a quarter of a 200,000-token window, with the rest left for the work itself. + +## Methodology notes + +- Every output above comes from a real command run against the real file; sizes are the byte counts of captured `stdout`. +- Token counts come from `gpt-tokenizer` over the exact captured text, not from a characters-per-token estimate. +- The JSON samples are real command output, shortened by dropping whole nodes and eliding long string values with `…`, never by rewriting values; the file name is shortened from the local path to `api.github.com.yaml`. +- The description is `api.github.com.yaml` from the `main` branch of `github/rest-api-description`, version 1.1.4, used unmodified. +- The agent chooses which nodes to open; the command syntax comes from the 114-token instruction counted separately above. +- Each command invocation analyzes the description again — about 42 seconds for this 9.8 MB file. A long-running process that keeps the analysis in memory would pay that cost once per session instead of once per step. diff --git a/docs/@v2/v2.sidebars.yaml b/docs/@v2/v2.sidebars.yaml index c7bf12f02d..7ccf4923d6 100644 --- a/docs/@v2/v2.sidebars.yaml +++ b/docs/@v2/v2.sidebars.yaml @@ -52,6 +52,8 @@ page: commands/stats.md - label: translate page: commands/translate.md + - label: tree + page: commands/tree.md - group: Guides page: guides/index.md items: @@ -66,6 +68,8 @@ page: guides/use-generated-client.md - label: Customize client generation page: guides/customize-client-generation.md + - label: Agent context savings with tree + page: guides/tree-agent-index-benchmark.md - label: Hide internal APIs page: guides/hide-apis.md - label: Replace the servers URL diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index 34179987a8..6c0449b428 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -143,7 +143,13 @@ export async function handleLintConfig(argv: Exact, version: string return; } - if (argv.format === 'json' || argv.format === 'junit' || argv.format === 'checkstyle') { + if ( + argv.format === 'json' || + argv.format === 'junit' || + argv.format === 'checkstyle' || + argv.format === 'mermaid' || + argv.format === 'dot' + ) { // these are single-document formats, so a separate config-lint document would break the output return; } diff --git a/packages/cli/src/commands/tree/__tests__/build-graph.test.ts b/packages/cli/src/commands/tree/__tests__/build-graph.test.ts new file mode 100644 index 0000000000..36ccc30476 --- /dev/null +++ b/packages/cli/src/commands/tree/__tests__/build-graph.test.ts @@ -0,0 +1,163 @@ +import { ResolveError, Source, type Document, type ResolvedRefMap } from '@redocly/openapi-core'; +import * as path from 'node:path'; + +import { buildGraph } from '../build-graph.js'; + +const CWD = '/project'; + +function makeDocument(absoluteRef: string): Document { + return { source: new Source(absoluteRef, ''), parsed: {} }; +} + +function resolvedEntry(targetAbsoluteRef: string, isRemote = true) { + return { + resolved: true as const, + isRemote, + node: {}, + nodePointer: '#/', + document: makeDocument(targetAbsoluteRef), + }; +} + +const resolveRef = (base: string, uri: string) => path.resolve(path.dirname(base), uri); + +describe('buildGraph', () => { + it('builds nodes and edges from cross-file refs, transitively', () => { + const refMap: ResolvedRefMap = new Map([ + ['/project/openapi.yaml::paths/users.yaml', resolvedEntry('/project/paths/users.yaml')], + [ + '/project/paths/users.yaml::../components/User.yaml', + resolvedEntry('/project/components/User.yaml'), + ], + ]); + + const graph = buildGraph([{ rootDocument: makeDocument('/project/openapi.yaml'), refMap }], { + base: CWD, + resolveRef, + }); + + expect(graph).toEqual({ + roots: ['openapi.yaml'], + nodes: [ + { id: 'components/User.yaml', resolved: true }, + { id: 'openapi.yaml', root: true, resolved: true }, + { id: 'paths/users.yaml', resolved: true }, + ], + edges: [ + { from: 'openapi.yaml', to: 'paths/users.yaml', refs: ['paths/users.yaml'] }, + { + from: 'paths/users.yaml', + to: 'components/User.yaml', + refs: ['../components/User.yaml'], + }, + ], + }); + }); + + it('skips same-file refs', () => { + const refMap: ResolvedRefMap = new Map([ + [ + '/project/openapi.yaml::#/components/schemas/Pet', + { ...resolvedEntry('/project/openapi.yaml'), isRemote: false }, + ], + ]); + + const graph = buildGraph([{ rootDocument: makeDocument('/project/openapi.yaml'), refMap }], { + base: CWD, + resolveRef, + }); + + expect(graph.nodes).toEqual([{ id: 'openapi.yaml', root: true, resolved: true }]); + expect(graph.edges).toEqual([]); + }); + + it('dedupes edges across refs and across roots, collecting distinct sorted refs', () => { + const entryY = resolvedEntry('/project/b.yaml'); + const entryX = resolvedEntry('/project/b.yaml'); + const refMapA: ResolvedRefMap = new Map([ + ['/project/a.yaml::b.yaml#/Y', entryY], + ['/project/a.yaml::b.yaml#/X', entryX], + ]); + const refMapB: ResolvedRefMap = new Map([['/project/a.yaml::b.yaml#/X', entryX]]); + + const graph = buildGraph( + [ + { rootDocument: makeDocument('/project/a.yaml'), refMap: refMapA }, + { rootDocument: makeDocument('/project/b.yaml'), refMap: refMapB }, + ], + { base: CWD, resolveRef } + ); + + expect(graph.roots).toEqual(['a.yaml', 'b.yaml']); + expect(graph.edges).toEqual([ + { from: 'a.yaml', to: 'b.yaml', refs: ['b.yaml#/X', 'b.yaml#/Y'] }, + ]); + expect(graph.nodes).toEqual([ + { id: 'a.yaml', root: true, resolved: true }, + { id: 'b.yaml', root: true, resolved: true }, + ]); + }); + + it('represents unresolved refs as resolved:false nodes with an edge', () => { + const refMap: ResolvedRefMap = new Map([ + [ + '/project/openapi.yaml::./missing.yaml#/Pet', + { + resolved: false as const, + isRemote: true, + document: undefined, + error: new ResolveError(new Error('ENOENT')), + }, + ], + ]); + + const graph = buildGraph([{ rootDocument: makeDocument('/project/openapi.yaml'), refMap }], { + base: CWD, + resolveRef, + }); + + expect(graph.nodes).toEqual([ + { id: 'missing.yaml', resolved: false }, + { id: 'openapi.yaml', root: true, resolved: true }, + ]); + expect(graph.edges).toEqual([ + { from: 'openapi.yaml', to: 'missing.yaml', refs: ['./missing.yaml#/Pet'] }, + ]); + }); + + it('keeps http(s) targets as external URL nodes', () => { + const refMap: ResolvedRefMap = new Map([ + [ + '/project/openapi.yaml::https://example.com/shared.yaml#/S', + resolvedEntry('https://example.com/shared.yaml'), + ], + ]); + + const graph = buildGraph([{ rootDocument: makeDocument('/project/openapi.yaml'), refMap }], { + base: CWD, + resolveRef, + }); + + expect(graph.nodes).toEqual([ + { id: 'https://example.com/shared.yaml', external: true, resolved: true }, + { id: 'openapi.yaml', root: true, resolved: true }, + ]); + }); + + it('handles cyclic file references', () => { + const refMap: ResolvedRefMap = new Map([ + ['/project/a.yaml::b.yaml', resolvedEntry('/project/b.yaml')], + ['/project/b.yaml::a.yaml', resolvedEntry('/project/a.yaml')], + ]); + + const graph = buildGraph([{ rootDocument: makeDocument('/project/a.yaml'), refMap }], { + base: CWD, + resolveRef, + }); + + expect(graph.edges).toEqual([ + { from: 'a.yaml', to: 'b.yaml', refs: ['b.yaml'] }, + { from: 'b.yaml', to: 'a.yaml', refs: ['a.yaml'] }, + ]); + }); +}); diff --git a/packages/cli/src/commands/tree/__tests__/build-structure.test.ts b/packages/cli/src/commands/tree/__tests__/build-structure.test.ts new file mode 100644 index 0000000000..9cd5fa3bda --- /dev/null +++ b/packages/cli/src/commands/tree/__tests__/build-structure.test.ts @@ -0,0 +1,534 @@ +import { + analyzeApi, + BaseResolver, + createConfig, + detectSpec, + getTypes, + normalizeTypes, + Source, + type Document, +} from '@redocly/openapi-core'; +import * as path from 'node:path'; + +import type { DependencyGraph } from '../types.js'; + +const CWD = '/project'; +const ROOT_ABS = '/project/openapi.yaml'; + +async function structureOf( + parsed: Record, + externalRefResolver: BaseResolver = new BaseResolver() +): Promise { + const rootDocument = { source: new Source(ROOT_ABS, ''), parsed } as Document; + const specVersion = detectSpec(parsed); + const config = await createConfig({}); + const types = normalizeTypes(config.extendTypes(getTypes(specVersion), specVersion), config); + const { graph } = await analyzeApi({ + rootDocument, + specVersion, + types, + externalRefResolver, + cwd: CWD, + resolveRef: (base, uri) => path.resolve(path.dirname(base), uri), + }); + return graph; +} + +function edgeRefs(graph: DependencyGraph, from: string, to: string): string[] | undefined { + return graph.edges.find((edge) => edge.from === from && edge.to === to)?.refs; +} + +describe('tree structure graph', () => { + it('attaches operationId to operation nodes when defined', async () => { + const graph = await structureOf({ + openapi: '3.0.0', + info: { title: 't', version: '1' }, + paths: { + '/pets': { + get: { operationId: 'listPets', responses: { '200': { description: 'ok' } } }, + post: { responses: { '201': { description: 'created' } } }, + }, + }, + }); + + expect(graph.nodes.find((node) => node.id === 'GET /pets')?.operationId).toBe('listPets'); + expect(graph.nodes.find((node) => node.id === 'POST /pets')?.operationId).toBeUndefined(); + }); + + it('builds the root -> path -> operation spine without refs', async () => { + const graph = await structureOf({ + openapi: '3.0.0', + info: { title: 't', version: '1' }, + paths: { + '/pets': { + get: { responses: { '200': { description: 'ok' } } }, + post: { responses: { '201': { description: 'created' } } }, + }, + '/users': { + get: { responses: { '200': { description: 'ok' } } }, + }, + }, + }); + + expect(graph).toEqual({ + roots: ['openapi.yaml'], + nodes: [ + { id: '/pets', resolved: true, kind: 'path', file: 'openapi.yaml' }, + { id: '/users', resolved: true, kind: 'path', file: 'openapi.yaml' }, + { id: 'GET /pets', resolved: true, kind: 'operation', file: 'openapi.yaml' }, + { id: 'GET /users', resolved: true, kind: 'operation', file: 'openapi.yaml' }, + { id: 'POST /pets', resolved: true, kind: 'operation', file: 'openapi.yaml' }, + { id: 'openapi.yaml', root: true, resolved: true, kind: 'root', file: 'openapi.yaml' }, + ], + edges: [ + { from: '/pets', to: 'GET /pets', refs: [] }, + { from: '/pets', to: 'POST /pets', refs: [] }, + { from: '/users', to: 'GET /users', refs: [] }, + { from: 'openapi.yaml', to: '/pets', refs: [] }, + { from: 'openapi.yaml', to: '/users', refs: [] }, + ], + }); + }); + + it('links an operation to the component it references', async () => { + const graph = await structureOf({ + openapi: '3.0.0', + info: { title: 't', version: '1' }, + paths: { + '/pets': { + get: { + responses: { + '200': { + description: 'ok', + content: { + 'application/json': { schema: { $ref: '#/components/schemas/Pet' } }, + }, + }, + }, + }, + }, + }, + components: { schemas: { Pet: { type: 'object' } } }, + }); + + expect(edgeRefs(graph, 'GET /pets', 'schemas/Pet')).toEqual(['#/components/schemas/Pet']); + expect(graph.nodes).toContainEqual({ + id: 'schemas/Pet', + resolved: true, + kind: 'component', + file: 'openapi.yaml', + }); + }); + + it('follows transitive component-to-component references', async () => { + const graph = await structureOf({ + openapi: '3.0.0', + info: { title: 't', version: '1' }, + paths: { + '/pets': { + get: { + responses: { + '200': { + description: 'ok', + content: { + 'application/json': { schema: { $ref: '#/components/schemas/Pet' } }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Pet: { type: 'object', properties: { home: { $ref: '#/components/schemas/Address' } } }, + Address: { type: 'object' }, + }, + }, + }); + + expect(edgeRefs(graph, 'schemas/Pet', 'schemas/Address')).toEqual([ + '#/components/schemas/Address', + ]); + }); + + it('normalizes a nested target pointer to its top-level component', async () => { + const graph = await structureOf({ + openapi: '3.0.0', + info: { title: 't', version: '1' }, + paths: { + '/pets': { + get: { + responses: { + '200': { + description: 'ok', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Pet/properties/name' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { Pet: { type: 'object', properties: { name: { type: 'string' } } } }, + }, + }); + + expect(edgeRefs(graph, 'GET /pets', 'schemas/Pet')).toEqual([ + '#/components/schemas/Pet/properties/name', + ]); + }); + + it('attributes a path-level parameter ref to the path node', async () => { + const graph = await structureOf({ + openapi: '3.0.0', + info: { title: 't', version: '1' }, + paths: { + '/pets': { + parameters: [{ $ref: '#/components/parameters/PetId' }], + get: { responses: { '200': { description: 'ok' } } }, + }, + }, + components: { + parameters: { PetId: { name: 'petId', in: 'query', schema: { type: 'string' } } }, + }, + }); + + expect(edgeRefs(graph, '/pets', 'parameters/PetId')).toEqual(['#/components/parameters/PetId']); + }); + + it('keeps a self-edge for a recursive schema', async () => { + const graph = await structureOf({ + openapi: '3.0.0', + info: { title: 't', version: '1' }, + paths: { + '/pets': { + get: { + responses: { + '200': { + description: 'ok', + content: { + 'application/json': { schema: { $ref: '#/components/schemas/Node' } }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Node: { type: 'object', properties: { next: { $ref: '#/components/schemas/Node' } } }, + }, + }, + }); + + expect(edgeRefs(graph, 'schemas/Node', 'schemas/Node')).toEqual(['#/components/schemas/Node']); + }); + + it('attributes a callback ref to its outer operation without callback-expression nodes', async () => { + const graph = await structureOf({ + openapi: '3.0.0', + info: { title: 't', version: '1' }, + paths: { + '/pets': { + post: { + responses: { '201': { description: 'created' } }, + callbacks: { + onEvent: { + '{$request.body#/url}': { + post: { + requestBody: { + content: { + 'application/json': { schema: { $ref: '#/components/schemas/Event' } }, + }, + }, + responses: { '200': { description: 'ok' } }, + }, + }, + }, + }, + }, + }, + }, + components: { schemas: { Event: { type: 'object' } } }, + }); + + expect(edgeRefs(graph, 'POST /pets', 'schemas/Event')).toEqual(['#/components/schemas/Event']); + // The callback's `$ref` is attributed to the outer operation: no callback-expression node and + // no extra operation node for the callback's inner POST. (`/pets` is the operation's spine parent.) + expect(graph.nodes.map((node) => node.id)).toEqual([ + '/pets', + 'POST /pets', + 'openapi.yaml', + 'schemas/Event', + ]); + }); + + it('represents a webhook with a root spine edge and its component edge', async () => { + const graph = await structureOf({ + openapi: '3.1.0', + info: { title: 't', version: '1' }, + webhooks: { + newPet: { + post: { + requestBody: { + content: { 'application/json': { schema: { $ref: '#/components/schemas/Pet' } } }, + }, + responses: { '200': { description: 'ok' } }, + }, + }, + }, + components: { schemas: { Pet: { type: 'object' } } }, + }); + + expect(edgeRefs(graph, 'openapi.yaml', 'webhooks/newPet')).toEqual([]); + expect(edgeRefs(graph, 'webhooks/newPet', 'schemas/Pet')).toEqual(['#/components/schemas/Pet']); + }); + + it('represents an unresolved file ref as a resolved:false file node', async () => { + const graph = await structureOf({ + openapi: '3.0.0', + info: { title: 't', version: '1' }, + paths: { + '/pets': { + get: { + responses: { + '200': { + description: 'ok', + content: { + 'application/json': { schema: { $ref: './missing.yaml#/Pet' } }, + }, + }, + }, + }, + }, + }, + }); + + expect(graph.nodes).toContainEqual({ + id: 'missing.yaml', + resolved: false, + kind: 'file', + file: 'missing.yaml', + }); + expect(edgeRefs(graph, 'GET /pets', 'missing.yaml')).toEqual(['./missing.yaml#/Pet']); + }); + + it('represents an external URL component without touching the network', async () => { + const URL_REF = 'https://example.com/shared.yaml#/components/schemas/S'; + // A resolver that never reaches the network for the external URL: it returns a fake document. + class OfflineResolver extends BaseResolver { + async resolveDocument(base: string | null, ref: string, isRoot = false) { + if (ref === 'https://example.com/shared.yaml' || ref === URL_REF) { + return { + source: new Source('https://example.com/shared.yaml', ''), + parsed: { components: { schemas: { S: { type: 'object' } } } }, + } as Document; + } + return super.resolveDocument(base, ref, isRoot); + } + } + + const graph = await structureOf( + { + openapi: '3.0.0', + info: { title: 't', version: '1' }, + paths: { + '/pets': { + get: { + responses: { + '200': { + description: 'ok', + content: { 'application/json': { schema: { $ref: URL_REF } } }, + }, + }, + }, + }, + }, + }, + new OfflineResolver() + ); + + expect(graph.nodes).toContainEqual({ + id: URL_REF, + external: true, + resolved: true, + kind: 'component', + file: 'https://example.com/shared.yaml', + }); + expect(edgeRefs(graph, 'GET /pets', URL_REF)).toEqual([URL_REF]); + }); + + it('prunes components that are unreachable from the root', async () => { + const graph = await structureOf({ + openapi: '3.0.0', + info: { title: 't', version: '1' }, + paths: { + '/pets': { + get: { + responses: { + '200': { + description: 'ok', + content: { + 'application/json': { schema: { $ref: '#/components/schemas/Pet' } }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Pet: { type: 'object' }, + Orphan: { type: 'object' }, + }, + }, + }); + + expect(graph.nodes.map((node) => node.id)).not.toContain('schemas/Orphan'); + expect(graph.nodes.map((node) => node.id)).toContain('schemas/Pet'); + }); + + it('maps OAS2 definitions referenced from a response', async () => { + const graph = await structureOf({ + swagger: '2.0', + info: { title: 't', version: '1' }, + paths: { + '/pets': { + get: { + responses: { '200': { description: 'ok', schema: { $ref: '#/definitions/Pet' } } }, + }, + }, + }, + definitions: { Pet: { type: 'object' } }, + }); + + expect(edgeRefs(graph, 'GET /pets', 'definitions/Pet')).toEqual(['#/definitions/Pet']); + expect(graph.nodes).toContainEqual({ + id: 'definitions/Pet', + resolved: true, + kind: 'component', + file: 'openapi.yaml', + }); + }); + + it('emits nodes sorted by codepoint for deterministic output', async () => { + // Uppercase 'Z' (0x5A) sorts before lowercase 'a' (0x61); path ids ('/…') sort before 'G' + // (0x47 > 0x2F '/') which sorts before 'o' ('openapi.yaml') and 's' ('schemas/…'). + const graph = await structureOf({ + openapi: '3.0.0', + info: { title: 't', version: '1' }, + paths: { + '/Zebra': { + get: { + responses: { + '200': { + description: 'ok', + content: { 'application/json': { schema: { $ref: '#/components/schemas/Apple' } } }, + }, + }, + }, + }, + '/apple': { get: { responses: { '200': { description: 'ok' } } } }, + }, + components: { schemas: { Apple: { type: 'object' } } }, + }); + + expect(graph.nodes.map((node) => node.id)).toEqual([ + '/Zebra', + '/apple', + 'GET /Zebra', + 'GET /apple', + 'openapi.yaml', + 'schemas/Apple', + ]); + }); + + it('fan-in: two operations referencing the same component produce one node and two edges', async () => { + const graph = await structureOf({ + openapi: '3.0.0', + info: { title: 't', version: '1' }, + paths: { + '/pets': { + get: { + responses: { + '200': { + description: 'ok', + content: { + 'application/json': { schema: { $ref: '#/components/schemas/Shared' } }, + }, + }, + }, + }, + }, + '/users': { + get: { + responses: { + '200': { + description: 'ok', + content: { + 'application/json': { schema: { $ref: '#/components/schemas/Shared' } }, + }, + }, + }, + }, + }, + }, + components: { schemas: { Shared: { type: 'object' } } }, + }); + + expect(graph.nodes.filter((node) => node.id === 'schemas/Shared')).toHaveLength(1); + expect(edgeRefs(graph, 'GET /pets', 'schemas/Shared')).toEqual(['#/components/schemas/Shared']); + expect(edgeRefs(graph, 'GET /users', 'schemas/Shared')).toEqual([ + '#/components/schemas/Shared', + ]); + }); +}); + +describe('tree structure graph (multi-file parity)', () => { + const sampleSplit = path.join(process.cwd(), 'tests/e2e/tree/sample-split/openapi.yaml'); + + async function structureGraphOf(apiPath: string): Promise { + const config = await createConfig({}); + const externalRefResolver = new BaseResolver(); + const rootDocument = await externalRefResolver.resolveDocument(null, apiPath, true); + if (rootDocument instanceof Error) throw rootDocument; + const specVersion = detectSpec(rootDocument.parsed); + const types = normalizeTypes(config.extendTypes(getTypes(specVersion), specVersion), config); + const { graph } = await analyzeApi({ + rootDocument, + specVersion, + types, + externalRefResolver, + cwd: path.dirname(apiPath), + resolveRef: (base, uri) => path.resolve(path.dirname(base), uri), + }); + return graph; + } + + it('walks a split multi-file description into real file nodes and cross-file edges', async () => { + const graph = await structureGraphOf(sampleSplit); + const nodes = graph.nodes.map((node) => ({ id: node.id, kind: node.kind })); + + // Operations come from the $ref'd path files, each keeping its own operationId. + expect(nodes).toContainEqual({ id: 'GET /orders', kind: 'operation' }); + expect(nodes).toContainEqual({ id: 'POST /orders', kind: 'operation' }); + + // The root's `components.schemas.*` whole-file aliases keep their canonical `schemas/Name` + // ids (with the real defining file attached) — split and single-file layouts produce the + // same component ids. + expect(nodes).toContainEqual({ id: 'schemas/Order', kind: 'component' }); + expect(nodes).toContainEqual({ id: 'schemas/OrderList', kind: 'component' }); + expect(graph.nodes.find((node) => node.id === 'components/schemas/Order.yaml')).toBeUndefined(); + expect(graph.nodes.find((node) => node.id === 'schemas/Order')?.file).toBe( + 'components/schemas/Order.yaml' + ); + + // Transitive component-to-component chains survive across files under semantic ids. + expect( + graph.edges.some((edge) => edge.from === 'schemas/Order' && edge.to.startsWith('schemas/')) + ).toBe(true); + }); +}); diff --git a/packages/cli/src/commands/tree/__tests__/filter-affected.test.ts b/packages/cli/src/commands/tree/__tests__/filter-affected.test.ts new file mode 100644 index 0000000000..b690b674bb --- /dev/null +++ b/packages/cli/src/commands/tree/__tests__/filter-affected.test.ts @@ -0,0 +1,199 @@ +import { filterAffected, filterOperations, limitGraphLevel } from '../filter-affected.js'; +import type { DependencyGraph } from '../types.js'; + +const graph: DependencyGraph = { + roots: ['openapi.yaml'], + nodes: [ + { id: 'components/Address.yaml', resolved: true }, + { id: 'components/User.yaml', resolved: true }, + { id: 'openapi.yaml', root: true, resolved: true }, + { id: 'paths/pets.yaml', resolved: true }, + { id: 'paths/users.yaml', resolved: true }, + ], + edges: [ + { from: 'components/User.yaml', to: 'components/Address.yaml', refs: ['Address.yaml'] }, + { from: 'openapi.yaml', to: 'paths/pets.yaml', refs: ['paths/pets.yaml'] }, + { from: 'openapi.yaml', to: 'paths/users.yaml', refs: ['paths/users.yaml'] }, + { from: 'paths/users.yaml', to: 'components/User.yaml', refs: ['../components/User.yaml'] }, + ], +}; + +describe('filterAffected', () => { + it('returns the changed file plus all transitive dependents up to the root', () => { + const affected = filterAffected(graph, ['components/Address.yaml']); + + expect(affected.nodes.map((node) => node.id)).toEqual([ + 'components/Address.yaml', + 'components/User.yaml', + 'openapi.yaml', + 'paths/users.yaml', + ]); + expect(affected.roots).toEqual(['openapi.yaml']); + }); + + it('excludes edges leading to untouched branches', () => { + const affected = filterAffected(graph, ['components/Address.yaml']); + + expect(affected.edges).toEqual([ + { from: 'components/User.yaml', to: 'components/Address.yaml', refs: ['Address.yaml'] }, + { from: 'openapi.yaml', to: 'paths/users.yaml', refs: ['paths/users.yaml'] }, + { from: 'paths/users.yaml', to: 'components/User.yaml', refs: ['../components/User.yaml'] }, + ]); + }); + + it('returns an empty graph when no changed ids are known', () => { + expect(filterAffected(graph, [])).toEqual({ roots: [], nodes: [], edges: [] }); + }); + + it('terminates on cyclic graphs and returns the full cycle', () => { + const cyclic: DependencyGraph = { + roots: ['a.yaml'], + nodes: [ + { id: 'a.yaml', root: true, resolved: true }, + { id: 'b.yaml', resolved: true }, + ], + edges: [ + { from: 'a.yaml', to: 'b.yaml', refs: ['b.yaml'] }, + { from: 'b.yaml', to: 'a.yaml', refs: ['a.yaml'] }, + ], + }; + + expect(filterAffected(cyclic, ['b.yaml'])).toEqual(cyclic); + }); + + it('ignores changed ids that are not nodes of the graph', () => { + expect(filterAffected(graph, ['ghost.yaml'])).toEqual({ roots: [], nodes: [], edges: [] }); + }); +}); + +describe('filterAffected — container seeds include their subtree', () => { + const structure: DependencyGraph = { + roots: ['openapi.yaml'], + nodes: [ + { id: 'openapi.yaml', root: true, resolved: true, kind: 'root' }, + { id: '/pets', resolved: true, kind: 'path' }, + { id: 'GET /pets', resolved: true, kind: 'operation' }, + { id: 'schemas/Pet', resolved: true, kind: 'component' }, + { id: 'schemas/Tag', resolved: true, kind: 'component' }, + { id: '/other', resolved: true, kind: 'path' }, + { id: 'GET /other', resolved: true, kind: 'operation' }, + ], + edges: [ + { from: 'openapi.yaml', to: '/pets', refs: [] }, + { from: '/pets', to: 'GET /pets', refs: [] }, + { from: 'GET /pets', to: 'schemas/Pet', refs: ['#/components/schemas/Pet'] }, + { from: 'schemas/Pet', to: 'schemas/Tag', refs: ['#/components/schemas/Tag'] }, + { from: 'openapi.yaml', to: '/other', refs: [] }, + { from: '/other', to: 'GET /other', refs: [] }, + ], + }; + + it('a path seed includes its operations and component chain (forward) plus the root (reverse)', () => { + const affected = filterAffected(structure, ['/pets']); + expect(affected.nodes.map((node) => node.id).sort()).toEqual([ + '/pets', + 'GET /pets', + 'openapi.yaml', + 'schemas/Pet', + 'schemas/Tag', + ]); + }); + + it('a root seed yields the whole tree', () => { + const affected = filterAffected(structure, ['openapi.yaml']); + expect(affected.nodes.length).toBe(structure.nodes.length); + }); + + it('a component seed stays reverse-only — its users, not its own dependencies', () => { + const affected = filterAffected(structure, ['schemas/Pet']); + expect(affected.nodes.map((node) => node.id).sort()).toEqual([ + '/pets', + 'GET /pets', + 'openapi.yaml', + 'schemas/Pet', + ]); + }); +}); + +describe('filterOperations', () => { + const structure: DependencyGraph = { + roots: ['openapi.yaml'], + nodes: [ + { id: 'openapi.yaml', root: true, resolved: true, kind: 'root' }, + { id: '/pets', resolved: true, kind: 'path' }, + { id: 'GET /pets', resolved: true, kind: 'operation' }, + { id: 'parameters/PetId', resolved: true, kind: 'component' }, + { id: 'schemas/Pet', resolved: true, kind: 'component' }, + { id: 'webhooks/newPet', resolved: true, kind: 'component' }, + ], + edges: [ + { from: 'openapi.yaml', to: '/pets', refs: [] }, + { from: 'openapi.yaml', to: 'webhooks/newPet', refs: [] }, + { from: '/pets', to: 'GET /pets', refs: [] }, + { from: '/pets', to: 'parameters/PetId', refs: ['#/components/parameters/PetId'] }, + { from: 'GET /pets', to: 'schemas/Pet', refs: ['#/components/schemas/Pet'] }, + { from: 'webhooks/newPet', to: 'schemas/Pet', refs: ['#/components/schemas/Pet'] }, + ], + }; + + it('keeps paths, operations, and webhook entries — no components', () => { + const surface = filterOperations(structure); + + expect(surface.nodes.map((node) => node.id)).toEqual([ + 'openapi.yaml', + '/pets', + 'GET /pets', + 'webhooks/newPet', + ]); + expect(surface.edges).toEqual([ + { from: 'openapi.yaml', to: '/pets', refs: [] }, + { from: 'openapi.yaml', to: 'webhooks/newPet', refs: [] }, + { from: '/pets', to: 'GET /pets', refs: [] }, + ]); + }); +}); + +describe('limitGraphLevel', () => { + const structure: DependencyGraph = { + roots: ['openapi.yaml'], + nodes: [ + { id: 'openapi.yaml', root: true, resolved: true, kind: 'root' }, + { id: '/pets', resolved: true, kind: 'path' }, + { id: 'GET /pets', resolved: true, kind: 'operation' }, + { id: 'parameters/PetId', resolved: true, kind: 'component' }, + { id: 'schemas/Pet', resolved: true, kind: 'component' }, + ], + edges: [ + { from: 'openapi.yaml', to: '/pets', refs: [] }, + { from: '/pets', to: 'GET /pets', refs: [] }, + { from: '/pets', to: 'parameters/PetId', refs: ['#/components/parameters/PetId'] }, + { from: 'GET /pets', to: 'parameters/PetId', refs: ['#/components/parameters/PetId'] }, + { from: 'GET /pets', to: 'schemas/Pet', refs: ['#/components/schemas/Pet'] }, + ], + }; + + it('keeps only nodes within maxLevel steps from the root', () => { + const limited = limitGraphLevel(structure, 1); + + expect(limited.nodes.map((node) => node.id)).toEqual(['openapi.yaml', '/pets']); + expect(limited.edges).toEqual([{ from: 'openapi.yaml', to: '/pets', refs: [] }]); + }); + + it('keeps a fan-in node reachable within the level and every edge between kept nodes', () => { + const limited = limitGraphLevel(structure, 2); + + // parameters/PetId is 2 steps away via /pets, so it stays — including its edge from GET /pets. + expect(limited.nodes.map((node) => node.id).sort()).toEqual([ + '/pets', + 'GET /pets', + 'openapi.yaml', + 'parameters/PetId', + ]); + expect(limited.edges).toContainEqual({ + from: 'GET /pets', + to: 'parameters/PetId', + refs: ['#/components/parameters/PetId'], + }); + expect(limited.nodes.map((node) => node.id)).not.toContain('schemas/Pet'); + }); +}); diff --git a/packages/cli/src/commands/tree/__tests__/filter-index.test.ts b/packages/cli/src/commands/tree/__tests__/filter-index.test.ts new file mode 100644 index 0000000000..028a1df767 --- /dev/null +++ b/packages/cli/src/commands/tree/__tests__/filter-index.test.ts @@ -0,0 +1,86 @@ +import type { ApiIndex } from '@redocly/openapi-core'; + +import { filterIndexByIds, filterIndexSections, limitIndexLevel } from '../filter-index.js'; + +const INDEX: ApiIndex = { + docName: 'openapi.yaml', + spec: 'oas3_0', + structure: [ + { id: 'Overview', title: 'Overview' }, + { + id: 'Operations', + title: 'Operations', + nodes: [ + { + id: 'Tickets', + title: 'Tickets', + nodes: [ + // Shares its `file` with the schemas/Order leaf below on purpose: an operation must + // never be kept by file alone, only a component leaf may be. + { + id: 'GET /tickets', + title: 'GET /tickets', + file: 'components/schemas/Order.yaml', + }, + { id: 'POST /tickets', title: 'POST /tickets' }, + ], + }, + ], + }, + { + id: 'Webhooks', + title: 'Webhooks', + nodes: [{ id: 'POST newTicket', title: 'POST newTicket' }], + }, + { + id: 'Components', + title: 'Components', + nodes: [ + { + id: 'components/schemas', + title: 'schemas', + nodes: [{ id: 'schemas/Order', title: 'Order', file: 'components/schemas/Order.yaml' }], + }, + ], + }, + ], +}; + +describe('filterIndexByIds', () => { + it('keeps ancestors of kept ids and drops the rest', () => { + const filtered = filterIndexByIds(INDEX, new Set(['POST /tickets'])); + expect(filtered.structure.map((section) => section.id)).toEqual(['Operations']); + expect(filtered.structure[0].nodes![0].nodes!.map((node) => node.id)).toEqual([ + 'POST /tickets', + ]); + }); + + it('matches component leaves by their semantic id for split and inline alike', () => { + // Graph and index share the id space (split aliases keep `section/Name` ids in the graph), + // so a keep-set of graph ids prunes the index without any file-based fallback. + const filtered = filterIndexByIds(INDEX, new Set(['schemas/Order'])); + expect(filtered.structure.map((section) => section.id)).toEqual(['Components']); + const schemas = filtered.structure[0].nodes!.find((node) => node.id === 'components/schemas')!; + expect(schemas.nodes!.map((node) => node.id)).toEqual(['schemas/Order']); + }); +}); + +describe('limitIndexLevel', () => { + it('prunes below the requested depth', () => { + const limited = limitIndexLevel(INDEX, 1); + expect(limited.structure.map((section) => section.id)).toEqual([ + 'Overview', + 'Operations', + 'Webhooks', + 'Components', + ]); + expect(limited.structure[1].nodes).toBeUndefined(); + }); +}); + +describe('filterIndexSections', () => { + it('keeps only the named sections', () => { + const filtered = filterIndexSections(INDEX, ['Operations', 'Webhooks']); + expect(filtered.structure.map((section) => section.id)).toEqual(['Operations', 'Webhooks']); + }); +}); diff --git a/packages/cli/src/commands/tree/__tests__/match-affected-by.test.ts b/packages/cli/src/commands/tree/__tests__/match-affected-by.test.ts new file mode 100644 index 0000000000..c53fc9d0f6 --- /dev/null +++ b/packages/cli/src/commands/tree/__tests__/match-affected-by.test.ts @@ -0,0 +1,214 @@ +import { matchAffectedBy } from '../match-affected-by.js'; +import type { DependencyGraph } from '../types.js'; + +const CWD = '/project'; + +const graph: DependencyGraph = { + roots: ['openapi.yaml'], + nodes: [ + { id: '/pets', resolved: true, kind: 'path', file: 'openapi.yaml' }, + { id: 'GET /pets', resolved: true, kind: 'operation', file: 'openapi.yaml' }, + { + id: 'common.yaml#/components/schemas/Pet', + resolved: true, + kind: 'component', + file: 'common.yaml', + }, + { id: 'openapi.yaml', root: true, resolved: true, kind: 'root', file: 'openapi.yaml' }, + { id: 'parameters/Pet', resolved: true, kind: 'component', file: 'openapi.yaml' }, + { id: 'schemas/Address', resolved: true, kind: 'component', file: 'openapi.yaml' }, + { id: 'schemas/Pet', resolved: true, kind: 'component', file: 'openapi.yaml' }, + ], + edges: [], +}; + +const ROOT_ID = 'openapi.yaml'; + +describe('matchAffectedBy', () => { + it('case 1: exact node id match', () => { + expect(matchAffectedBy(graph, ['schemas/Address'], { cwd: CWD, rootId: ROOT_ID })).toEqual({ + changedIds: ['schemas/Address'], + notes: [], + warnings: [], + }); + }); + + it('matches a shorthand id written with a leading ./', () => { + expect(matchAffectedBy(graph, ['./schemas/Address'], { cwd: CWD, rootId: ROOT_ID })).toEqual({ + changedIds: ['schemas/Address'], + notes: [], + warnings: [], + }); + }); + + it('case 2: exact id wins over bare-name logic — no ambiguity note', () => { + expect(matchAffectedBy(graph, ['schemas/Pet'], { cwd: CWD, rootId: ROOT_ID })).toEqual({ + changedIds: ['schemas/Pet'], + notes: [], + warnings: [], + }); + }); + + it('case 3a: pointer form — component pointer', () => { + expect( + matchAffectedBy(graph, ['#/components/schemas/Pet'], { cwd: CWD, rootId: ROOT_ID }) + ).toEqual({ + changedIds: ['schemas/Pet'], + notes: [], + warnings: [], + }); + }); + + it('case 3b: pointer form — operation pointer', () => { + expect(matchAffectedBy(graph, ['#/paths/~1pets/get'], { cwd: CWD, rootId: ROOT_ID })).toEqual({ + changedIds: ['GET /pets'], + notes: [], + warnings: [], + }); + }); + + it('case 4: a file path matches every node defined in that file', () => { + expect(matchAffectedBy(graph, ['common.yaml'], { cwd: CWD, rootId: ROOT_ID })).toEqual({ + changedIds: ['common.yaml#/components/schemas/Pet'], + notes: [], + warnings: [], + }); + }); + + it('case 5: root file — changedIds gets just the root id (subtree expanded downstream), note emitted', () => { + const result = matchAffectedBy(graph, ['openapi.yaml'], { cwd: CWD, rootId: ROOT_ID }); + + expect(result.changedIds).toEqual(['openapi.yaml']); + expect(result.warnings).toEqual([]); + expect(result.notes).toEqual([ + 'openapi.yaml is the root document — the whole tree is affected.', + ]); + }); + + it('case 5b: root pointer `#/` behaves like the root file', () => { + const result = matchAffectedBy(graph, ['#/'], { cwd: CWD, rootId: ROOT_ID }); + + expect(result.changedIds).toEqual(['openapi.yaml']); + expect(result.notes).toEqual([ + 'openapi.yaml is the root document — the whole tree is affected.', + ]); + }); + + it('case 6a: bare component name matching multiple — includes all + ambiguity note', () => { + const result = matchAffectedBy(graph, ['Pet'], { cwd: CWD, rootId: ROOT_ID }); + + expect(result.changedIds).toEqual([ + 'common.yaml#/components/schemas/Pet', + 'parameters/Pet', + 'schemas/Pet', + ]); + expect(result.warnings).toEqual([]); + expect(result.notes).toEqual([ + '"Pet" matches multiple components: common.yaml#/components/schemas/Pet, parameters/Pet, schemas/Pet — including all of them.', + ]); + }); + + it('case 6b: bare component name matching exactly one — no note', () => { + expect(matchAffectedBy(graph, ['Address'], { cwd: CWD, rootId: ROOT_ID })).toEqual({ + changedIds: ['schemas/Address'], + notes: [], + warnings: [], + }); + }); + + it('expands a * wildcard against all node ids', () => { + expect(matchAffectedBy(graph, ['schemas/*'], { cwd: CWD, rootId: ROOT_ID })).toEqual({ + changedIds: ['schemas/Address', 'schemas/Pet'], + notes: [], + warnings: [], + }); + }); + + it('anchors wildcards — a pattern does not match inside longer ids', () => { + expect(matchAffectedBy(graph, ['schemas/Pe*'], { cwd: CWD, rootId: ROOT_ID })).toEqual({ + // Must not match common.yaml#/components/schemas/Pet. + changedIds: ['schemas/Pet'], + notes: [], + warnings: [], + }); + }); + + it('matches paths with a wildcard', () => { + expect(matchAffectedBy(graph, ['/pe*'], { cwd: CWD, rootId: ROOT_ID })).toEqual({ + changedIds: ['/pets'], + notes: [], + warnings: [], + }); + }); + + it('warns for a wildcard that matches nothing', () => { + expect(matchAffectedBy(graph, ['schemas/Ghost*'], { cwd: CWD, rootId: ROOT_ID })).toEqual({ + changedIds: [], + notes: [], + warnings: [ + 'schemas/Ghost* does not match any path, operation, or component of openapi.yaml.', + ], + }); + }); + + it('case 7: unknown input — empty arrays + warning', () => { + expect(matchAffectedBy(graph, ['Ghost'], { cwd: CWD, rootId: ROOT_ID })).toEqual({ + changedIds: [], + notes: [], + warnings: ['Ghost does not match any path, operation, or component of openapi.yaml.'], + }); + }); + + it('case 7b: mixed call — known input still matched, warning for unknown', () => { + const result = matchAffectedBy(graph, ['Ghost', 'schemas/Address'], { + cwd: CWD, + rootId: ROOT_ID, + }); + + expect(result.changedIds).toEqual(['schemas/Address']); + expect(result.warnings).toEqual([ + 'Ghost does not match any path, operation, or component of openapi.yaml.', + ]); + expect(result.notes).toEqual([]); + }); + + it('case 8: dedup — Pet + schemas/Pet → schemas/Pet appears once in changedIds', () => { + const result = matchAffectedBy(graph, ['Pet', 'schemas/Pet'], { cwd: CWD, rootId: ROOT_ID }); + + expect(result.changedIds).toEqual([ + 'common.yaml#/components/schemas/Pet', + 'parameters/Pet', + 'schemas/Pet', + ]); + }); + + it('warns for a pointer that maps to no node instead of bare-name matching', () => { + expect( + matchAffectedBy(graph, ['#/components/schemas/Missing'], { cwd: CWD, rootId: 'openapi.yaml' }) + ).toEqual({ + changedIds: [], + notes: [], + warnings: [ + '#/components/schemas/Missing does not match any path, operation, or component of openapi.yaml.', + ], + }); + }); + + it('does not bare-match non-component nodes', () => { + expect(matchAffectedBy(graph, ['pets'], { cwd: CWD, rootId: 'openapi.yaml' })).toEqual({ + changedIds: [], + notes: [], + warnings: ['pets does not match any path, operation, or component of openapi.yaml.'], + }); + }); + + it('points an unmatched file path to --files (structure mode is bundled)', () => { + expect(matchAffectedBy(graph, ['paths/pets.yaml'], { cwd: CWD, rootId: ROOT_ID })).toEqual({ + changedIds: [], + notes: [], + warnings: [ + 'paths/pets.yaml does not match any path, operation, or component of openapi.yaml. For file-level analysis, use `--files`.', + ], + }); + }); +}); diff --git a/packages/cli/src/commands/tree/__tests__/print.test.ts b/packages/cli/src/commands/tree/__tests__/print.test.ts new file mode 100644 index 0000000000..8ab729d527 --- /dev/null +++ b/packages/cli/src/commands/tree/__tests__/print.test.ts @@ -0,0 +1,314 @@ +import { renderDot } from '../print/dot.js'; +import { renderJson } from '../print/json.js'; +import { renderMermaid } from '../print/mermaid.js'; +import { renderStylish } from '../print/stylish.js'; +import type { DependencyGraph } from '../types.js'; + +const graph: DependencyGraph = { + roots: ['openapi.yaml'], + nodes: [ + { id: 'components/Pet.yaml', resolved: true }, + { id: 'components/User.yaml', resolved: true }, + { id: 'components/missing.yaml', resolved: false }, + { id: 'https://example.com/shared.yaml', external: true, resolved: true }, + { id: 'openapi.yaml', root: true, resolved: true }, + { id: 'paths/pets.yaml', resolved: true }, + { id: 'paths/users.yaml', resolved: true }, + ], + edges: [ + { from: 'components/User.yaml', to: 'components/Pet.yaml', refs: ['Pet.yaml'] }, + { from: 'components/User.yaml', to: 'components/missing.yaml', refs: ['missing.yaml'] }, + { + from: 'components/User.yaml', + to: 'https://example.com/shared.yaml', + refs: ['https://example.com/shared.yaml#/Address'], + }, + { from: 'openapi.yaml', to: 'paths/pets.yaml', refs: ['paths/pets.yaml'] }, + { from: 'openapi.yaml', to: 'paths/users.yaml', refs: ['paths/users.yaml'] }, + { from: 'paths/pets.yaml', to: 'components/Pet.yaml', refs: ['../components/Pet.yaml'] }, + { from: 'paths/users.yaml', to: 'components/User.yaml', refs: ['../components/User.yaml'] }, + ], +}; + +describe('renderStylish', () => { + it('renders a tree with broken-ref and external markers', () => { + expect(renderStylish(graph)).toMatchInlineSnapshot(` + "openapi.yaml + ├── paths/pets.yaml + │ └── components/Pet.yaml + └── paths/users.yaml + └── components/User.yaml + ├── components/Pet.yaml + ├── components/missing.yaml ❌ + └── https://example.com/shared.yaml 🔗" + `); + }); + + it('appends a summary in affected mode', () => { + const affected: DependencyGraph = { + roots: ['openapi.yaml'], + nodes: [ + { id: 'components/Pet.yaml', resolved: true }, + { id: 'components/User.yaml', resolved: true }, + { id: 'openapi.yaml', root: true, resolved: true }, + { id: 'paths/pets.yaml', resolved: true }, + { id: 'paths/users.yaml', resolved: true }, + ], + edges: [ + { from: 'components/User.yaml', to: 'components/Pet.yaml', refs: ['Pet.yaml'] }, + { from: 'openapi.yaml', to: 'paths/pets.yaml', refs: ['paths/pets.yaml'] }, + { from: 'openapi.yaml', to: 'paths/users.yaml', refs: ['paths/users.yaml'] }, + { from: 'paths/pets.yaml', to: 'components/Pet.yaml', refs: ['../components/Pet.yaml'] }, + { from: 'paths/users.yaml', to: 'components/User.yaml', refs: ['../components/User.yaml'] }, + ], + }; + + expect( + renderStylish(affected, { + summary: '5 of 7 files affected · affected roots: openapi.yaml', + }) + ).toMatchInlineSnapshot(` + "openapi.yaml + ├── paths/pets.yaml + │ └── components/Pet.yaml + └── paths/users.yaml + └── components/User.yaml + └── components/Pet.yaml + + 5 of 7 files affected · affected roots: openapi.yaml" + `); + }); + + it('reports when nothing is affected', () => { + expect(renderStylish({ roots: [], nodes: [], edges: [] }, {})).toMatchInlineSnapshot( + `"No files affected."` + ); + }); + + it('renders one tree per root and re-expands shared files in each tree', () => { + const multiRoot: DependencyGraph = { + roots: ['a.yaml', 'b.yaml'], + nodes: [ + { id: 'a.yaml', root: true, resolved: true }, + { id: 'b.yaml', root: true, resolved: true }, + { id: 'shared.yaml', resolved: true }, + ], + edges: [ + { from: 'a.yaml', to: 'shared.yaml', refs: ['shared.yaml'] }, + { from: 'b.yaml', to: 'shared.yaml', refs: ['shared.yaml'] }, + ], + }; + + expect(renderStylish(multiRoot)).toMatchInlineSnapshot(` + "a.yaml + └── shared.yaml + + b.yaml + └── shared.yaml" + `); + }); + + it('marks a true cycle with 🔁 and stops expanding', () => { + const cyclic: DependencyGraph = { + roots: ['root.yaml'], + nodes: [ + { id: 'root.yaml', root: true, resolved: true }, + { id: 'A.yaml', resolved: true }, + { id: 'B.yaml', resolved: true }, + ], + edges: [ + { from: 'root.yaml', to: 'A.yaml', refs: ['A.yaml'] }, + { from: 'A.yaml', to: 'B.yaml', refs: ['B.yaml'] }, + { from: 'B.yaml', to: 'A.yaml', refs: ['A.yaml'] }, + ], + }; + + expect(renderStylish(cyclic)).toMatchInlineSnapshot(` + "root.yaml + └── A.yaml + └── B.yaml + └── A.yaml 🔁" + `); + }); + + it('shows operationId next to the method when showOperationId is set', () => { + const structure: DependencyGraph = { + roots: ['openapi.yaml'], + nodes: [ + { id: 'openapi.yaml', root: true, resolved: true, kind: 'root' }, + { id: '/pets', resolved: true, kind: 'path' }, + { id: 'GET /pets', resolved: true, kind: 'operation', operationId: 'listPets' }, + { id: 'POST /pets', resolved: true, kind: 'operation' }, + ], + edges: [ + { from: 'openapi.yaml', to: '/pets', refs: [] }, + { from: '/pets', to: 'GET /pets', refs: [] }, + { from: '/pets', to: 'POST /pets', refs: [] }, + ], + }; + + expect(renderStylish(structure, { showOperationId: true })).toMatchInlineSnapshot(` + "openapi.yaml + └── /pets + ├── GET (listPets) + └── POST" + `); + + // Without the option the id stays hidden. + expect(renderStylish(structure)).not.toContain('listPets'); + }); + + it('cuts the tree at maxLevel and marks pruned branches with …', () => { + const structure: DependencyGraph = { + roots: ['openapi.yaml'], + nodes: [ + { id: 'openapi.yaml', root: true, resolved: true, kind: 'root' }, + { id: '/pets', resolved: true, kind: 'path' }, + { id: 'GET /pets', resolved: true, kind: 'operation' }, + { id: 'schemas/Pet', resolved: true, kind: 'component' }, + { id: '/stores', resolved: true, kind: 'path' }, + ], + edges: [ + { from: 'openapi.yaml', to: '/pets', refs: [] }, + { from: 'openapi.yaml', to: '/stores', refs: [] }, + { from: '/pets', to: 'GET /pets', refs: [] }, + { from: 'GET /pets', to: 'schemas/Pet', refs: ['#/components/schemas/Pet'] }, + ], + }; + + // A node at the cut level gets the marker only when it actually has hidden children. + expect(renderStylish(structure, { maxLevel: 1 })).toMatchInlineSnapshot(` + "openapi.yaml + ├── /pets … + └── /stores" + `); + + expect(renderStylish(structure, { maxLevel: 2 })).toMatchInlineSnapshot(` + "openapi.yaml + ├── /pets + │ └── GET … + └── /stores" + `); + }); + + it('re-expands a fan-in dependency (shared, non-cyclic) under every parent', () => { + const fanIn: DependencyGraph = { + roots: ['root.yaml'], + nodes: [ + { id: 'root.yaml', root: true, resolved: true }, + { id: 'P1.yaml', resolved: true }, + { id: 'P2.yaml', resolved: true }, + { id: 'Response.yaml', resolved: true }, + { id: 'Error.yaml', resolved: true }, + ], + edges: [ + { from: 'root.yaml', to: 'P1.yaml', refs: ['P1.yaml'] }, + { from: 'root.yaml', to: 'P2.yaml', refs: ['P2.yaml'] }, + { from: 'P1.yaml', to: 'Response.yaml', refs: ['Response.yaml'] }, + { from: 'P2.yaml', to: 'Response.yaml', refs: ['Response.yaml'] }, + { from: 'Response.yaml', to: 'Error.yaml', refs: ['Error.yaml'] }, + ], + }; + + expect(renderStylish(fanIn)).toMatchInlineSnapshot(` + "root.yaml + ├── P1.yaml + │ └── Response.yaml + │ └── Error.yaml + └── P2.yaml + └── Response.yaml + └── Error.yaml" + `); + }); + + it('renders operations as the method only under their path', () => { + const structure: DependencyGraph = { + roots: ['openapi.yaml'], + nodes: [ + { id: 'openapi.yaml', root: true, resolved: true, kind: 'root' }, + { id: '/pets', resolved: true, kind: 'path' }, + { id: 'GET /pets', resolved: true, kind: 'operation' }, + { id: 'POST /pets', resolved: true, kind: 'operation' }, + ], + edges: [ + { from: 'openapi.yaml', to: '/pets', refs: [] }, + { from: '/pets', to: 'GET /pets', refs: [] }, + { from: '/pets', to: 'POST /pets', refs: [] }, + ], + }; + + expect(renderStylish(structure)).toMatchInlineSnapshot(` + "openapi.yaml + └── /pets + ├── GET + └── POST" + `); + }); +}); + +describe('renderMermaid', () => { + it('renders a flowchart with stable ids and a root class', () => { + expect(renderMermaid(graph)).toMatchInlineSnapshot(` + "flowchart LR + n0["components/Pet.yaml"] + n1["components/User.yaml"] + n2["components/missing.yaml"] + n3["https://example.com/shared.yaml"] + n4["openapi.yaml"]:::root + n5["paths/pets.yaml"] + n6["paths/users.yaml"] + n1 --> n0 + n1 --> n2 + n1 --> n3 + n4 --> n5 + n4 --> n6 + n5 --> n0 + n6 --> n1 + classDef root font-weight:bold" + `); + }); + + it('escapes "#" in labels so mermaid does not read it as an entity', () => { + const withHash: DependencyGraph = { + roots: ['openapi.yaml'], + nodes: [ + { id: 'openapi.yaml', root: true, resolved: true }, + { id: 'components.yaml#/components/schemas/Pet', resolved: true }, + ], + edges: [ + { + from: 'openapi.yaml', + to: 'components.yaml#/components/schemas/Pet', + refs: ['./components.yaml#/components/schemas/Pet'], + }, + ], + }; + + const output = renderMermaid(withHash); + expect(output).toContain('["components.yaml#35;/components/schemas/Pet"]'); + expect(output).not.toContain('["components.yaml#/components/schemas/Pet"]'); + }); +}); + +describe('renderJson', () => { + it('emits a nodes/links graph (D3 shape) without roots/edges keys', () => { + const json = JSON.parse(renderJson(graph)); + expect(json.nodes).toEqual(graph.nodes); + expect(json.links).toContainEqual({ + source: 'openapi.yaml', + target: 'paths/pets.yaml', + refs: ['paths/pets.yaml'], + }); + expect(json).not.toHaveProperty('roots'); + expect(json).not.toHaveProperty('edges'); + }); +}); + +describe('renderDot', () => { + it('emits a Graphviz digraph with quoted ids and directed edges', () => { + const dot = renderDot(graph); + expect(dot.startsWith('digraph')).toBe(true); + expect(dot).toContain('"openapi.yaml" -> "paths/pets.yaml"'); + expect(dot).toContain('"https://example.com/shared.yaml"'); + }); +}); diff --git a/packages/cli/src/commands/tree/build-graph.ts b/packages/cli/src/commands/tree/build-graph.ts new file mode 100644 index 0000000000..e19540f408 --- /dev/null +++ b/packages/cli/src/commands/tree/build-graph.ts @@ -0,0 +1,56 @@ +import { isAbsoluteUrl, type Document, type ResolvedRefMap } from '@redocly/openapi-core'; + +import { compareStrings, toNodeId } from './node-id.js'; +import type { DependencyGraph, GraphEdge, GraphNode } from './types.js'; + +export function buildGraph( + resolutions: Array<{ rootDocument: Document; refMap: ResolvedRefMap }>, + options: { base: string; resolveRef: (base: string, uri: string) => string } +): DependencyGraph { + const { base, resolveRef } = options; + const nodes = new Map(); + const edges = new Map(); + + const upsertNode = (id: string, resolved: boolean, root?: boolean) => { + const node = nodes.get(id) ?? { id, resolved: false }; + if (resolved) node.resolved = true; + if (root) node.root = true; + if (isAbsoluteUrl(id)) node.external = true; + nodes.set(id, node); + }; + + for (const { rootDocument, refMap } of resolutions) { + upsertNode(toNodeId(rootDocument.source.absoluteRef, base), true, true); + + for (const [refId, resolvedRef] of refMap) { + if (!resolvedRef.isRemote) continue; + + const separatorIndex = refId.indexOf('::'); + const sourceAbsolute = refId.slice(0, separatorIndex); + const refString = refId.slice(separatorIndex + 2); + const targetAbsolute = + resolvedRef.document?.source.absoluteRef ?? + resolveRef(sourceAbsolute, refString.split('#')[0]); + + const from = toNodeId(sourceAbsolute, base); + const to = toNodeId(targetAbsolute, base); + upsertNode(from, true); + upsertNode(to, resolvedRef.document !== undefined); + + const edgeKey = `${from} -> ${to}`; + const edge = edges.get(edgeKey) ?? { from, to, refs: [] }; + if (!edge.refs.includes(refString)) { + edge.refs.push(refString); + } + edges.set(edgeKey, edge); + } + } + + return { + roots: resolutions.map(({ rootDocument }) => toNodeId(rootDocument.source.absoluteRef, base)), + nodes: [...nodes.values()].sort((a, b) => compareStrings(a.id, b.id)), + edges: [...edges.values()] + .map((edge) => ({ ...edge, refs: [...edge.refs].sort(compareStrings) })) + .sort((a, b) => compareStrings(a.from, b.from) || compareStrings(a.to, b.to)), + }; +} diff --git a/packages/cli/src/commands/tree/filter-affected.ts b/packages/cli/src/commands/tree/filter-affected.ts new file mode 100644 index 0000000000..41a071e227 --- /dev/null +++ b/packages/cli/src/commands/tree/filter-affected.ts @@ -0,0 +1,79 @@ +import { collectConnectedIds } from '@redocly/openapi-core'; + +import type { DependencyGraph } from './types.js'; + +export { collectConnectedIds }; + +export function filterOperations(graph: DependencyGraph): DependencyGraph { + // The API surface: paths, operations, and webhook entries. Webhook nodes carry the generic + // `component` kind, so they are matched by their id instead. + const kept = new Set( + graph.nodes + .filter( + (node) => + node.kind === 'root' || + node.kind === 'path' || + node.kind === 'operation' || + node.id.startsWith('webhooks/') + ) + .map((node) => node.id) + ); + + return { + roots: graph.roots, + nodes: graph.nodes.filter((node) => kept.has(node.id)), + edges: graph.edges.filter((edge) => kept.has(edge.from) && kept.has(edge.to)), + }; +} + +export function limitGraphLevel(graph: DependencyGraph, maxLevel: number): DependencyGraph { + const children = new Map(); + for (const edge of graph.edges) { + const list = children.get(edge.from) ?? []; + list.push(edge.to); + children.set(edge.from, list); + } + + // BFS from the roots: keep everything reachable in at most `maxLevel` steps. + const kept = new Set(graph.roots); + let frontier = graph.roots; + for (let level = 0; level < maxLevel; level++) { + const next: string[] = []; + for (const id of frontier) { + for (const child of children.get(id) ?? []) { + if (!kept.has(child)) { + kept.add(child); + next.push(child); + } + } + } + frontier = next; + } + + return { + roots: graph.roots, + nodes: graph.nodes.filter((node) => kept.has(node.id)), + edges: graph.edges.filter((edge) => kept.has(edge.from) && kept.has(edge.to)), + }; +} + +export function filterAffected(graph: DependencyGraph, changedIds: string[]): DependencyGraph { + const nodesById = new Map(graph.nodes.map((node) => [node.id, node])); + const affected = collectConnectedIds(changedIds, graph.edges, { reverse: true }); + + const containerSeeds = changedIds.filter((id) => { + const node = nodesById.get(id); + return ( + node?.root || node?.kind === 'root' || node?.kind === 'path' || node?.kind === 'operation' + ); + }); + for (const id of collectConnectedIds(containerSeeds, graph.edges, { reverse: false })) { + affected.add(id); + } + + return { + roots: graph.roots.filter((root) => affected.has(root)), + nodes: graph.nodes.filter((node) => affected.has(node.id)), + edges: graph.edges.filter((edge) => affected.has(edge.from) && affected.has(edge.to)), + }; +} diff --git a/packages/cli/src/commands/tree/filter-index.ts b/packages/cli/src/commands/tree/filter-index.ts new file mode 100644 index 0000000000..8db14ebc35 --- /dev/null +++ b/packages/cli/src/commands/tree/filter-index.ts @@ -0,0 +1,42 @@ +import type { ApiIndex, ApiIndexNode } from '@redocly/openapi-core'; + +// Index ids and graph ids share the same semantic space (split component aliases keep their +// `section/Name` ids in the graph), so pruning is pure id-matching. +export function filterIndexByIds(index: ApiIndex, keepIds: Set): ApiIndex { + return { ...index, structure: keepNodes(index.structure, keepIds) }; +} + +function keepNodes(nodes: ApiIndexNode[], keepIds: Set): ApiIndexNode[] { + const kept: ApiIndexNode[] = []; + for (const node of nodes) { + const keptChildren = node.nodes ? keepNodes(node.nodes, keepIds) : []; + if (keepIds.has(node.id) && keptChildren.length === 0) { + kept.push(node.nodes ? { ...node, nodes: undefined } : node); + } else if (keepIds.has(node.id) || keptChildren.length > 0) { + kept.push({ ...node, nodes: keptChildren }); + } + } + return kept; +} + +export function limitIndexLevel(index: ApiIndex, level: number): ApiIndex { + return { ...index, structure: pruneBelow(index.structure, level, 1) }; +} + +function pruneBelow(nodes: ApiIndexNode[], maxLevel: number, depth: number): ApiIndexNode[] { + return nodes.map((node) => { + if (!node.nodes) return node; + if (depth >= maxLevel) { + const { nodes: _dropped, ...rest } = node; + return rest; + } + return { ...node, nodes: pruneBelow(node.nodes, maxLevel, depth + 1) }; + }); +} + +export function filterIndexSections(index: ApiIndex, sectionIds: string[]): ApiIndex { + return { + ...index, + structure: index.structure.filter((section) => sectionIds.includes(section.id)), + }; +} diff --git a/packages/cli/src/commands/tree/index.ts b/packages/cli/src/commands/tree/index.ts new file mode 100644 index 0000000000..223940f88f --- /dev/null +++ b/packages/cli/src/commands/tree/index.ts @@ -0,0 +1,378 @@ +import { + analyzeApi, + appendDepsClosure, + BaseResolver, + buildApiIndex, + buildNodeEnvelope, + detectSpec, + findIndexNode, + getTypes, + hasIndexLocation, + logger, + normalizeTypes, + resolveDocument, + slash, + type CollectFn, + type Document, + type IndexGroupBy, + type NormalizedNodeType, + type ResolvedRefMap, + type SpecVersion, +} from '@redocly/openapi-core'; +import { writeFileSync } from 'node:fs'; +import * as path from 'node:path'; + +import type { Entrypoint, VerifyConfigOptions } from '../../types.js'; +import { exitWithError } from '../../utils/error.js'; +import { getFallbackApisOrExit } from '../../utils/miscellaneous.js'; +import type { CommandArgs } from '../../wrapper.js'; +import { buildGraph } from './build-graph.js'; +import { filterAffected, filterOperations, limitGraphLevel } from './filter-affected.js'; +import { filterIndexByIds, filterIndexSections, limitIndexLevel } from './filter-index.js'; +import { matchAffectedBy, wildcardToRegExp } from './match-affected-by.js'; +import { commonDir } from './node-id.js'; +import { renderDot } from './print/dot.js'; +import { renderIndexJson } from './print/index-json.js'; +import { renderJson } from './print/json.js'; +import { renderMermaid } from './print/mermaid.js'; +import { renderStylish, type StylishOptions } from './print/stylish.js'; +import type { DependencyGraph, TreeFormat } from './types.js'; + +export type TreeArgv = { + apis?: string[]; + format: TreeFormat; + output?: string; + level?: number; + operations?: boolean; + uses?: string[]; + files?: boolean; + 'group-by': IndexGroupBy; + node?: string; + 'with-deps'?: boolean; +} & VerifyConfigOptions; + +type TreeModeContext = { + argv: TreeArgv; + config: CommandArgs['config']; + collectSpecData: CommandArgs['collectSpecData']; + externalRefResolver: BaseResolver; + cwd: string; +}; + +export async function handleTree({ argv, config, collectSpecData }: CommandArgs) { + if (argv.level !== undefined && (!Number.isInteger(argv.level) || argv.level < 1)) { + return exitWithError('The --level value must be a positive integer.'); + } + + const apis = await getFallbackApisOrExit(argv.apis, config); + const externalRefResolver = new BaseResolver(config.resolve); + const cwd = process.cwd(); + + if (argv.files && argv.node !== undefined) { + return exitWithError( + 'The --node option applies to the structure view and cannot be combined with --files.' + ); + } + + if (argv.files) { + if (argv.operations) { + return exitWithError( + 'The --operations option applies to the structure view and cannot be combined with --files.' + ); + } + return handleFilesMode({ apis, argv, config, collectSpecData, externalRefResolver, cwd }); + } + + if (apis.length > 1) { + return exitWithError( + 'The tree command shows the structure of one API description at a time. Pass a single API, or use --files for the multi-API file-level graph.' + ); + } + + return handleStructureMode({ + api: apis[0], + argv, + config, + collectSpecData, + externalRefResolver, + cwd, + }); +} + +async function loadApi({ + apiPath, + config, + collectSpecData, + externalRefResolver, +}: { + apiPath: string; + config: CommandArgs['config']; + collectSpecData?: CollectFn; + externalRefResolver: BaseResolver; +}): Promise<{ + rootDocument: Document; + specVersion: SpecVersion; + types: Record; +}> { + const rootDocument = await externalRefResolver.resolveDocument(null, apiPath, true); + if (rootDocument instanceof Error) { + return exitWithError(`Failed to load ${apiPath}: ${rootDocument.message}`); + } + collectSpecData?.(rootDocument); + const specVersion = detectSpec(rootDocument.parsed); + const types = normalizeTypes(config.extendTypes(getTypes(specVersion), specVersion), config); + return { rootDocument, specVersion, types }; +} + +async function handleFilesMode({ + apis, + argv, + config, + collectSpecData, + externalRefResolver, + cwd, +}: TreeModeContext & { apis: Entrypoint[] }): Promise { + const resolutions: Array<{ rootDocument: Document; refMap: ResolvedRefMap }> = []; + for (const { path: apiPath } of apis) { + const { rootDocument, types } = await loadApi({ + apiPath, + config, + collectSpecData, + externalRefResolver, + }); + const refMap = await resolveDocument({ + rootDocument, + rootType: types.Root, + externalRefResolver, + }); + resolutions.push({ rootDocument, refMap }); + } + + const base = commonDir( + resolutions.map(({ rootDocument }) => path.dirname(rootDocument.source.absoluteRef)) + ); + + const graph = buildGraph(resolutions, { + base, + resolveRef: (refBase, uri) => externalRefResolver.resolveExternalRef(refBase, uri), + }); + + let printedGraph = graph; + let stylishOptions: StylishOptions = {}; + if (argv['uses']) { + const knownIds = new Set(graph.nodes.map((node) => node.id)); + // Match paths the way they are displayed — relative to the API root — and fall + // back to paths relative to the current working directory. A `*`/`?` wildcard + // matches the displayed file ids directly. + const changedIds = argv['uses'].flatMap((file) => { + if (/[*?]/.test(file)) { + const matcher = wildcardToRegExp(file); + const matches = graph.nodes.map((node) => node.id).filter((id) => matcher.test(id)); + if (matches.length === 0) { + logger.warn(`${file} does not match any file of the processed APIs.\n`); + } + return matches; + } + const fromRoot = slash(path.relative(base, path.resolve(base, file))); + if (knownIds.has(fromRoot)) return [fromRoot]; + const fromCwd = slash(path.relative(base, path.resolve(cwd, file))); + return [knownIds.has(fromCwd) ? fromCwd : fromRoot]; + }); + for (const id of changedIds) { + if (!knownIds.has(id)) { + logger.warn(`${id} is not referenced by any of the processed APIs.\n`); + } + } + const knownChanged = changedIds.filter((id) => knownIds.has(id)); + printedGraph = filterAffected(graph, knownChanged); + stylishOptions = { + summary: `${printedGraph.nodes.length} of ${graph.nodes.length} files affected · affected roots: ${ + printedGraph.roots.join(', ') || 'none' + }`, + }; + } + + renderOutput(printedGraph, argv, stylishOptions); +} + +async function handleStructureMode({ + api, + argv, + config, + collectSpecData, + externalRefResolver, + cwd, +}: TreeModeContext & { api: Entrypoint }): Promise { + const { rootDocument, specVersion, types } = await loadApi({ + apiPath: api.path, + config, + collectSpecData, + externalRefResolver, + }); + + const analysis = await analyzeApi({ + rootDocument, + specVersion, + types, + externalRefResolver, + cwd, + resolveRef: (base, uri) => externalRefResolver.resolveExternalRef(base, uri), + }); + const graph = analysis.graph; + + for (const node of graph.nodes) { + if (!node.resolved) { + logger.warn(`Could not resolve ${node.id} — shown as unresolved (❌).\n`); + } + } + + const isOpenApi = specVersion.startsWith('oas'); + + if (!isOpenApi && (argv.node !== undefined || argv['with-deps'])) { + return exitWithError( + 'The --node, --with-deps, and --group-by options support OpenAPI descriptions only for now.' + ); + } + + if (argv.node !== undefined) { + const fullIndex = buildApiIndex(analysis, { specVersion, cwd, groupBy: argv['group-by'] }); + const indexNode = findIndexNode(fullIndex.structure, argv.node); + if (!indexNode) { + return exitWithError( + `No index node matches "${argv.node}". Run \`redocly tree --format=json\` to list node ids.` + ); + } + if (indexNode.nodes !== undefined && indexNode.nodes.length > 0) { + // The sub-index is shaped like a one-section top-level index, so --level applies as-is. + const subIndex = { ...fullIndex, structure: [indexNode] }; + const limited = argv.level !== undefined ? limitIndexLevel(subIndex, argv.level) : subIndex; + emitRendered(renderIndexJson(limited), argv); + return; + } + if (!hasIndexLocation(indexNode)) { + return exitWithError( + `Node "${indexNode.id}" has no source location. Pick one of its child nodes.` + ); + } + let envelope = buildNodeEnvelope({ indexNode, analysis, cwd }); + if (argv['with-deps']) { + envelope = appendDepsClosure({ envelope, indexNode, analysis, index: fullIndex, cwd }); + } + emitRendered(JSON.stringify(envelope, null, 2), argv); + return; + } + + const index = + argv.format === 'json' && isOpenApi + ? buildApiIndex(analysis, { specVersion, cwd, groupBy: argv['group-by'] }) + : undefined; + + // Structure mode resolves exactly one API (handleTree rejects more), so there is a single root. + const rootId = graph.roots[0]; + + let printedGraph = graph; + let stylishOptions: StylishOptions = {}; + + if (argv['uses']) { + const match = matchAffectedBy(graph, argv['uses'], { cwd, rootId }); + + for (const note of match.notes) { + logger.warn(note + '\n'); + } + for (const warning of match.warnings) { + logger.warn(warning + '\n'); + } + + printedGraph = filterAffected(graph, match.changedIds); + + const totalOperations = graph.nodes.filter((node) => node.kind === 'operation').length; + const affectedOperations = printedGraph.nodes.filter( + (node) => node.kind === 'operation' + ).length; + const affectedPaths = printedGraph.nodes + .filter((node) => node.kind === 'path') + .map((node) => node.id); + const summary = + totalOperations > 0 + ? `${affectedOperations} of ${totalOperations} operations affected · affected paths: ${affectedPaths.join(', ') || 'none'}` + : `${printedGraph.nodes.length} of ${graph.nodes.length} nodes affected`; + + stylishOptions = { + summary, + emptyMessage: 'No nodes affected.', + }; + } + + if (argv.operations) { + printedGraph = filterOperations(printedGraph); + stylishOptions = { ...stylishOptions, showOperationId: true }; + } + + if (index !== undefined) { + let printedIndex = index; + if (argv['uses']) { + const keepIds = new Set(printedGraph.nodes.map((node) => node.id)); + printedIndex = filterIndexByIds(printedIndex, keepIds); + if (index.structure.some((section) => section.id === 'Webhooks')) { + logger.warn( + 'Webhooks are not part of the dependency graph yet, so they are omitted from --uses-filtered output.\n' + ); + } + } + if (argv.operations) { + printedIndex = filterIndexSections(printedIndex, ['Operations', 'Webhooks']); + } + if (argv.level !== undefined) { + printedIndex = limitIndexLevel(printedIndex, argv.level); + } + emitRendered(renderIndexJson(printedIndex), argv); + return; + } + + renderOutput(printedGraph, argv, stylishOptions); +} + +function renderOutput( + graph: DependencyGraph, + argv: TreeArgv, + stylishOptions: StylishOptions +): void { + let printedGraph = graph; + if (argv.level !== undefined) { + // The stylish view cuts by DISPLAY depth (matching `tree -L`); graph formats have no display + // depth, so they keep the nodes within `level` steps of the root instead. + if (argv.format === 'stylish') { + stylishOptions = { ...stylishOptions, maxLevel: argv.level }; + } else { + printedGraph = limitGraphLevel(printedGraph, argv.level); + } + } + const rendered = renderGraph(printedGraph, argv.format, stylishOptions); + emitRendered(rendered, argv); +} + +function emitRendered(rendered: string, argv: TreeArgv): void { + if (argv.output) { + writeFileSync(argv.output, rendered + '\n'); + logger.info(`Tree written to ${argv.output}\n`); + return; + } + logger.output(rendered + '\n'); +} + +function renderGraph( + graph: DependencyGraph, + format: TreeFormat, + stylishOptions: StylishOptions +): string { + switch (format) { + case 'json': + return renderJson(graph); + case 'mermaid': + return renderMermaid(graph); + case 'dot': + return renderDot(graph); + default: + return renderStylish(graph, stylishOptions); + } +} diff --git a/packages/cli/src/commands/tree/match-affected-by.ts b/packages/cli/src/commands/tree/match-affected-by.ts new file mode 100644 index 0000000000..d7344aa58c --- /dev/null +++ b/packages/cli/src/commands/tree/match-affected-by.ts @@ -0,0 +1,102 @@ +import { slash } from '@redocly/openapi-core'; +import * as path from 'node:path'; + +import { mapRootPointer } from './node-id.js'; +import type { DependencyGraph } from './types.js'; + +export type AffectedByMatch = { + changedIds: string[]; + notes: string[]; + warnings: string[]; +}; + +export function wildcardToRegExp(pattern: string): RegExp { + const escaped = pattern + .replace(/[.+^${}()|[\]\\]/g, '\\$&') + .replace(/\*/g, '.*') + .replace(/\?/g, '.'); + return new RegExp(`^${escaped}$`); +} + +export function matchAffectedBy( + graph: DependencyGraph, + inputs: string[], + options: { cwd: string; rootId: string } +): AffectedByMatch { + const { cwd, rootId } = options; + const nodeIds = new Set(graph.nodes.map((n) => n.id)); + + const changedSet = new Set(); + const notes: string[] = []; + const warnings: string[] = []; + + for (const input of inputs) { + // A `*`/`?` wildcard matches against every node id. + if (/[*?]/.test(input)) { + const matcher = wildcardToRegExp(input); + const matches = graph.nodes.filter((n) => matcher.test(n.id)).map((n) => n.id); + if (matches.length > 0) { + for (const id of matches) changedSet.add(id); + continue; + } + warnings.push(`${input} does not match any path, operation, or component of ${rootId}.`); + continue; + } + + const rel = slash(path.relative(cwd, path.resolve(cwd, input))); + const pointer = input.startsWith('#') ? mapRootPointer(input, rootId) : undefined; + + // The root — as the root file path or the `#/` pointer — affects the whole tree. Seed just the + // root id; `filterAffected` expands it to the full subtree. + if (rel === rootId || pointer?.id === rootId) { + changedSet.add(rootId); + notes.push(`${rootId} is the root document — the whole tree is affected.`); + continue; + } + + // Exact node id (shorthand) wins, tolerating a `./`-relative spelling. + const exactId = nodeIds.has(input) ? input : nodeIds.has(rel) ? rel : undefined; + if (exactId) { + changedSet.add(exactId); + continue; + } + + if (pointer && nodeIds.has(pointer.id)) { + changedSet.add(pointer.id); + continue; + } + const fileMatches = graph.nodes.filter((n) => n.file === rel).map((n) => n.id); + if (fileMatches.length > 0) { + for (const id of fileMatches) changedSet.add(id); + continue; + } + + if (!input.includes('/') && !input.includes('#')) { + const componentMatches = graph.nodes + .filter((n) => n.kind === 'component') + .filter((n) => n.id.split('/').at(-1) === input) + .map((n) => n.id); + if (componentMatches.length > 0) { + for (const id of componentMatches) changedSet.add(id); + if (componentMatches.length > 1) { + notes.push( + `"${input}" matches multiple components: ${componentMatches.join(', ')} — including all of them.` + ); + } + continue; + } + } + + let warning = `${input} does not match any path, operation, or component of ${rootId}.`; + if (/\.(ya?ml|json)$/i.test(input)) { + warning += ' For file-level analysis, use `--files`.'; + } + warnings.push(warning); + } + + return { + changedIds: Array.from(changedSet), + notes, + warnings, + }; +} diff --git a/packages/cli/src/commands/tree/node-id.ts b/packages/cli/src/commands/tree/node-id.ts new file mode 100644 index 0000000000..d6dad8b098 --- /dev/null +++ b/packages/cli/src/commands/tree/node-id.ts @@ -0,0 +1,10 @@ +export { + commonDir, + compareStrings, + mapForeignLocation, + mapRootPointer, + OPERATION_METHODS, + parsePointerSegments, + toNodeId, + type MappedNode, +} from '@redocly/openapi-core'; diff --git a/packages/cli/src/commands/tree/print/dot.ts b/packages/cli/src/commands/tree/print/dot.ts new file mode 100644 index 0000000000..f416c48f65 --- /dev/null +++ b/packages/cli/src/commands/tree/print/dot.ts @@ -0,0 +1,16 @@ +import type { DependencyGraph } from '../types.js'; + +const quote = (value: string): string => `"${value.replace(/(["\\])/g, '\\$1')}"`; + +/** Renders the graph as Graphviz DOT — consumable by Graphviz and most graph-drawing tools. */ +export function renderDot(graph: DependencyGraph): string { + const lines = ['digraph tree {']; + for (const node of graph.nodes) { + lines.push(` ${quote(node.id)}${node.root ? ' [shape=box, style=bold]' : ''};`); + } + for (const edge of graph.edges) { + lines.push(` ${quote(edge.from)} -> ${quote(edge.to)};`); + } + lines.push('}'); + return lines.join('\n'); +} diff --git a/packages/cli/src/commands/tree/print/index-json.ts b/packages/cli/src/commands/tree/print/index-json.ts new file mode 100644 index 0000000000..fb51d7e771 --- /dev/null +++ b/packages/cli/src/commands/tree/print/index-json.ts @@ -0,0 +1,5 @@ +import type { ApiIndex } from '@redocly/openapi-core'; + +export function renderIndexJson(index: ApiIndex): string { + return JSON.stringify(index, null, 2); +} diff --git a/packages/cli/src/commands/tree/print/json.ts b/packages/cli/src/commands/tree/print/json.ts new file mode 100644 index 0000000000..d9eae43dc8 --- /dev/null +++ b/packages/cli/src/commands/tree/print/json.ts @@ -0,0 +1,9 @@ +import type { DependencyGraph } from '../types.js'; + +export function renderJson(graph: DependencyGraph): string { + const data = { + nodes: graph.nodes, + links: graph.edges.map(({ from, to, refs }) => ({ source: from, target: to, refs })), + }; + return JSON.stringify(data, null, 2); +} diff --git a/packages/cli/src/commands/tree/print/mermaid.ts b/packages/cli/src/commands/tree/print/mermaid.ts new file mode 100644 index 0000000000..ad2bafb2b8 --- /dev/null +++ b/packages/cli/src/commands/tree/print/mermaid.ts @@ -0,0 +1,23 @@ +import type { DependencyGraph } from '../types.js'; + +export function renderMermaid(graph: DependencyGraph): string { + const mermaidIds = new Map(graph.nodes.map((node, index) => [node.id, `n${index}`])); + // Escape `#` first: it starts Mermaid HTML-entity codes (e.g. `#quot;`), so a literal `#` + // in an id (foreign-component ids look like `file.yaml#/components/...`) must become `#35;`. + const escapeLabel = (label: string) => label.replace(/#/g, '#35;').replace(/"/g, '#quot;'); + const lines = ['flowchart LR']; + + for (const node of graph.nodes) { + lines.push( + ` ${mermaidIds.get(node.id)}["${escapeLabel(node.id)}"]${node.root ? ':::root' : ''}` + ); + } + for (const edge of graph.edges) { + lines.push(` ${mermaidIds.get(edge.from)} --> ${mermaidIds.get(edge.to)}`); + } + if (graph.nodes.some((node) => node.root)) { + lines.push(' classDef root font-weight:bold'); + } + + return lines.join('\n'); +} diff --git a/packages/cli/src/commands/tree/print/stylish.ts b/packages/cli/src/commands/tree/print/stylish.ts new file mode 100644 index 0000000000..8bacaff123 --- /dev/null +++ b/packages/cli/src/commands/tree/print/stylish.ts @@ -0,0 +1,84 @@ +import { compareStrings } from '../node-id.js'; +import type { DependencyGraph } from '../types.js'; + +export type StylishOptions = { + summary?: string; + emptyMessage?: string; + /** Deepest visible level; branches cut at this level end with `…`. Root is level 0. */ + maxLevel?: number; + /** Append `(operationId)` to operations that define one. */ + showOperationId?: boolean; +}; + +export function renderStylish(graph: DependencyGraph, options: StylishOptions = {}): string { + if (graph.nodes.length === 0) { + return options.emptyMessage ?? 'No files affected.'; + } + + const childrenByNode = new Map(); + for (const edge of graph.edges) { + const children = childrenByNode.get(edge.from) ?? []; + children.push(edge.to); + childrenByNode.set(edge.from, children); + } + for (const children of childrenByNode.values()) { + children.sort(compareStrings); + } + + const nodesById = new Map(graph.nodes.map((node) => [node.id, node])); + const lines: string[] = []; + + const label = (id: string, parentId: string | undefined, isCycle: boolean): string => { + const node = nodesById.get(id); + let text = id; + // An operation id is " "; under its own path, show just the method. + if (node?.kind === 'operation' && parentId && id.endsWith(` ${parentId}`)) { + text = id.slice(0, -parentId.length - 1); + } + if (node?.kind === 'operation' && options.showOperationId && node.operationId) { + text += ` (${node.operationId})`; + } + if (node?.external) text += ' 🔗'; + if (node && !node.resolved) text += ' ❌'; + if (isCycle) text += ' 🔁'; + return text; + }; + + // `ancestors` is the path from the root to the current node. A child already on that path is a + // cycle: mark it with `🔁` and stop, so traversal terminates. A fan-in dependency (the same file + // reached from several parents, without forming a cycle) is expanded under each parent. + // `level` is the child's distance from the root; at `maxLevel` the branch is cut with `…`. + const renderSubtree = (id: string, prefix: string, ancestors: Set, level: number) => { + const children = childrenByNode.get(id) ?? []; + children.forEach((child, index) => { + const isLast = index === children.length - 1; + const isCycle = ancestors.has(child); + const atLimit = options.maxLevel !== undefined && level >= options.maxLevel; + const hasHiddenChildren = atLimit && !isCycle && (childrenByNode.get(child)?.length ?? 0) > 0; + lines.push( + `${prefix}${isLast ? '└── ' : '├── '}${label(child, id, isCycle)}${hasHiddenChildren ? ' …' : ''}` + ); + if (!isCycle && !atLimit) { + renderSubtree( + child, + `${prefix}${isLast ? ' ' : '│ '}`, + new Set([...ancestors, child]), + level + 1 + ); + } + }); + }; + + graph.roots.forEach((root, index) => { + if (index > 0) lines.push(''); + lines.push(label(root, undefined, false)); + renderSubtree(root, '', new Set([root]), 1); + }); + + if (options.summary !== undefined) { + lines.push(''); + lines.push(options.summary); + } + + return lines.join('\n'); +} diff --git a/packages/cli/src/commands/tree/types.ts b/packages/cli/src/commands/tree/types.ts new file mode 100644 index 0000000000..2fa2e981ac --- /dev/null +++ b/packages/cli/src/commands/tree/types.ts @@ -0,0 +1,3 @@ +export type TreeFormat = 'stylish' | 'json' | 'mermaid' | 'dot'; + +export type { DependencyGraph, GraphEdge, GraphNode, NodeKind } from '@redocly/openapi-core'; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 894cb5d568..ba9158400c 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -3,9 +3,10 @@ import './utils/assert-node-version.js'; import { logger, + type ComponentNamesStrategy, + type IndexGroupBy, type OutputFormat, type RuleSeverity, - type ComponentNamesStrategy, } from '@redocly/openapi-core'; import * as dotenv from 'dotenv'; import * as path from 'node:path'; @@ -44,6 +45,8 @@ import type { import { handleSplit } from './commands/split/index.js'; import { handleStats } from './commands/stats/index.js'; import { handleTranslations } from './commands/translations.js'; +import { handleTree } from './commands/tree/index.js'; +import type { TreeFormat } from './commands/tree/types.js'; import { handlePushStatus } from './reunite/commands/push-status.js'; import { handlePush } from './reunite/commands/push.js'; import { outputExtensions } from './types.js'; @@ -87,6 +90,73 @@ yargs(hideBin(process.argv)) commandWrapper(handleStats)(argv); } ) + .command( + 'tree [apis...]', + 'Display the structure of an API description as a tree.', + (yargs) => + yargs + .env('REDOCLY_CLI_TREE') + .positional('apis', { array: true, type: 'string' }) + .option({ + config: { description: 'Path to the config file.', type: 'string' }, + 'lint-config': { + description: 'Severity level for config file linting.', + choices: ['warn', 'error', 'off'] as ReadonlyArray, + default: 'warn' as RuleSeverity, + }, + format: { + description: 'Use a specific output format.', + choices: ['stylish', 'json', 'mermaid', 'dot'] as ReadonlyArray, + default: 'stylish' as TreeFormat, + }, + 'group-by': { + description: 'Group operations in the JSON index by tags or by paths.', + choices: ['tags', 'paths'] as ReadonlyArray, + default: 'tags' as IndexGroupBy, + }, + node: { + description: + 'Print one JSON-index node: a branch returns its sub-index, a leaf returns its raw source lines and refs. Accepts a semantic id or file#/pointer.', + type: 'string' as const, + requiresArg: true, + }, + 'with-deps': { + description: 'With --node on a leaf: append the transitive $ref closure.', + type: 'boolean' as const, + default: false, + }, + output: { + alias: 'o', + description: 'Write the output to a file instead of stdout.', + type: 'string', + }, + level: { + description: 'Limit the displayed depth of the tree.', + type: 'number', + requiresArg: true, + }, + operations: { + description: 'Show only the API surface: paths, operations, and webhooks.', + type: 'boolean', + default: false, + }, + uses: { + description: + 'Show only the part of the tree that uses (depends on) the given components, paths, or files. Accepts `*` and `?` wildcards.', + array: true, + type: 'string', + requiresArg: true, + }, + files: { + description: 'Show the file-level $ref graph instead of the document structure.', + type: 'boolean', + default: false, + }, + }), + (argv) => { + commandWrapper(handleTree)(argv); + } + ) .command( 'score [api]', 'Score an API description for integration simplicity and agent readiness.', diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index 14b2a0f2da..362f1e9586 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -14,6 +14,7 @@ import type { RespectArgv } from './commands/respect/index.js'; import type { SplitArgv } from './commands/split/types.js'; import type { StatsArgv } from './commands/stats/index.js'; import type { TranslationsArgv } from './commands/translations.js'; +import type { TreeArgv } from './commands/tree/index.js'; import type { PushStatusArgv } from './reunite/commands/push-status.js'; import type { PushArgv } from './reunite/commands/push.js'; @@ -31,6 +32,7 @@ export const outputExtensions = ['json', 'yaml', 'yml'] as const; export type OutputExtension = (typeof outputExtensions)[number]; export type CommandArgv = | StatsArgv + | TreeArgv | SplitArgv | JoinArgv | LintArgv diff --git a/packages/core/src/__tests__/entity.test.ts b/packages/core/src/__tests__/entity.test.ts index 8bd2e04e8e..1cf1c6e0ab 100644 --- a/packages/core/src/__tests__/entity.test.ts +++ b/packages/core/src/__tests__/entity.test.ts @@ -2,8 +2,11 @@ import { entityFileSchema, entityFileDefaultSchema } from '@redocly/config'; import { outdent } from 'outdent'; import { describe, it, expect } from 'vitest'; +import { lintEntityFile } from '../lint-entity.js'; +import { makeDocumentFromString, BaseResolver } from '../resolve.js'; import { createEntityTypes } from '../types/entity.js'; import { type NormalizedNodeType, normalizeTypes, type ResolveTypeFn } from '../types/index.js'; + describe('entity-yaml', () => { it('should create entity types with discriminator', () => { const { entityTypes } = createEntityTypes(entityFileSchema, entityFileDefaultSchema); @@ -72,9 +75,6 @@ describe('entity-yaml', () => { }); it('should correctly discriminate between different entity types in an array', async () => { - const { lintEntityFile } = await import('../lint-entity.js'); - const { makeDocumentFromString, BaseResolver } = await import('../resolve.js'); - const entities = outdent` - type: user key: john-doe diff --git a/packages/core/src/api-graph/__tests__/analyze.test.ts b/packages/core/src/api-graph/__tests__/analyze.test.ts new file mode 100644 index 0000000000..32ea99c7ee --- /dev/null +++ b/packages/core/src/api-graph/__tests__/analyze.test.ts @@ -0,0 +1,79 @@ +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { detectSpec } from '../../detect-spec.js'; +import { getTypes } from '../../oas-types.js'; +import { BaseResolver, type Document } from '../../resolve.js'; +import { normalizeTypes } from '../../types/index.js'; +import { analyzeApi, type ApiAnalysis } from '../build-graph.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +async function analyzeFixture(fixtureRoot: string): Promise { + const resolver = new BaseResolver(); + const rootDocument = (await resolver.resolveDocument( + null, + join(fixtureRoot, 'openapi.yaml'), + true + )) as Document; + const specVersion = detectSpec(rootDocument.parsed); + const types = normalizeTypes(getTypes(specVersion), {}); + return analyzeApi({ + rootDocument, + specVersion, + types, + externalRefResolver: resolver, + cwd: fixtureRoot, + resolveRef: (base, uri) => join(dirname(base), uri), + }); +} + +describe('analyzeApi', () => { + it('collects index metadata alongside the graph in one walk', async () => { + const { graph, meta } = await analyzeFixture(join(__dirname, 'fixtures', 'split')); + + expect(meta.info).toMatchObject({ + title: 'Split API', + description: 'Multi-file description for api-graph tests.', + }); + expect(meta.servers?.urls).toEqual(['https://api.example.com/v1']); + expect(meta.declaredTags.map((tag) => tag.name)).toEqual(['Tickets']); + expect(meta.declaredTags[0].description).toBe('Buy tickets and manage reservations.'); + + const buyTickets = meta.operations.find((operation) => operation.id === 'POST /tickets'); + expect(buyTickets).toMatchObject({ + method: 'POST', + containerKey: '/tickets', + isWebhook: false, + tags: ['Tickets'], + summary: 'Buy museum tickets', + operationId: 'buyTickets', + }); + expect(buyTickets!.location.source.absoluteRef.endsWith('paths/tickets.yaml')).toBe(true); + + const ticketComponent = meta.components.find( + (component) => component.section === 'schemas' && component.name === 'Ticket' + ); + expect(ticketComponent).toBeDefined(); + expect(ticketComponent!.location.source.absoluteRef.endsWith('Ticket.yaml')).toBe(true); + + expect(meta.pathsLocation).toBeDefined(); + expect(meta.componentsLocation).toBeDefined(); + + // The graph is unchanged by metadata collection. + expect(graph.nodes.some((node) => node.id === 'POST /tickets')).toBe(true); + }); + + it('collects webhook operations for the index without adding them to the graph', async () => { + const { graph, meta } = await analyzeFixture(join(__dirname, 'fixtures', 'webhooks')); + + const alert = meta.operations.find((operation) => operation.isWebhook); + expect(alert).toMatchObject({ + method: 'POST', + containerKey: 'newTicket', + summary: 'New ticket alert', + }); + expect(meta.webhooksLocation).toBeDefined(); + expect(graph.nodes.some((node) => node.id === 'POST newTicket')).toBe(false); + }); +}); diff --git a/packages/core/src/api-graph/__tests__/build-graph.test.ts b/packages/core/src/api-graph/__tests__/build-graph.test.ts new file mode 100644 index 0000000000..18b69c6b6b --- /dev/null +++ b/packages/core/src/api-graph/__tests__/build-graph.test.ts @@ -0,0 +1,146 @@ +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { detectSpec } from '../../detect-spec.js'; +import { getTypes } from '../../oas-types.js'; +import { BaseResolver, makeDocumentFromString, type Document } from '../../resolve.js'; +import { normalizeTypes } from '../../types/index.js'; +import { analyzeApi } from '../build-graph.js'; +import type { DependencyGraph } from '../types.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const CWD = '/project'; + +async function graphOfString(yaml: string): Promise { + const document = makeDocumentFromString(yaml, '/project/openapi.yaml'); + return graphOfDocument(document, CWD); +} + +async function graphOfDocument(document: Document, cwd: string): Promise { + const specVersion = detectSpec(document.parsed); + const types = normalizeTypes(getTypes(specVersion), {}); + const { graph } = await analyzeApi({ + rootDocument: document, + specVersion, + types, + externalRefResolver: new BaseResolver(), + cwd, + resolveRef: (base, uri) => join(dirname(base), uri), + }); + return graph; +} + +describe('analyzeApi structure graph', () => { + it('builds the root -> path -> operation spine for a single file', async () => { + const graph = await graphOfString( + [ + 'openapi: 3.0.0', + 'info: { title: t, version: "1" }', + 'paths:', + ' /pets:', + ' get:', + ' operationId: listPets', + " responses: { '200': { description: ok } }", + ].join('\n') + ); + + expect(graph.roots).toEqual(['openapi.yaml']); + const operation = graph.nodes.find((node) => node.id === 'GET /pets'); + expect(operation).toMatchObject({ + kind: 'operation', + operationId: 'listPets', + file: 'openapi.yaml', + resolved: true, + }); + expect(graph.edges).toContainEqual({ from: 'openapi.yaml', to: '/pets', refs: [] }); + expect(graph.edges).toContainEqual({ from: '/pets', to: 'GET /pets', refs: [] }); + }); + + it('marks an unresolvable ref as an unresolved node instead of failing', async () => { + const graph = await graphOfString( + [ + 'openapi: 3.0.0', + 'info: { title: t, version: "1" }', + 'paths:', + ' /pets:', + ' get:', + ' responses:', + " '200':", + ' description: ok', + ' content:', + ' application/json:', + ' schema:', + " $ref: '#/components/schemas/Missing'", + ].join('\n') + ); + + const missing = graph.nodes.find((node) => node.id === 'schemas/Missing'); + expect(missing).toMatchObject({ kind: 'component', resolved: false }); + }); + + it('attaches real files and operationId for a split multi-file description', async () => { + const fixtureRoot = join(__dirname, 'fixtures', 'split'); + const resolver = new BaseResolver(); + const rootDocument = (await resolver.resolveDocument( + null, + join(fixtureRoot, 'openapi.yaml'), + true + )) as Document; + + const graph = await graphOfDocument(rootDocument, fixtureRoot); + + const operation = graph.nodes.find((node) => node.id === 'POST /tickets'); + expect(operation).toMatchObject({ + kind: 'operation', + operationId: 'buyTickets', + file: 'paths/tickets.yaml', + }); + + // The root's `components.schemas.Ticket` alias is a whole-file ref, so the file keeps the + // canonical `schemas/Ticket` id (with the real defining file attached) — the same id the + // bundled walk used to produce. Neither the aliased file nor the path-item file appear as + // separate file nodes. + const ticketSchema = graph.nodes.find((node) => node.id === 'schemas/Ticket'); + expect(ticketSchema).toMatchObject({ + kind: 'component', + file: 'components/schemas/Ticket.yaml', + resolved: true, + }); + expect( + graph.nodes.find((node) => node.id === 'components/schemas/Ticket.yaml') + ).toBeUndefined(); + expect(graph.nodes.find((node) => node.id === 'paths/tickets.yaml')).toBeUndefined(); + + // The operation's response schema $ref lives directly in the operation's own file, so its + // owner is the operation itself — and the whole-file target collapses to the alias id. + expect( + graph.edges.some((edge) => edge.from === 'POST /tickets' && edge.to === 'schemas/Ticket') + ).toBe(true); + // TicketId.yaml has no root alias, so it stays a plain file node behind the schema. + expect( + graph.edges.some( + (edge) => edge.from === 'schemas/Ticket' && edge.to === 'components/schemas/TicketId.yaml' + ) + ).toBe(true); + expect( + graph.edges.some( + (edge) => edge.from === 'paths/tickets.yaml' && edge.to === 'components/schemas/Ticket.yaml' + ) + ).toBe(false); + + const pathNode = graph.nodes.find((node) => node.id === '/tickets'); + expect(pathNode).toMatchObject({ kind: 'path', file: 'paths/tickets.yaml' }); + + // The operation's own `callbacks.onEvent` nested operation must not be misattributed to + // the outer /tickets path: its operationId must not leak onto any node, and it must not + // get its own top-level spine node under a synthesized callback-expression "path". + expect(graph.nodes.some((node) => node.operationId === 'handleEvent')).toBe(false); + expect( + graph.nodes.find((node) => node.id === 'POST {$request.body#/callbackUrl}') + ).toBeUndefined(); + + // A ref inside a callback subtree attributes to the OUTER spine operation, + // never to a callback-owned node or the path-item file. + expect(graph.edges.some((edge) => edge.from === 'paths/tickets.yaml')).toBe(false); + }); +}); diff --git a/packages/core/src/api-graph/__tests__/build-index.test.ts b/packages/core/src/api-graph/__tests__/build-index.test.ts new file mode 100644 index 0000000000..87f08b84dc --- /dev/null +++ b/packages/core/src/api-graph/__tests__/build-index.test.ts @@ -0,0 +1,95 @@ +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { detectSpec } from '../../detect-spec.js'; +import { getTypes } from '../../oas-types.js'; +import { BaseResolver, type Document } from '../../resolve.js'; +import { normalizeTypes } from '../../types/index.js'; +import { analyzeApi } from '../build-graph.js'; +import { buildApiIndex, type ApiIndex, type IndexGroupBy } from '../build-index.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +async function indexOfFixture( + fixtureRoot: string, + groupBy: IndexGroupBy = 'tags' +): Promise { + const resolver = new BaseResolver(); + const rootDocument = (await resolver.resolveDocument( + null, + join(fixtureRoot, 'openapi.yaml'), + true + )) as Document; + const specVersion = detectSpec(rootDocument.parsed); + const types = normalizeTypes(getTypes(specVersion), {}); + const analysis = await analyzeApi({ + rootDocument, + specVersion, + types, + externalRefResolver: resolver, + cwd: fixtureRoot, + resolveRef: (base, uri) => join(dirname(base), uri), + }); + return buildApiIndex(analysis, { specVersion, cwd: fixtureRoot, groupBy }); +} + +describe('buildApiIndex', () => { + it('assembles sections with semantic ids, real files, and line ranges', async () => { + const index = await indexOfFixture(join(__dirname, 'fixtures', 'split')); + + expect(index.docName).toBe('openapi.yaml'); + expect(index.spec).toBe('oas3_0'); + expect(index.docDescription).toBe('Split API — Multi-file description for api-graph tests.'); + expect(index.structure.map((section) => section.id)).toEqual([ + 'Overview', + 'Servers', + 'Operations', + 'Components', + ]); + + const operations = index.structure.find((section) => section.id === 'Operations')!; + expect(operations.pointer).toBe('#/paths'); + const tickets = operations.nodes!.find((group) => group.id === 'Tickets')!; + expect(tickets.summary).toBe('Buy tickets and manage reservations.'); + const buyTickets = tickets.nodes!.find((node) => node.id === 'POST /tickets')!; + expect(buyTickets).toMatchObject({ + title: 'POST /tickets — Buy museum tickets', + operationId: 'buyTickets', + file: 'paths/tickets.yaml', + }); + expect(buyTickets.pointer).toBe('#/post'); + expect(buyTickets.start_line).toBeGreaterThanOrEqual(1); + expect(buyTickets.end_line).toBeGreaterThanOrEqual(buyTickets.start_line!); + + // Phase 1's graph dropped split component aliases; the INDEX restores semantic names, + // pointing at the real defining file. + const components = index.structure.find((section) => section.id === 'Components')!; + const schemas = components.nodes!.find((group) => group.id === 'components/schemas')!; + const ticket = schemas.nodes!.find((node) => node.id === 'schemas/Ticket')!; + expect(ticket.file).toBe('components/schemas/Ticket.yaml'); + expect(ticket.start_line).toBe(1); + }); + + it('groups by paths with --group-by paths', async () => { + const index = await indexOfFixture(join(__dirname, 'fixtures', 'split'), 'paths'); + + const operations = index.structure.find((section) => section.id === 'Operations')!; + const ticketsPath = operations.nodes!.find((group) => group.id === '/tickets')!; + expect(ticketsPath.nodes!.map((node) => node.id)).toEqual(['GET /tickets', 'POST /tickets']); + }); + + it('takes the Servers section from the root list, not from an operation override', async () => { + const index = await indexOfFixture(join(__dirname, 'fixtures', 'server-overrides')); + + const servers = index.structure.find((section) => section.id === 'Servers')!; + expect(servers.summary).toBe('https://api.example.com'); + }); + + it('adds a Webhooks section from webhook operations', async () => { + const index = await indexOfFixture(join(__dirname, 'fixtures', 'webhooks')); + + const webhooks = index.structure.find((section) => section.id === 'Webhooks')!; + expect(webhooks.nodes!.map((node) => node.id)).toEqual(['POST newTicket']); + expect(webhooks.nodes![0].title).toBe('POST newTicket — New ticket alert'); + }); +}); diff --git a/packages/core/src/api-graph/__tests__/fixtures/outside/common/Error.yaml b/packages/core/src/api-graph/__tests__/fixtures/outside/common/Error.yaml new file mode 100644 index 0000000000..6acdcbdd6d --- /dev/null +++ b/packages/core/src/api-graph/__tests__/fixtures/outside/common/Error.yaml @@ -0,0 +1,4 @@ +type: object +properties: + message: + type: string diff --git a/packages/core/src/api-graph/__tests__/fixtures/outside/sub/openapi.yaml b/packages/core/src/api-graph/__tests__/fixtures/outside/sub/openapi.yaml new file mode 100644 index 0000000000..1e63c5ce39 --- /dev/null +++ b/packages/core/src/api-graph/__tests__/fixtures/outside/sub/openapi.yaml @@ -0,0 +1,9 @@ +openapi: 3.0.3 +info: + title: Outside API + version: 1.0.0 +paths: {} +components: + schemas: + Error: + $ref: '../common/Error.yaml' diff --git a/packages/core/src/api-graph/__tests__/fixtures/server-overrides/openapi.yaml b/packages/core/src/api-graph/__tests__/fixtures/server-overrides/openapi.yaml new file mode 100644 index 0000000000..50eac1f849 --- /dev/null +++ b/packages/core/src/api-graph/__tests__/fixtures/server-overrides/openapi.yaml @@ -0,0 +1,15 @@ +openapi: 3.1.0 +info: + title: Server overrides API + version: 1.0.0 +servers: + - url: https://api.example.com +paths: + /uploads: + post: + summary: Upload a file + servers: + - url: https://uploads.example.com + responses: + '200': + description: ok diff --git a/packages/core/src/api-graph/__tests__/fixtures/split/components/schemas/Ticket.yaml b/packages/core/src/api-graph/__tests__/fixtures/split/components/schemas/Ticket.yaml new file mode 100644 index 0000000000..81c010bc80 --- /dev/null +++ b/packages/core/src/api-graph/__tests__/fixtures/split/components/schemas/Ticket.yaml @@ -0,0 +1,4 @@ +type: object +properties: + ticketId: + $ref: './TicketId.yaml' diff --git a/packages/core/src/api-graph/__tests__/fixtures/split/components/schemas/TicketId.yaml b/packages/core/src/api-graph/__tests__/fixtures/split/components/schemas/TicketId.yaml new file mode 100644 index 0000000000..923fc66b34 --- /dev/null +++ b/packages/core/src/api-graph/__tests__/fixtures/split/components/schemas/TicketId.yaml @@ -0,0 +1,2 @@ +type: string +description: Unique ticket identifier. diff --git a/packages/core/src/api-graph/__tests__/fixtures/split/openapi.yaml b/packages/core/src/api-graph/__tests__/fixtures/split/openapi.yaml new file mode 100644 index 0000000000..0925bcedf7 --- /dev/null +++ b/packages/core/src/api-graph/__tests__/fixtures/split/openapi.yaml @@ -0,0 +1,17 @@ +openapi: 3.0.3 +info: + title: Split API + version: 1.0.0 + description: Multi-file description for api-graph tests. +servers: + - url: https://api.example.com/v1 +tags: + - name: Tickets + description: Buy tickets and manage reservations. +paths: + /tickets: + $ref: './paths/tickets.yaml' +components: + schemas: + Ticket: + $ref: './components/schemas/Ticket.yaml' diff --git a/packages/core/src/api-graph/__tests__/fixtures/split/paths/tickets.yaml b/packages/core/src/api-graph/__tests__/fixtures/split/paths/tickets.yaml new file mode 100644 index 0000000000..5783e621f9 --- /dev/null +++ b/packages/core/src/api-graph/__tests__/fixtures/split/paths/tickets.yaml @@ -0,0 +1,36 @@ +get: + summary: List tickets + tags: [Tickets] + operationId: listTickets + responses: + '200': + description: Success. + content: + application/json: + schema: + type: array + items: + $ref: '../components/schemas/Ticket.yaml' +post: + summary: Buy museum tickets + tags: [Tickets] + operationId: buyTickets + responses: + '201': + description: Created. + content: + application/json: + schema: + $ref: '../components/schemas/Ticket.yaml' + callbacks: + onEvent: + '{$request.body#/callbackUrl}': + post: + operationId: handleEvent + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '../components/schemas/Ticket.yaml' diff --git a/packages/core/src/api-graph/__tests__/fixtures/webhooks/openapi.yaml b/packages/core/src/api-graph/__tests__/fixtures/webhooks/openapi.yaml new file mode 100644 index 0000000000..9ce8e7dcc0 --- /dev/null +++ b/packages/core/src/api-graph/__tests__/fixtures/webhooks/openapi.yaml @@ -0,0 +1,12 @@ +openapi: 3.1.0 +info: + title: Webhooks API + version: 1.0.0 +paths: {} +webhooks: + newTicket: + post: + summary: New ticket alert + responses: + '200': + description: ok diff --git a/packages/core/src/api-graph/__tests__/node-id.test.ts b/packages/core/src/api-graph/__tests__/node-id.test.ts new file mode 100644 index 0000000000..99812bdf40 --- /dev/null +++ b/packages/core/src/api-graph/__tests__/node-id.test.ts @@ -0,0 +1,166 @@ +import { commonDir, mapForeignLocation, mapRootPointer, parsePointerSegments } from '../node-id.js'; + +describe('commonDir', () => { + it('returns the directory itself for a single path', () => { + expect(commonDir(['/project/api'])).toBe('/project/api'); + }); + + it('returns the shared ancestor directory for multiple paths', () => { + expect(commonDir(['/project/api', '/project/admin'])).toBe('/project'); + expect(commonDir(['/p/a/b', '/p/a/c/d'])).toBe('/p/a'); + }); +}); + +describe('parsePointerSegments', () => { + it('splits and unescapes pointer fragments', () => { + expect(parsePointerSegments('#/paths/~1pets~1{petId}/get')).toEqual([ + 'paths', + '/pets/{petId}', + 'get', + ]); + expect(parsePointerSegments('#/components/schemas/Tilde~0Name')).toEqual([ + 'components', + 'schemas', + 'Tilde~Name', + ]); + expect(parsePointerSegments('#/')).toEqual([]); + expect(parsePointerSegments('')).toEqual([]); + }); +}); + +describe('mapRootPointer', () => { + it('maps the document root', () => { + expect(mapRootPointer('#/', 'openapi.yaml')).toEqual({ id: 'openapi.yaml', kind: 'root' }); + }); + + it('maps a path item', () => { + expect(mapRootPointer('#/paths/~1pets', 'openapi.yaml')).toEqual({ + id: '/pets', + kind: 'path', + ancestry: [], + }); + }); + + it('maps an operation and everything nested in it', () => { + expect(mapRootPointer('#/paths/~1pets/get', 'openapi.yaml')).toEqual({ + id: 'GET /pets', + kind: 'operation', + ancestry: ['/pets'], + }); + expect( + mapRootPointer( + '#/paths/~1pets/post/requestBody/content/application~1json/schema', + 'openapi.yaml' + ) + ).toEqual({ id: 'POST /pets', kind: 'operation', ancestry: ['/pets'] }); + }); + + it('attributes callback sites to the outer operation', () => { + expect( + mapRootPointer( + '#/paths/~1pets/post/callbacks/onEvent/{$request.body#~1url}/post/responses/200', + 'openapi.yaml' + ) + ).toEqual({ id: 'POST /pets', kind: 'operation', ancestry: ['/pets'] }); + }); + + it('maps path-level (non-method) members to the path', () => { + expect(mapRootPointer('#/paths/~1pets/parameters/0', 'openapi.yaml')).toEqual({ + id: '/pets', + kind: 'path', + ancestry: [], + }); + }); + + it('maps x-query operations', () => { + expect(mapRootPointer('#/paths/~1pets/x-query', 'openapi.yaml')).toEqual({ + id: 'X-QUERY /pets', + kind: 'operation', + ancestry: ['/pets'], + }); + }); + + it('maps OAS3 components and nested pointers inside them', () => { + expect(mapRootPointer('#/components/schemas/Pet', 'openapi.yaml')).toEqual({ + id: 'schemas/Pet', + kind: 'component', + }); + expect(mapRootPointer('#/components/schemas/User/properties/address', 'openapi.yaml')).toEqual({ + id: 'schemas/User', + kind: 'component', + }); + }); + + it('maps OAS2 root sections as components', () => { + expect(mapRootPointer('#/definitions/Pet', 'openapi.yaml')).toEqual({ + id: 'definitions/Pet', + kind: 'component', + }); + expect(mapRootPointer('#/securityDefinitions/api_key', 'openapi.yaml')).toEqual({ + id: 'securityDefinitions/api_key', + kind: 'component', + }); + }); + + it('falls back to the first two segments for other root-level sites', () => { + expect(mapRootPointer('#/webhooks/newPet/post/requestBody', 'openapi.yaml')).toEqual({ + id: 'webhooks/newPet', + kind: 'component', + ancestry: [], + }); + expect(mapRootPointer('#/servers/0', 'openapi.yaml')).toEqual({ + id: 'servers/0', + kind: 'component', + ancestry: [], + }); + expect(mapRootPointer('#/info', 'openapi.yaml')).toEqual({ + id: 'info', + kind: 'component', + ancestry: [], + }); + }); +}); + +describe('mapForeignLocation', () => { + it('maps a component section inside another file to a canonical ref id', () => { + expect(mapForeignLocation('common.yaml', '#/components/schemas/Pet/properties/x')).toEqual({ + id: 'common.yaml#/components/schemas/Pet', + kind: 'component', + file: 'common.yaml', + }); + expect(mapForeignLocation('legacy.yaml', '#/definitions/Pet')).toEqual({ + id: 'legacy.yaml#/definitions/Pet', + kind: 'component', + file: 'legacy.yaml', + }); + }); + + it('maps anything else to the whole file', () => { + expect(mapForeignLocation('schemas/pet.yaml', '#/')).toEqual({ + id: 'schemas/pet.yaml', + kind: 'file', + file: 'schemas/pet.yaml', + }); + expect(mapForeignLocation('schemas/pet.yaml', '#/properties/name')).toEqual({ + id: 'schemas/pet.yaml', + kind: 'file', + file: 'schemas/pet.yaml', + }); + }); + + it('treats a path-item parameters array as the whole file, not an OAS2 component', () => { + expect(mapForeignLocation('paths/pets.yaml', '#/parameters/0')).toEqual({ + id: 'paths/pets.yaml', + kind: 'file', + file: 'paths/pets.yaml', + }); + }); + + it('still maps a named OAS2 parameters component in another file', () => { + expect(mapForeignLocation('common.yaml', '#/parameters/PetId')).toEqual({ + id: 'common.yaml#/parameters/PetId', + kind: 'component', + file: 'common.yaml', + }); + }); +}); diff --git a/packages/core/src/api-graph/__tests__/slice.test.ts b/packages/core/src/api-graph/__tests__/slice.test.ts new file mode 100644 index 0000000000..13a258d675 --- /dev/null +++ b/packages/core/src/api-graph/__tests__/slice.test.ts @@ -0,0 +1,179 @@ +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { detectSpec } from '../../detect-spec.js'; +import { getTypes } from '../../oas-types.js'; +import { BaseResolver, type Document } from '../../resolve.js'; +import { normalizeTypes } from '../../types/index.js'; +import { analyzeApi, type ApiAnalysis } from '../build-graph.js'; +import { buildApiIndex, type ApiIndex } from '../build-index.js'; +import { appendDepsClosure, buildNodeEnvelope, findIndexNode, hasIndexLocation } from '../slice.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const FIXTURE_ROOT = join(__dirname, 'fixtures', 'split'); + +async function analyzed(): Promise<{ analysis: ApiAnalysis; index: ApiIndex }> { + const resolver = new BaseResolver(); + const rootDocument = (await resolver.resolveDocument( + null, + join(FIXTURE_ROOT, 'openapi.yaml'), + true + )) as Document; + const specVersion = detectSpec(rootDocument.parsed); + const types = normalizeTypes(getTypes(specVersion), {}); + const analysis = await analyzeApi({ + rootDocument, + specVersion, + types, + externalRefResolver: resolver, + cwd: FIXTURE_ROOT, + resolveRef: (base, uri) => join(dirname(base), uri), + }); + const index = buildApiIndex(analysis, { specVersion, cwd: FIXTURE_ROOT, groupBy: 'tags' }); + return { analysis, index }; +} + +describe('findIndexNode', () => { + it('finds nodes by semantic id and by file#pointer', async () => { + const { index } = await analyzed(); + + const byId = findIndexNode(index.structure, 'POST /tickets')!; + expect(byId.title).toBe('POST /tickets — Buy museum tickets'); + + const byPointer = findIndexNode(index.structure, 'paths/tickets.yaml#/post'); + expect(byPointer).toBe(byId); + + expect(findIndexNode(index.structure, 'DELETE /nowhere')).toBeUndefined(); + }); +}); + +describe('buildNodeEnvelope', () => { + it('slices raw source lines and resolves outgoing refs', async () => { + const { analysis, index } = await analyzed(); + + const indexNode = findIndexNode(index.structure, 'POST /tickets')!; + if (!hasIndexLocation(indexNode)) throw new Error('operation node must carry a location'); + + const envelope = buildNodeEnvelope({ indexNode, analysis, cwd: FIXTURE_ROOT }); + + expect(envelope.id).toBe('POST /tickets'); + expect(envelope.file).toBe('paths/tickets.yaml'); + expect(envelope.content).toContain('operationId: buyTickets'); + expect(envelope.content).not.toContain('get:'); + expect(envelope.refs).toEqual([ + { + ref: '../components/schemas/Ticket.yaml', + resolved: true, + file: 'components/schemas/Ticket.yaml', + pointer: '#/', + }, + ]); + }); + + it('returns the whole file for a whole-file component node', async () => { + const { analysis, index } = await analyzed(); + + const indexNode = findIndexNode(index.structure, 'schemas/Ticket')!; + if (!hasIndexLocation(indexNode)) throw new Error('component node must carry a location'); + + const envelope = buildNodeEnvelope({ indexNode, analysis, cwd: FIXTURE_ROOT }); + expect(envelope.file).toBe('components/schemas/Ticket.yaml'); + expect(envelope.start_line).toBe(1); + expect(envelope.content).toContain('type: object'); + }); +}); + +describe('buildNodeEnvelope outside cwd', () => { + it('resolves nodes whose file lives outside the working directory', async () => { + const outsideCwd = join(__dirname, 'fixtures', 'outside', 'sub'); + const resolver = new BaseResolver(); + const rootDocument = (await resolver.resolveDocument( + null, + join(outsideCwd, 'openapi.yaml'), + true + )) as Document; + const specVersion = detectSpec(rootDocument.parsed); + const types = normalizeTypes(getTypes(specVersion), {}); + const analysis = await analyzeApi({ + rootDocument, + specVersion, + types, + externalRefResolver: resolver, + cwd: outsideCwd, + resolveRef: (base, uri) => join(dirname(base), uri), + }); + const index = buildApiIndex(analysis, { specVersion, cwd: outsideCwd, groupBy: 'tags' }); + + const errorNode = findIndexNode(index.structure, 'schemas/Error')!; + expect(errorNode.file).toBe('../common/Error.yaml'); + if (!hasIndexLocation(errorNode)) throw new Error('component node must carry a location'); + + const envelope = buildNodeEnvelope({ indexNode: errorNode, analysis, cwd: outsideCwd }); + expect(envelope.file).toBe('../common/Error.yaml'); + expect(envelope.content).toContain('message'); + }); +}); + +describe('appendDepsClosure', () => { + it('appends the transitive dependency closure in BFS order', async () => { + const { analysis, index } = await analyzed(); + + const indexNode = findIndexNode(index.structure, 'POST /tickets')!; + if (!hasIndexLocation(indexNode)) throw new Error('operation node must carry a location'); + const base = buildNodeEnvelope({ indexNode, analysis, cwd: FIXTURE_ROOT }); + + const withDeps = appendDepsClosure({ + envelope: base, + indexNode, + analysis, + index, + cwd: FIXTURE_ROOT, + }); + + expect(withDeps.deps!.map((dep) => dep.file)).toEqual([ + 'components/schemas/Ticket.yaml', + 'components/schemas/TicketId.yaml', + ]); + expect(withDeps.deps![0].content).toContain('ticketId'); + expect(withDeps.truncated).toBeUndefined(); + }); + + it('truncates the closure at the byte cap and says so', async () => { + const { analysis, index } = await analyzed(); + + const indexNode = findIndexNode(index.structure, 'POST /tickets')!; + if (!hasIndexLocation(indexNode)) throw new Error('operation node must carry a location'); + const base = buildNodeEnvelope({ indexNode, analysis, cwd: FIXTURE_ROOT }); + + const capped = appendDepsClosure({ + envelope: base, + indexNode, + analysis, + index, + cwd: FIXTURE_ROOT, + capBytes: 10, + }); + + expect(capped.deps!.length).toBeLessThan(2); + expect(capped.truncated).toBe(true); + }); + + it('returns an empty closure for grouping nodes instead of walking the graph', async () => { + const { analysis, index } = await analyzed(); + + const operationsSection = findIndexNode(index.structure, 'Operations')!; + if (!hasIndexLocation(operationsSection)) throw new Error('Operations carries paths location'); + const base = buildNodeEnvelope({ indexNode: operationsSection, analysis, cwd: FIXTURE_ROOT }); + + const withDeps = appendDepsClosure({ + envelope: base, + indexNode: operationsSection, + analysis, + index, + cwd: FIXTURE_ROOT, + }); + + expect(withDeps.deps).toEqual([]); + expect(withDeps.truncated).toBeUndefined(); + }); +}); diff --git a/packages/core/src/api-graph/build-graph.ts b/packages/core/src/api-graph/build-graph.ts new file mode 100644 index 0000000000..70f72a6dca --- /dev/null +++ b/packages/core/src/api-graph/build-graph.ts @@ -0,0 +1,504 @@ +import type { SpecVersion } from '../oas-types.js'; +import { isAbsoluteUrl, isRef, type Location } from '../ref-utils.js'; +import { + resolveDocument, + type BaseResolver, + type Document, + type ResolvedRefMap, +} from '../resolve.js'; +import type { NormalizedNodeType } from '../types/index.js'; +import { isPlainObject } from '../utils/is-plain-object.js'; +import { normalizeVisitors, type Oas3Visitor } from '../visitors.js'; +import { walkDocument, type UserContext, type WalkContext } from '../walk.js'; +import { COMPONENT_SECTIONS } from './build-index.js'; +import { + compareStrings, + mapForeignLocation, + mapRootPointer, + OPERATION_METHODS, + parsePointerSegments, + toNodeId, + type MappedNode, +} from './node-id.js'; +import type { DependencyGraph, GraphEdge, GraphNode } from './types.js'; + +export type CollectedOperation = { + id: string; + method: string; + containerKey: string; + isWebhook: boolean; + tags: string[]; + summary?: string; + description?: string; + operationId?: string; + deprecated?: boolean; + location: Location; + pathItemLocation: Location; +}; + +export type CollectedComponent = { + section: string; + name: string; + description?: string; + location: Location; +}; + +export type ApiIndexMeta = { + info?: { title?: string; description?: string; location: Location }; + servers?: { urls: string[]; location: Location }; + declaredTags: { name: string; description?: string; location: Location }[]; + operations: CollectedOperation[]; + components: CollectedComponent[]; + pathsLocation?: Location; + webhooksLocation?: Location; + componentsLocation?: Location; +}; + +export type ApiAnalysis = { + graph: DependencyGraph; + meta: ApiIndexMeta; + resolvedRefMap: ResolvedRefMap; + rootDocument: Document; +}; + +export async function analyzeApi(options: { + rootDocument: Document; + specVersion: SpecVersion; + types: Record; + externalRefResolver: BaseResolver; + cwd: string; + resolveRef: (base: string, uri: string) => string; +}): Promise { + const { rootDocument, specVersion, types, externalRefResolver, cwd, resolveRef } = options; + + const resolvedRefMap = await resolveDocument({ + rootDocument, + rootType: types.Root, + externalRefResolver, + }); + + const ctx: WalkContext = { problems: [], specVersion, visitorsData: {} }; + + const { graph, meta } = walkStructure({ + document: rootDocument, + types, + resolvedRefMap, + ctx, + cwd, + resolveRef, + }); + + return { graph, meta, resolvedRefMap, rootDocument }; +} + +function walkStructure(options: { + document: Document; + types: Record; + resolvedRefMap: ResolvedRefMap; + ctx: WalkContext; + cwd: string; + resolveRef: (base: string, uri: string) => string; +}): { graph: DependencyGraph; meta: ApiIndexMeta } { + const { document, types, resolvedRefMap, ctx, cwd, resolveRef } = options; + + const rootAbs = document.source.absoluteRef; + const rootId = toNodeId(rootAbs, cwd); + + const nodes = new Map(); + const edges = new Map(); + const meta: ApiIndexMeta = { declaredTags: [], operations: [], components: [] }; + + // A split layout defines root components as whole-file refs (`Order: {$ref: Order.yaml}`). + // Bundling used to inline those files under their component names; to keep the same canonical + // ids without bundling, map each aliased file to its `section/Name` id up front, and remember + // the alias entries themselves so they don't become self-edges. + const { fileAliases, aliasEntryPointers } = collectRootComponentAliases( + document.parsed, + rootAbs, + resolveRef + ); + + const addOrUpdateNode = (mapped: MappedNode & { file: string }, resolved: boolean) => { + const node = nodes.get(mapped.id) ?? { id: mapped.id, resolved: false }; + if (resolved) node.resolved = true; + if (isAbsoluteUrl(mapped.id)) node.external = true; + node.kind = mapped.kind; + node.file = mapped.file; + nodes.set(mapped.id, node); + }; + + const addEdge = (from: string, to: string, refString?: string) => { + const edgeKey = `${from} -> ${to}`; + const edge = edges.get(edgeKey) ?? { from, to, refs: [] }; + if (refString !== undefined && !edge.refs.includes(refString)) { + edge.refs.push(refString); + } + edges.set(edgeKey, edge); + }; + + const mapToNode = (absoluteRef: string, pointer: string): MappedNode & { file: string } => { + if (absoluteRef === rootAbs) { + return { ...mapRootPointer(pointer, rootId), file: rootId }; + } + const fileId = toNodeId(absoluteRef, cwd); + const mapped = mapForeignLocation(fileId, pointer); + const alias = fileAliases.get(absoluteRef); + // Any location that falls back to the whole file collapses to the aliased component, + // exactly as it did when the file was bundled under that name. + if (alias !== undefined && mapped.kind === 'file') { + return { id: alias, kind: 'component', file: fileId }; + } + return mapped; + }; + + const nodeFor = (location: Location): string => { + const mapped = mapToNode(location.source.absoluteRef, location.pointer); + addOrUpdateNode(mapped, true); + linkToRoot(mapped); + return mapped.id; + }; + + const linkToRoot = (mapped: MappedNode) => { + if (mapped.ancestry === undefined) return; + let previous = rootId; + for (const ancestorId of mapped.ancestry) { + // Keep a file already stamped by a direct PathItem visit (e.g. a $ref'd path file); + // rootId is only a fallback for an ancestor first created through this link. + const ancestorFile = nodes.get(ancestorId)?.file ?? rootId; + addOrUpdateNode({ id: ancestorId, kind: 'path', file: ancestorFile }, true); + addEdge(previous, ancestorId); + previous = ancestorId; + } + addEdge(previous, mapped.id); + }; + + const unresolvedTargetId = (siteLocation: Location, refString: string): string => { + const [uri, fragment] = refString.split('#'); + const siteFile = siteLocation.source.absoluteRef; + + let mapped: MappedNode & { file: string }; + if (uri === '') { + mapped = mapToNode(siteFile, '#' + (fragment ?? '/')); + } else { + const fileId = toNodeId(resolveRef(siteFile, uri), cwd); + mapped = + fragment !== undefined + ? mapForeignLocation(fileId, '#' + fragment) + : { id: fileId, kind: 'file', file: fileId }; + } + + addOrUpdateNode(mapped, false); + return mapped.id; + }; + + // Remembers the top-level PathItem currently being walked, so a $ref'd path item's + // operations (whose own rawLocation points into the foreign file, not the root) can still + // be traced back to a root-relative pointer. Identity, not the pointer, decides ownership: + // an Operation nested in a callback's own PathItem has a different `parent` object and is + // correctly ignored even though tracking is never reset between sibling operations. + let currentPathItemNode: unknown; + let currentPathItemRawLocation: Location | undefined; + + // Remembers the spine operation whose subtree is currently being walked, plus the absolute + // ref of the file that operation is defined in. Inside that same file — including the + // operation's own callbacks, whose nested PathItem/Operation never overwrite this tracking, + // same identity rule as above — a $ref's owner site collapses to a bare FILE node by + // `mapForeignLocation` (it has no `components/...`-shaped pointer of its own); redirecting + // that owner to the operation instead matches the old bundled walk, where the same ref's + // owner was the operation. Once a ref has hopped into a *different* file (e.g. a component + // schema referencing another schema), the site's absoluteRef no longer matches + // `currentOperationFileAbs`, so it correctly keeps the current file-owner behavior. + let currentOperationNode: unknown; + let currentOperationNodeId: string | undefined; + let currentOperationFileAbs: string | undefined; + + let currentWebhookPathItemNode: unknown; + let currentWebhookKey: string | undefined; + let currentWebhookPathItemLocation: Location | undefined; + + const collectNamed = + (section: string) => + (node: Record, collectorCtx: Pick) => { + for (const name of Object.keys(node)) { + const value = node[name]; + const target = isRef(value) + ? collectorCtx.resolve(value) + : { node: value, location: collectorCtx.location.child([name]) }; + if (!target.location) continue; + // Resolved nodes are untyped JSON, so narrowing to the one field we read is safe. + const description = (target.node as { description?: string } | undefined)?.description; + meta.components.push({ section, name, description, location: target.location }); + } + }; + + // Each section's visitor is its Named* node type: schemas → NamedSchemas, and so on. + const namedComponentVisitors = Object.fromEntries( + COMPONENT_SECTIONS.map((section) => [ + `Named${section[0].toUpperCase()}${section.slice(1)}`, + collectNamed(section), + ]) + ); + + // The dynamically built Named* keys can't be inferred as visitor members, + // so the assembled object needs an explicit Oas3Visitor assertion. + const visitor = { + ...namedComponentVisitors, + Info(node, vctx) { + meta.info = { title: node.title, description: node.description, location: vctx.location }; + }, + // Oas3Visitor has no dedicated ServerList entry, so node falls back to the visitor + // type's untyped catch-all — annotate it explicitly to avoid implicit `any` below. + ServerList(node: { url?: string }[], vctx) { + // Path items and operations can override servers; only the root list describes the API. + if (vctx.rawLocation.pointer !== '#/servers') return; + meta.servers = { + urls: node.map((server) => server.url).filter((url): url is string => Boolean(url)), + location: vctx.location, + }; + }, + Tag(node, vctx) { + meta.declaredTags.push({ + name: node.name, + description: node.description, + location: vctx.location, + }); + }, + Paths: { + enter(_node, vctx) { + meta.pathsLocation ??= vctx.location; + }, + }, + WebhooksMap: { + enter(_node, vctx) { + meta.webhooksLocation ??= vctx.location; + }, + }, + Components: { + enter(_node, vctx) { + meta.componentsLocation ??= vctx.location; + }, + }, + PathItem: { + enter(node, vctx) { + if (vctx.rawLocation.source.absoluteRef !== rootAbs) return; + const segments = parsePointerSegments(vctx.rawLocation.pointer); + if (segments.length === 2 && segments[0] === 'paths') { + const spineNodeId = nodeFor(vctx.rawLocation); + nodes.get(spineNodeId)!.file = toNodeId(vctx.location.source.absoluteRef, cwd); + currentPathItemNode = node; + currentPathItemRawLocation = vctx.rawLocation; + } + if (segments.length === 2 && segments[0] === 'webhooks') { + currentWebhookPathItemNode = node; + currentWebhookKey = segments[1]; + currentWebhookPathItemLocation = vctx.location; + } + }, + }, + Operation: { + enter(node, vctx) { + if ( + currentWebhookPathItemNode !== undefined && + vctx.parent === currentWebhookPathItemNode + ) { + const method = String(vctx.key); + if (OPERATION_METHODS.has(method)) { + meta.operations.push({ + id: `${method.toUpperCase()} ${currentWebhookKey}`, + method: method.toUpperCase(), + containerKey: currentWebhookKey!, + isWebhook: true, + tags: node.tags ?? [], + summary: node.summary, + description: node.description, + operationId: node.operationId, + deprecated: node.deprecated, + location: vctx.location, + pathItemLocation: currentWebhookPathItemLocation!, + }); + } + return; + } + if (currentPathItemRawLocation === undefined || vctx.parent !== currentPathItemNode) { + return; + } + const method = String(vctx.key); + if (!OPERATION_METHODS.has(method)) return; + + const operationNodeId = nodeFor(currentPathItemRawLocation.child([method])); + if (typeof node.operationId === 'string') { + nodes.get(operationNodeId)!.operationId = node.operationId; + } + nodes.get(operationNodeId)!.file = toNodeId(vctx.location.source.absoluteRef, cwd); + + currentOperationNode = node; + currentOperationNodeId = operationNodeId; + currentOperationFileAbs = vctx.location.source.absoluteRef; + + meta.operations.push({ + id: operationNodeId, + method: method.toUpperCase(), + containerKey: parsePointerSegments(currentPathItemRawLocation.pointer)[1], + isWebhook: false, + tags: node.tags ?? [], + summary: node.summary, + description: node.description, + operationId: node.operationId, + deprecated: node.deprecated, + location: vctx.location, + pathItemLocation: currentPathItemRawLocation, + }); + }, + leave(node) { + if (node === currentOperationNode) { + currentOperationNode = undefined; + currentOperationNodeId = undefined; + currentOperationFileAbs = undefined; + } + }, + }, + ref: { + enter(refNode, vctx, resolved) { + if (vctx.location.source.absoluteRef === rootAbs) { + if (aliasEntryPointers.has(vctx.location.pointer)) return; + // A root paths/webhooks entry that is a whole-file ref used to be inlined by the + // bundler: the spine and its operations come from the PathItem visitor, so a resolved + // entry adds no edge. An unresolved one still must surface as a broken file node. + const segments = parsePointerSegments(vctx.location.pointer); + if ( + segments.length === 2 && + (segments[0] === 'paths' || segments[0] === 'webhooks') && + resolved.node !== undefined && + resolved.location + ) { + return; + } + } + const mappedOwner = mapToNode(vctx.location.source.absoluteRef, vctx.location.pointer); + const ownerId = + currentOperationNodeId !== undefined && + mappedOwner.kind === 'file' && + vctx.location.source.absoluteRef === currentOperationFileAbs + ? currentOperationNodeId + : nodeFor(vctx.location); + const refString = String(refNode.$ref); + // Mirrors NoUnresolvedRefs: `resolved.location` can be truthy (pointing at a fallback + // location) even when the pointer path inside the target document doesn't exist, so + // `node` is the only reliable signal that the $ref actually resolved to something. + const targetId = + resolved.node !== undefined && resolved.location + ? nodeFor(resolved.location) + : unresolvedTargetId(vctx.location, refString); + addEdge(ownerId, targetId, refString); + }, + }, + } as Oas3Visitor; + + addOrUpdateNode({ id: rootId, kind: 'root', file: rootId }, true); + nodes.get(rootId)!.root = true; + + const normalizedVisitors = normalizeVisitors( + [{ severity: 'warn', ruleId: 'tree', visitor }], + types + ); + walkDocument({ document, rootType: types.Root, normalizedVisitors, resolvedRefMap, ctx }); + + return { graph: finalizeGraph(rootId, nodes, edges), meta }; +} + +const OAS2_ALIAS_SECTIONS = ['definitions', 'parameters', 'responses', 'securityDefinitions']; + +/** Finds root component entries that are plain whole-file refs and maps the file to the entry id. */ +function collectRootComponentAliases( + parsed: unknown, + rootAbs: string, + resolveRef: (base: string, uri: string) => string +): { fileAliases: Map; aliasEntryPointers: Set } { + const fileAliases = new Map(); + const aliasEntryPointers = new Set(); + const root = parsed as Record>> | undefined; + + const collectSection = ( + section: Record, + idPrefix: string, + pointerPrefix: string + ) => { + for (const [name, value] of Object.entries(section)) { + const refString = (value as { $ref?: unknown } | undefined)?.$ref; + if (typeof refString !== 'string') continue; + const [uri, fragment] = refString.split('#'); + // Only whole-file refs behave like bundle-time inlining; refs into a named section of + // another file already map to a canonical foreign id on their own. + if (uri === '' || (fragment !== undefined && fragment !== '/' && fragment !== '')) continue; + fileAliases.set(resolveRef(rootAbs, uri), `${idPrefix}/${name}`); + aliasEntryPointers.add(`${pointerPrefix}/${escapeAliasKey(name)}`); + } + }; + + const components = root?.components; + if (components !== undefined) { + for (const [section, entries] of Object.entries(components)) { + if (!isPlainObject(entries)) continue; + collectSection(entries, section, `#/components/${escapeAliasKey(section)}`); + } + } + for (const section of OAS2_ALIAS_SECTIONS) { + const entries = root?.[section]; + if (!isPlainObject(entries)) continue; + collectSection(entries, section, `#/${section}`); + } + + return { fileAliases, aliasEntryPointers }; +} + +function escapeAliasKey(key: string): string { + return key.replace(/~/g, '~0').replace(/\//g, '~1'); +} + +/** Keeps only nodes reachable from the root, sorted for stable output. */ +function finalizeGraph( + rootId: string, + nodeMap: Map, + edgeMap: Map +): DependencyGraph { + const connectedIds = collectConnectedIds([rootId], [...edgeMap.values()]); + + const nodes = [...nodeMap.values()] + .filter((node) => connectedIds.has(node.id)) + .sort((a, b) => compareStrings(a.id, b.id)); + + const edges = [...edgeMap.values()] + .filter((edge) => connectedIds.has(edge.from) && connectedIds.has(edge.to)) + .map((edge) => ({ ...edge, refs: [...edge.refs].sort(compareStrings) })) + .sort((a, b) => compareStrings(a.from, b.from) || compareStrings(a.to, b.to)); + + return { roots: [rootId], nodes, edges }; +} + +export function collectConnectedIds( + seeds: string[], + edges: GraphEdge[], + { reverse = false }: { reverse?: boolean } = {} +): Set { + const adjacency = new Map(); + for (const edge of edges) { + const from = reverse ? edge.to : edge.from; + const to = reverse ? edge.from : edge.to; + const neighbours = adjacency.get(from) ?? []; + neighbours.push(to); + adjacency.set(from, neighbours); + } + + const seen = new Set(seeds); + const queue = [...seen]; + while (queue.length > 0) { + const current = queue.shift()!; + for (const next of adjacency.get(current) ?? []) { + if (!seen.has(next)) { + seen.add(next); + queue.push(next); + } + } + } + return seen; +} diff --git a/packages/core/src/api-graph/build-index.ts b/packages/core/src/api-graph/build-index.ts new file mode 100644 index 0000000000..d4e4d08617 --- /dev/null +++ b/packages/core/src/api-graph/build-index.ts @@ -0,0 +1,250 @@ +import * as path from 'node:path'; + +import { getLineColLocation } from '../format/codeframes.js'; +import type { SpecVersion } from '../oas-types.js'; +import { isAbsoluteUrl, type Location } from '../ref-utils.js'; +import type { + ApiAnalysis, + ApiIndexMeta, + CollectedComponent, + CollectedOperation, +} from './build-graph.js'; + +export const SUMMARY_LIMIT = 160; +const UNTAGGED = 'untagged'; + +/** OpenAPI component sections, in the order the index lists them. */ +export const COMPONENT_SECTIONS: readonly string[] = [ + 'schemas', + 'responses', + 'parameters', + 'requestBodies', + 'headers', + 'securitySchemes', + 'examples', + 'links', + 'callbacks', +]; + +export type IndexGroupBy = 'tags' | 'paths'; + +export type ApiIndexNode = { + id: string; + title: string; + pointer?: string; + file?: string; + start_line?: number; + end_line?: number; + summary?: string; + operationId?: string; + deprecated?: boolean; + nodes?: ApiIndexNode[]; +}; + +export type ApiIndex = { + docName: string; + spec: SpecVersion; + docDescription?: string; + structure: ApiIndexNode[]; +}; + +export function buildApiIndex( + analysis: ApiAnalysis, + options: { specVersion: SpecVersion; cwd: string; groupBy: IndexGroupBy } +): ApiIndex { + const { meta, rootDocument } = analysis; + const { specVersion, cwd, groupBy } = options; + + const structure: ApiIndexNode[] = []; + + if (meta.info) { + const summary = truncateSummary(meta.info.description); + structure.push({ + id: 'Overview', + title: 'Overview', + ...toFileRange(meta.info.location, cwd), + ...(summary ? { summary } : {}), + }); + } + + if (meta.servers) { + const summary = truncateSummary(meta.servers.urls.join(', ')); + structure.push({ + id: 'Servers', + title: 'Servers', + ...toFileRange(meta.servers.location, cwd), + ...(summary ? { summary } : {}), + }); + } + + const pathOperations = meta.operations.filter((operation) => !operation.isWebhook); + if (pathOperations.length > 0) { + structure.push({ + id: 'Operations', + title: 'Operations', + ...(meta.pathsLocation ? toFileRange(meta.pathsLocation, cwd) : {}), + nodes: + groupBy === 'tags' + ? groupByTags(pathOperations, meta.declaredTags, cwd) + : groupByPaths(pathOperations, cwd), + }); + } + + const webhookOperations = meta.operations.filter((operation) => operation.isWebhook); + if (webhookOperations.length > 0) { + structure.push({ + id: 'Webhooks', + title: 'Webhooks', + ...(meta.webhooksLocation ? toFileRange(meta.webhooksLocation, cwd) : {}), + nodes: webhookOperations.map((operation) => toOperationNode(operation, cwd)), + }); + } + + const componentNodes = groupComponents(meta.components, cwd); + if (componentNodes.length > 0) { + structure.push({ + id: 'Components', + title: 'Components', + ...(meta.componentsLocation ? toFileRange(meta.componentsLocation, cwd) : {}), + nodes: componentNodes, + }); + } + + const docDescription = meta.info ? buildDocDescription(meta.info) : undefined; + + return { + docName: toRelativePath(rootDocument.source.absoluteRef, cwd), + spec: specVersion, + ...(docDescription ? { docDescription } : {}), + structure, + }; +} + +export function toRelativePath(absoluteRef: string, cwd: string): string { + return isAbsoluteUrl(absoluteRef) + ? absoluteRef + : path.relative(cwd, absoluteRef).split(path.sep).join('/'); +} + +function toFileRange(location: Location, cwd: string) { + const lineCol = getLineColLocation({ + source: location.source, + pointer: location.pointer, + reportOnKey: false, + }); + return { + pointer: location.pointer, + file: toRelativePath(location.source.absoluteRef, cwd), + start_line: lineCol.start.line, + // getLineColLocation always computes `end` for a string pointer. + end_line: lineCol.end!.line, + }; +} + +function truncateSummary(text: string | undefined): string | undefined { + if (!text) return undefined; + const normalized = text.replace(/\s+/g, ' ').trim(); + if (!normalized) return undefined; + if (normalized.length <= SUMMARY_LIMIT) return normalized; + const cut = normalized.slice(0, SUMMARY_LIMIT); + const lastSpace = cut.lastIndexOf(' '); + return `${lastSpace > 0 ? cut.slice(0, lastSpace) : cut}…`; +} + +function buildDocDescription(docInfo: { + title?: string; + description?: string; +}): string | undefined { + return truncateSummary([docInfo.title, docInfo.description].filter(Boolean).join(' — ')); +} + +function toOperationNode(operation: CollectedOperation, cwd: string): ApiIndexNode { + const titleSuffix = truncateSummary(operation.summary); + const summary = truncateSummary(operation.summary ?? operation.description); + return { + id: operation.id, + title: titleSuffix ? `${operation.id} — ${titleSuffix}` : operation.id, + ...(operation.operationId ? { operationId: operation.operationId } : {}), + ...(operation.deprecated ? { deprecated: true } : {}), + ...toFileRange(operation.location, cwd), + ...(summary ? { summary } : {}), + }; +} + +function groupByTags( + operations: CollectedOperation[], + declaredTags: ApiIndexMeta['declaredTags'], + cwd: string +): ApiIndexNode[] { + const groups = new Map(); + for (const operation of operations) { + const tagNames = operation.tags.length > 0 ? operation.tags : [UNTAGGED]; + for (const tagName of new Set(tagNames)) { + const group = groups.get(tagName) ?? []; + group.push(operation); + groups.set(tagName, group); + } + } + + const orderedNames = [ + ...declaredTags.map((tag) => tag.name), + ...[...groups.keys()].filter( + (name) => name !== UNTAGGED && !declaredTags.some((tag) => tag.name === name) + ), + UNTAGGED, + ]; + + const nodes: ApiIndexNode[] = []; + for (const name of orderedNames) { + const groupOperations = groups.get(name); + if (!groupOperations) continue; + const declared = declaredTags.find((tag) => tag.name === name); + const summary = truncateSummary(declared?.description); + nodes.push({ + id: name, + title: name, + ...(declared ? toFileRange(declared.location, cwd) : {}), + ...(summary ? { summary } : {}), + nodes: groupOperations.map((operation) => toOperationNode(operation, cwd)), + }); + } + return nodes; +} + +function groupByPaths(operations: CollectedOperation[], cwd: string): ApiIndexNode[] { + const groups = new Map(); + for (const operation of operations) { + const group = groups.get(operation.containerKey) ?? { + location: operation.pathItemLocation, + operations: [], + }; + group.operations.push(operation); + groups.set(operation.containerKey, group); + } + return [...groups.entries()].map(([pathKey, group]) => ({ + id: pathKey, + title: pathKey, + ...toFileRange(group.location, cwd), + nodes: group.operations.map((operation) => toOperationNode(operation, cwd)), + })); +} + +function groupComponents(components: CollectedComponent[], cwd: string): ApiIndexNode[] { + const sections = [...new Set(components.map((component) => component.section))]; + sections.sort((a, b) => COMPONENT_SECTIONS.indexOf(a) - COMPONENT_SECTIONS.indexOf(b)); + return sections.map((section) => ({ + id: `components/${section}`, + title: section, + nodes: components + .filter((component) => component.section === section) + .map((component) => { + const summary = truncateSummary(component.description); + return { + id: `${section}/${component.name}`, + title: component.name, + ...toFileRange(component.location, cwd), + ...(summary ? { summary } : {}), + }; + }), + })); +} diff --git a/packages/core/src/api-graph/node-id.ts b/packages/core/src/api-graph/node-id.ts new file mode 100644 index 0000000000..1555bfed22 --- /dev/null +++ b/packages/core/src/api-graph/node-id.ts @@ -0,0 +1,117 @@ +import * as path from 'node:path'; + +import { escapePointerFragment, isAbsoluteUrl, unescapePointerFragment } from '../ref-utils.js'; +import { slash } from '../utils/slash.js'; +import type { NodeKind } from './types.js'; + +export const compareStrings = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0); + +export function toNodeId(absoluteRef: string, cwd: string): string { + return isAbsoluteUrl(absoluteRef) ? absoluteRef : slash(path.relative(cwd, absoluteRef)); +} + +export function commonDir(dirs: string[]): string { + if (dirs.length === 0) return ''; + const segmented = dirs.map((dir) => slash(dir).split('/')); + const [first, ...rest] = segmented; + let end = first.length; + for (const parts of rest) { + let i = 0; + while (i < end && parts[i] === first[i]) i++; + end = i; + } + return first.slice(0, end).join('/') || '/'; +} + +export const OPERATION_METHODS = new Set([ + 'get', + 'put', + 'post', + 'delete', + 'options', + 'head', + 'patch', + 'trace', + 'query', + 'x-query', +]); + +const OAS2_COMPONENT_SECTIONS = new Set([ + 'definitions', + 'parameters', + 'responses', + 'securityDefinitions', +]); + +export type MappedNode = { + id: string; + kind: NodeKind; + /** Ancestor ids for structural spine edges, outermost first ([] = link directly to root; undefined = no structural link). */ + ancestry?: string[]; +}; + +export function parsePointerSegments(pointer: string): string[] { + return pointer + .replace(/^#?\/?/, '') + .split('/') + .filter(Boolean) + .map(unescapePointerFragment); +} + +/** + * Maps a JSON pointer inside the root document to its tree node — the document root, a path, an + * operation, a component, or a generic top-level group — with a short, file-prefix-free id such as + * `GET /pets` or `schemas/Pet`. + */ +export function mapRootPointer(pointer: string, rootId: string): MappedNode { + const segments = parsePointerSegments(pointer); + if (segments.length === 0) { + return { id: rootId, kind: 'root' }; + } + const [head, second, third] = segments; + if (head === 'paths' && second !== undefined) { + if (third !== undefined && OPERATION_METHODS.has(third)) { + return { id: `${third.toUpperCase()} ${second}`, kind: 'operation', ancestry: [second] }; + } + return { id: second, kind: 'path', ancestry: [] }; + } + if (head === 'components' && second !== undefined && third !== undefined) { + return { id: `${second}/${third}`, kind: 'component' }; + } + if (OAS2_COMPONENT_SECTIONS.has(head) && second !== undefined) { + return { id: `${head}/${second}`, kind: 'component' }; + } + return { + id: second !== undefined ? `${head}/${second}` : head, + kind: 'component', + ancestry: [], + }; +} + +/** + * Maps a location in another file to its tree node — a component inside that file or the whole file. + * A component address is `components/{type}/{name}` in OAS 3.x (first 3 segments) or `{section}/{name}` + * in OAS 2.0 (first 2); anything deeper, like a property, collapses back to that component. + * Examples: `common.yaml#/components/schemas/Pet` (kept copy-pasteable as a `$ref`), `schemas/pet.yaml`. + */ +export function mapForeignLocation(fileId: string, pointer: string): MappedNode & { file: string } { + const segments = parsePointerSegments(pointer); + + let componentPath: string[] | undefined; + if (segments[0] === 'components' && segments.length >= 3) { + componentPath = segments.slice(0, 3); + } else if ( + OAS2_COMPONENT_SECTIONS.has(segments[0]) && + segments.length >= 2 && + // A numeric key is an array index (path-item `parameters`), not a named OAS2 component. + !/^\d+$/.test(segments[1]) + ) { + componentPath = segments.slice(0, 2); + } + + if (componentPath) { + const canonical = componentPath.map(escapePointerFragment).join('/'); + return { id: `${fileId}#/${canonical}`, kind: 'component', file: fileId }; + } + return { id: fileId, kind: 'file', file: fileId }; +} diff --git a/packages/core/src/api-graph/slice.ts b/packages/core/src/api-graph/slice.ts new file mode 100644 index 0000000000..6207f9bbb3 --- /dev/null +++ b/packages/core/src/api-graph/slice.ts @@ -0,0 +1,264 @@ +import { isRef } from '../ref-utils.js'; +import type { Document } from '../resolve.js'; +import { isPlainObject } from '../utils/is-plain-object.js'; +import type { ApiAnalysis } from './build-graph.js'; +import { + COMPONENT_SECTIONS, + toRelativePath, + type ApiIndex, + type ApiIndexNode, +} from './build-index.js'; +import type { NodeKind } from './types.js'; + +export type ApiNodeRef = { + ref: string; + resolved: boolean; + file?: string; + pointer?: string; +}; + +export type ApiNodeEnvelope = { + id: string; + pointer?: string; + file: string; + start_line: number; + end_line: number; + content: string; + refs: ApiNodeRef[]; + deps?: ApiNodeEnvelope[]; + truncated?: boolean; +}; + +export type LocatedIndexNode = ApiIndexNode & { + file: string; + start_line: number; + end_line: number; +}; + +export function hasIndexLocation(node: ApiIndexNode): node is LocatedIndexNode { + return node.file !== undefined && node.start_line !== undefined && node.end_line !== undefined; +} + +export function findIndexNode( + structure: ApiIndexNode[], + selector: string +): ApiIndexNode | undefined { + for (const node of structure) { + if (node.id === selector) return node; + if ( + node.file !== undefined && + node.pointer !== undefined && + `${node.file}${node.pointer}` === selector + ) { + return node; + } + const found = node.nodes ? findIndexNode(node.nodes, selector) : undefined; + if (found) return found; + } + return undefined; +} + +export function buildNodeEnvelope(options: { + indexNode: LocatedIndexNode; + analysis: ApiAnalysis; + cwd: string; +}): ApiNodeEnvelope { + const { indexNode, analysis, cwd } = options; + + const document = documentsByFile(analysis, cwd).get(indexNode.file); + if (!document) { + throw new Error(`Source document for "${indexNode.file}" is not resolved.`); + } + + const lines = document.source.body.split('\n'); + const content = lines.slice(indexNode.start_line - 1, indexNode.end_line).join('\n'); + + const subtree = + indexNode.pointer === undefined + ? undefined + : getNodeAtPointer(document.parsed, indexNode.pointer); + const refs = [...collectRefStrings(subtree)].sort().map((ref): ApiNodeRef => { + // Key format mirrors core's internal makeRefId: `${absoluteRef}::${$ref}`. + const resolvedRef = analysis.resolvedRefMap.get(`${document.source.absoluteRef}::${ref}`); + if (!resolvedRef?.resolved || resolvedRef.node === undefined) return { ref, resolved: false }; + return { + ref, + resolved: true, + file: toRelativePath(resolvedRef.document.source.absoluteRef, cwd), + pointer: resolvedRef.nodePointer.startsWith('#') + ? resolvedRef.nodePointer + : `#${resolvedRef.nodePointer}`, + }; + }); + + return { + id: indexNode.id, + ...(indexNode.pointer !== undefined ? { pointer: indexNode.pointer } : {}), + file: indexNode.file, + start_line: indexNode.start_line, + end_line: indexNode.end_line, + content, + refs, + }; +} + +// Nested by analysis, then cwd: the same analysis can be sliced against more than one cwd. +const documentsByFileCache = new WeakMap>>(); + +export const DEPS_CONTENT_CAP_BYTES = 65536; + +// Only these graph node kinds carry content of their own; a root/path node is pure structure. +const SEEDABLE_KINDS = new Set(['operation', 'component', 'file']); + +/** + * Deps are meaningful only for content leaves — operations and components (or the file that + * defines one). A grouping or structural node (a section, a tag group, a path spine node) + * yields an empty closure by design: it has no content of its own to walk from. + */ +export function appendDepsClosure(options: { + envelope: ApiNodeEnvelope; + indexNode: LocatedIndexNode; + analysis: ApiAnalysis; + index: ApiIndex; + cwd: string; + capBytes?: number; +}): ApiNodeEnvelope { + const { envelope, indexNode, analysis, index, cwd } = options; + const capBytes = options.capBytes ?? DEPS_CONTENT_CAP_BYTES; + + const nodesById = new Map(analysis.graph.nodes.map((node) => [node.id, node])); + const seed = nodesById.has(indexNode.id) ? indexNode.id : indexNode.file; + const seedNode = nodesById.get(seed); + if (!seedNode?.kind || !SEEDABLE_KINDS.has(seedNode.kind)) { + return { ...envelope, deps: [] }; + } + + const leavesById = new Map(); + const leavesByFile = new Map(); + collectLocatedLeaves(index.structure, leavesById, leavesByFile); + + const adjacency = new Map(); + for (const edge of analysis.graph.edges) { + const neighbours = adjacency.get(edge.from) ?? []; + neighbours.push(edge.to); + adjacency.set(edge.from, neighbours); + } + + const deps: ApiNodeEnvelope[] = []; + let truncated = false; + let budget = capBytes; + const seen = new Set([seed]); + const queue = [...(adjacency.get(seed) ?? [])]; + + while (queue.length > 0) { + const currentId = queue.shift()!; + if (seen.has(currentId)) continue; + seen.add(currentId); + + const depNode = leavesById.get(currentId) ?? leavesByFile.get(currentId); + const depEnvelope = depNode + ? buildNodeEnvelope({ indexNode: depNode, analysis, cwd }) + : wholeFileEnvelope(currentId, analysis, cwd); + if (depEnvelope) { + if (depEnvelope.content.length > budget) { + truncated = true; + break; + } + budget -= depEnvelope.content.length; + deps.push(depEnvelope); + } + for (const next of adjacency.get(currentId) ?? []) { + if (!seen.has(next)) queue.push(next); + } + } + + return { ...envelope, deps, ...(truncated ? { truncated: true } : {}) }; +} + +// A component leaf's id is `${section}/${name}` (see groupComponents), e.g. `schemas/Ticket`. +function isComponentLeafId(id: string): boolean { + return COMPONENT_SECTIONS.some((section) => id.startsWith(`${section}/`)); +} + +function collectLocatedLeaves( + nodes: ApiIndexNode[], + byId: Map, + byFile: Map +): void { + for (const node of nodes) { + if (node.nodes) { + collectLocatedLeaves(node.nodes, byId, byFile); + continue; + } + if (hasIndexLocation(node)) { + byId.set(node.id, node); + // Only a component leaf may stand in for its whole file: a component file holds exactly + // one component, but a path-item file can hold several operations, so an operation leaf + // must never alias the shared file back to itself. + if (isComponentLeafId(node.id) && !byFile.has(node.file)) byFile.set(node.file, node); + } + } +} + +function wholeFileEnvelope( + fileId: string, + analysis: ApiAnalysis, + cwd: string +): ApiNodeEnvelope | undefined { + const document = documentsByFile(analysis, cwd).get(fileId); + if (!document) return undefined; + const lineCount = document.source.body.split('\n').length; + return { + id: fileId, + file: fileId, + start_line: 1, + end_line: lineCount, + content: document.source.body, + refs: [], + }; +} + +function documentsByFile(analysis: ApiAnalysis, cwd: string): Map { + const byCwd = documentsByFileCache.get(analysis) ?? new Map>(); + const cached = byCwd.get(cwd); + if (cached) return cached; + + const documents = new Map(); + documents.set( + toRelativePath(analysis.rootDocument.source.absoluteRef, cwd), + analysis.rootDocument + ); + for (const resolvedRef of analysis.resolvedRefMap.values()) { + if (resolvedRef.document) { + documents.set( + toRelativePath(resolvedRef.document.source.absoluteRef, cwd), + resolvedRef.document + ); + } + } + byCwd.set(cwd, documents); + documentsByFileCache.set(analysis, byCwd); + return documents; +} + +function getNodeAtPointer(parsed: unknown, pointer: string): unknown { + const fragment = pointer.replace(/^#/, ''); + if (fragment === '/' || fragment === '') return parsed; + let current = parsed; + for (const segment of fragment.split('/').slice(1)) { + if (!isPlainObject(current) && !Array.isArray(current)) return undefined; + const key = segment.replace(/~1/g, '/').replace(/~0/g, '~'); + current = (current as Record)[key]; + } + return current; +} + +function collectRefStrings(node: unknown, refs = new Set()): Set { + if (Array.isArray(node)) { + for (const item of node) collectRefStrings(item, refs); + } else if (isPlainObject(node)) { + if (isRef(node)) refs.add(node.$ref); + for (const value of Object.values(node)) collectRefStrings(value, refs); + } + return refs; +} diff --git a/packages/core/src/api-graph/types.ts b/packages/core/src/api-graph/types.ts new file mode 100644 index 0000000000..f8fda6c99e --- /dev/null +++ b/packages/core/src/api-graph/types.ts @@ -0,0 +1,28 @@ +export type NodeKind = 'root' | 'path' | 'operation' | 'component' | 'file'; + +export type GraphNode = { + id: string; + root?: boolean; + external?: boolean; + /** False: the file is referenced but could not be loaded. */ + resolved: boolean; + /** Node category in the structure view; absent in --files mode. */ + kind?: NodeKind; + /** The operation's `operationId`, when defined; only on `operation` nodes. */ + operationId?: string; + /** Cwd-relative source file the node is defined in; absent in --files mode. */ + file?: string; +}; + +export type GraphEdge = { + from: string; + to: string; + /** Distinct $ref strings used from `from` to `to`, sorted. */ + refs: string[]; +}; + +export type DependencyGraph = { + roots: string[]; + nodes: GraphNode[]; + edges: GraphEdge[]; +}; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 251f2684ce..0c7fb49071 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -126,6 +126,43 @@ export { bundle, bundleFromString, type BundleResult } from './bundle/bundle.js' export { bundleDocument, type ComponentNamesStrategy } from './bundle/bundle-document.js'; export { mapTypeToComponent } from './bundle/bundle-visitor.js'; export { type Assertions, type Assertion } from './rules/common/assertions/index.js'; +export { + commonDir, + compareStrings, + mapForeignLocation, + mapRootPointer, + OPERATION_METHODS, + parsePointerSegments, + toNodeId, + type MappedNode, +} from './api-graph/node-id.js'; +export type { DependencyGraph, GraphEdge, GraphNode, NodeKind } from './api-graph/types.js'; +export { + analyzeApi, + collectConnectedIds, + type ApiAnalysis, + type ApiIndexMeta, + type CollectedComponent, + type CollectedOperation, +} from './api-graph/build-graph.js'; +export { + buildApiIndex, + COMPONENT_SECTIONS, + SUMMARY_LIMIT, + type ApiIndex, + type ApiIndexNode, + type IndexGroupBy, +} from './api-graph/build-index.js'; +export { + appendDepsClosure, + buildNodeEnvelope, + DEPS_CONTENT_CAP_BYTES, + findIndexNode, + hasIndexLocation, + type ApiNodeEnvelope, + type ApiNodeRef, + type LocatedIndexNode, +} from './api-graph/slice.js'; export { logger, type LoggerInterface } from './logger.js'; export { HandledError } from './utils/error.js'; export { isSupportedExtension } from './utils/is-supported-extension.js'; diff --git a/tests/e2e/tree/index-fixture/openapi.yaml b/tests/e2e/tree/index-fixture/openapi.yaml new file mode 100644 index 0000000000..9f02838344 --- /dev/null +++ b/tests/e2e/tree/index-fixture/openapi.yaml @@ -0,0 +1,58 @@ +openapi: 3.1.0 +info: + title: Museum API + version: 1.1.0 + description: Imaginary, but delightful Museum API for interview practice. +servers: + - url: https://api.fake-museum-example.com/v1.1 +tags: + - name: Tickets + description: Buy tickets and manage reservations. +paths: + /museum-hours: + get: + summary: Get museum hours + description: Get upcoming museum operating hours. + operationId: getMuseumHours + responses: + '200': + description: Success. + /tickets: + post: + summary: Buy museum tickets + operationId: buyMuseumTickets + tags: [Tickets] + responses: + '201': + description: Created. + content: + application/json: + schema: + $ref: '#/components/schemas/Ticket' + /legacy-tickets: + post: + summary: Buy tickets the old way + deprecated: true + tags: [Tickets] + responses: + '201': + description: Created. +webhooks: + publicationOfNewEvent: + post: + summary: New event added + responses: + '200': + description: Acknowledged. +components: + schemas: + Ticket: + description: A ticket for museum entry or special event. + type: object + properties: + ticketId: + type: string + securitySchemes: + MuseumPlaceholderAuth: + type: http + scheme: basic diff --git a/tests/e2e/tree/index-json-by-paths/snapshot.txt b/tests/e2e/tree/index-json-by-paths/snapshot.txt new file mode 100644 index 0000000000..209c9a6706 --- /dev/null +++ b/tests/e2e/tree/index-json-by-paths/snapshot.txt @@ -0,0 +1,154 @@ +{ + "docName": "openapi.yaml", + "spec": "oas3_1", + "docDescription": "Museum API — Imaginary, but delightful Museum API for interview practice.", + "structure": [ + { + "id": "Overview", + "title": "Overview", + "pointer": "#/info", + "file": "openapi.yaml", + "start_line": 3, + "end_line": 5, + "summary": "Imaginary, but delightful Museum API for interview practice." + }, + { + "id": "Servers", + "title": "Servers", + "pointer": "#/servers", + "file": "openapi.yaml", + "start_line": 7, + "end_line": 7, + "summary": "https://api.fake-museum-example.com/v1.1" + }, + { + "id": "Operations", + "title": "Operations", + "pointer": "#/paths", + "file": "openapi.yaml", + "start_line": 12, + "end_line": 39, + "nodes": [ + { + "id": "/museum-hours", + "title": "/museum-hours", + "pointer": "#/paths/~1museum-hours", + "file": "openapi.yaml", + "start_line": 13, + "end_line": 19, + "nodes": [ + { + "id": "GET /museum-hours", + "title": "GET /museum-hours — Get museum hours", + "operationId": "getMuseumHours", + "pointer": "#/paths/~1museum-hours/get", + "file": "openapi.yaml", + "start_line": 14, + "end_line": 19, + "summary": "Get museum hours" + } + ] + }, + { + "id": "/tickets", + "title": "/tickets", + "pointer": "#/paths/~1tickets", + "file": "openapi.yaml", + "start_line": 21, + "end_line": 31, + "nodes": [ + { + "id": "POST /tickets", + "title": "POST /tickets — Buy museum tickets", + "operationId": "buyMuseumTickets", + "pointer": "#/paths/~1tickets/post", + "file": "openapi.yaml", + "start_line": 22, + "end_line": 31, + "summary": "Buy museum tickets" + } + ] + }, + { + "id": "/legacy-tickets", + "title": "/legacy-tickets", + "pointer": "#/paths/~1legacy-tickets", + "file": "openapi.yaml", + "start_line": 33, + "end_line": 39, + "nodes": [ + { + "id": "POST /legacy-tickets", + "title": "POST /legacy-tickets — Buy tickets the old way", + "deprecated": true, + "pointer": "#/paths/~1legacy-tickets/post", + "file": "openapi.yaml", + "start_line": 34, + "end_line": 39, + "summary": "Buy tickets the old way" + } + ] + } + ] + }, + { + "id": "Webhooks", + "title": "Webhooks", + "pointer": "#/webhooks", + "file": "openapi.yaml", + "start_line": 41, + "end_line": 46, + "nodes": [ + { + "id": "POST publicationOfNewEvent", + "title": "POST publicationOfNewEvent — New event added", + "pointer": "#/webhooks/publicationOfNewEvent/post", + "file": "openapi.yaml", + "start_line": 43, + "end_line": 46, + "summary": "New event added" + } + ] + }, + { + "id": "Components", + "title": "Components", + "pointer": "#/components", + "file": "openapi.yaml", + "start_line": 48, + "end_line": 58, + "nodes": [ + { + "id": "components/schemas", + "title": "schemas", + "nodes": [ + { + "id": "schemas/Ticket", + "title": "Ticket", + "pointer": "#/components/schemas/Ticket", + "file": "openapi.yaml", + "start_line": 50, + "end_line": 54, + "summary": "A ticket for museum entry or special event." + } + ] + }, + { + "id": "components/securitySchemes", + "title": "securitySchemes", + "nodes": [ + { + "id": "securitySchemes/MuseumPlaceholderAuth", + "title": "MuseumPlaceholderAuth", + "pointer": "#/components/securitySchemes/MuseumPlaceholderAuth", + "file": "openapi.yaml", + "start_line": 57, + "end_line": 58 + } + ] + } + ] + } + ] +} + diff --git a/tests/e2e/tree/index-json/snapshot.txt b/tests/e2e/tree/index-json/snapshot.txt new file mode 100644 index 0000000000..323367823b --- /dev/null +++ b/tests/e2e/tree/index-json/snapshot.txt @@ -0,0 +1,141 @@ +{ + "docName": "openapi.yaml", + "spec": "oas3_1", + "docDescription": "Museum API — Imaginary, but delightful Museum API for interview practice.", + "structure": [ + { + "id": "Overview", + "title": "Overview", + "pointer": "#/info", + "file": "openapi.yaml", + "start_line": 3, + "end_line": 5, + "summary": "Imaginary, but delightful Museum API for interview practice." + }, + { + "id": "Servers", + "title": "Servers", + "pointer": "#/servers", + "file": "openapi.yaml", + "start_line": 7, + "end_line": 7, + "summary": "https://api.fake-museum-example.com/v1.1" + }, + { + "id": "Operations", + "title": "Operations", + "pointer": "#/paths", + "file": "openapi.yaml", + "start_line": 12, + "end_line": 39, + "nodes": [ + { + "id": "Tickets", + "title": "Tickets", + "pointer": "#/tags/0", + "file": "openapi.yaml", + "start_line": 9, + "end_line": 10, + "summary": "Buy tickets and manage reservations.", + "nodes": [ + { + "id": "POST /tickets", + "title": "POST /tickets — Buy museum tickets", + "operationId": "buyMuseumTickets", + "pointer": "#/paths/~1tickets/post", + "file": "openapi.yaml", + "start_line": 22, + "end_line": 31, + "summary": "Buy museum tickets" + }, + { + "id": "POST /legacy-tickets", + "title": "POST /legacy-tickets — Buy tickets the old way", + "deprecated": true, + "pointer": "#/paths/~1legacy-tickets/post", + "file": "openapi.yaml", + "start_line": 34, + "end_line": 39, + "summary": "Buy tickets the old way" + } + ] + }, + { + "id": "untagged", + "title": "untagged", + "nodes": [ + { + "id": "GET /museum-hours", + "title": "GET /museum-hours — Get museum hours", + "operationId": "getMuseumHours", + "pointer": "#/paths/~1museum-hours/get", + "file": "openapi.yaml", + "start_line": 14, + "end_line": 19, + "summary": "Get museum hours" + } + ] + } + ] + }, + { + "id": "Webhooks", + "title": "Webhooks", + "pointer": "#/webhooks", + "file": "openapi.yaml", + "start_line": 41, + "end_line": 46, + "nodes": [ + { + "id": "POST publicationOfNewEvent", + "title": "POST publicationOfNewEvent — New event added", + "pointer": "#/webhooks/publicationOfNewEvent/post", + "file": "openapi.yaml", + "start_line": 43, + "end_line": 46, + "summary": "New event added" + } + ] + }, + { + "id": "Components", + "title": "Components", + "pointer": "#/components", + "file": "openapi.yaml", + "start_line": 48, + "end_line": 58, + "nodes": [ + { + "id": "components/schemas", + "title": "schemas", + "nodes": [ + { + "id": "schemas/Ticket", + "title": "Ticket", + "pointer": "#/components/schemas/Ticket", + "file": "openapi.yaml", + "start_line": 50, + "end_line": 54, + "summary": "A ticket for museum entry or special event." + } + ] + }, + { + "id": "components/securitySchemes", + "title": "securitySchemes", + "nodes": [ + { + "id": "securitySchemes/MuseumPlaceholderAuth", + "title": "MuseumPlaceholderAuth", + "pointer": "#/components/securitySchemes/MuseumPlaceholderAuth", + "file": "openapi.yaml", + "start_line": 57, + "end_line": 58 + } + ] + } + ] + } + ] +} + diff --git a/tests/e2e/tree/multi-api/a.yaml b/tests/e2e/tree/multi-api/a.yaml new file mode 100644 index 0000000000..be31314c37 --- /dev/null +++ b/tests/e2e/tree/multi-api/a.yaml @@ -0,0 +1,14 @@ +openapi: 3.0.0 +info: + title: A + version: '1.0' +paths: + /a: + get: + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: ./shared.yaml#/components/schemas/Shared diff --git a/tests/e2e/tree/multi-api/b.yaml b/tests/e2e/tree/multi-api/b.yaml new file mode 100644 index 0000000000..03b60f8237 --- /dev/null +++ b/tests/e2e/tree/multi-api/b.yaml @@ -0,0 +1,14 @@ +openapi: 3.0.0 +info: + title: B + version: '1.0' +paths: + /b: + get: + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: ./shared.yaml#/components/schemas/Shared diff --git a/tests/e2e/tree/multi-api/shared.yaml b/tests/e2e/tree/multi-api/shared.yaml new file mode 100644 index 0000000000..0372a52494 --- /dev/null +++ b/tests/e2e/tree/multi-api/shared.yaml @@ -0,0 +1,7 @@ +components: + schemas: + Shared: + type: object + properties: + id: + type: string diff --git a/tests/e2e/tree/node-branch/snapshot.txt b/tests/e2e/tree/node-branch/snapshot.txt new file mode 100644 index 0000000000..69bdd7b557 --- /dev/null +++ b/tests/e2e/tree/node-branch/snapshot.txt @@ -0,0 +1,39 @@ +{ + "docName": "openapi.yaml", + "spec": "oas3_1", + "docDescription": "Museum API — Imaginary, but delightful Museum API for interview practice.", + "structure": [ + { + "id": "Tickets", + "title": "Tickets", + "pointer": "#/tags/0", + "file": "openapi.yaml", + "start_line": 9, + "end_line": 10, + "summary": "Buy tickets and manage reservations.", + "nodes": [ + { + "id": "POST /tickets", + "title": "POST /tickets — Buy museum tickets", + "operationId": "buyMuseumTickets", + "pointer": "#/paths/~1tickets/post", + "file": "openapi.yaml", + "start_line": 22, + "end_line": 31, + "summary": "Buy museum tickets" + }, + { + "id": "POST /legacy-tickets", + "title": "POST /legacy-tickets — Buy tickets the old way", + "deprecated": true, + "pointer": "#/paths/~1legacy-tickets/post", + "file": "openapi.yaml", + "start_line": 34, + "end_line": 39, + "summary": "Buy tickets the old way" + } + ] + } + ] +} + diff --git a/tests/e2e/tree/node-leaf-pointer/snapshot.txt b/tests/e2e/tree/node-leaf-pointer/snapshot.txt new file mode 100644 index 0000000000..acf6692e8d --- /dev/null +++ b/tests/e2e/tree/node-leaf-pointer/snapshot.txt @@ -0,0 +1,17 @@ +{ + "id": "GET /orders", + "pointer": "#/get", + "file": "paths/orders.yaml", + "start_line": 2, + "end_line": 10, + "content": " operationId: listOrders\n summary: List orders\n responses:\n '200':\n description: A list of orders.\n content:\n application/json:\n schema:\n $ref: ../components/schemas/OrderList.yaml", + "refs": [ + { + "ref": "../components/schemas/OrderList.yaml", + "resolved": true, + "file": "components/schemas/OrderList.yaml", + "pointer": "#/" + } + ] +} + diff --git a/tests/e2e/tree/node-leaf/snapshot.txt b/tests/e2e/tree/node-leaf/snapshot.txt new file mode 100644 index 0000000000..acf6692e8d --- /dev/null +++ b/tests/e2e/tree/node-leaf/snapshot.txt @@ -0,0 +1,17 @@ +{ + "id": "GET /orders", + "pointer": "#/get", + "file": "paths/orders.yaml", + "start_line": 2, + "end_line": 10, + "content": " operationId: listOrders\n summary: List orders\n responses:\n '200':\n description: A list of orders.\n content:\n application/json:\n schema:\n $ref: ../components/schemas/OrderList.yaml", + "refs": [ + { + "ref": "../components/schemas/OrderList.yaml", + "resolved": true, + "file": "components/schemas/OrderList.yaml", + "pointer": "#/" + } + ] +} + diff --git a/tests/e2e/tree/node-unknown/snapshot.txt b/tests/e2e/tree/node-unknown/snapshot.txt new file mode 100644 index 0000000000..a954207929 --- /dev/null +++ b/tests/e2e/tree/node-unknown/snapshot.txt @@ -0,0 +1,3 @@ + +No index node matches "GET /nowhere". Run `redocly tree --format=json` to list node ids. + diff --git a/tests/e2e/tree/node-with-deps/snapshot.txt b/tests/e2e/tree/node-with-deps/snapshot.txt new file mode 100644 index 0000000000..fa12c547ea --- /dev/null +++ b/tests/e2e/tree/node-with-deps/snapshot.txt @@ -0,0 +1,75 @@ +{ + "id": "GET /orders", + "pointer": "#/get", + "file": "paths/orders.yaml", + "start_line": 2, + "end_line": 10, + "content": " operationId: listOrders\n summary: List orders\n responses:\n '200':\n description: A list of orders.\n content:\n application/json:\n schema:\n $ref: ../components/schemas/OrderList.yaml", + "refs": [ + { + "ref": "../components/schemas/OrderList.yaml", + "resolved": true, + "file": "components/schemas/OrderList.yaml", + "pointer": "#/" + } + ], + "deps": [ + { + "id": "schemas/OrderList", + "pointer": "#/", + "file": "components/schemas/OrderList.yaml", + "start_line": 1, + "end_line": 6, + "content": "type: object\nproperties:\n items:\n type: array\n items:\n $ref: Order.yaml", + "refs": [ + { + "ref": "Order.yaml", + "resolved": true, + "file": "components/schemas/Order.yaml", + "pointer": "#/" + } + ] + }, + { + "id": "schemas/Order", + "pointer": "#/", + "file": "components/schemas/Order.yaml", + "start_line": 1, + "end_line": 10, + "content": "type: object\nproperties:\n id:\n type: string\n status:\n $ref: OrderStatus.yaml\n items:\n type: array\n items:\n $ref: MenuItem.yaml", + "refs": [ + { + "ref": "MenuItem.yaml", + "resolved": true, + "file": "components/schemas/MenuItem.yaml", + "pointer": "#/" + }, + { + "ref": "OrderStatus.yaml", + "resolved": true, + "file": "components/schemas/OrderStatus.yaml", + "pointer": "#/" + } + ] + }, + { + "id": "schemas/MenuItem", + "pointer": "#/", + "file": "components/schemas/MenuItem.yaml", + "start_line": 1, + "end_line": 8, + "content": "type: object\nproperties:\n id:\n type: string\n name:\n type: string\n price:\n type: number", + "refs": [] + }, + { + "id": "schemas/OrderStatus", + "pointer": "#/", + "file": "components/schemas/OrderStatus.yaml", + "start_line": 1, + "end_line": 5, + "content": "type: string\nenum:\n - placed\n - served\n - paid", + "refs": [] + } + ] +} + diff --git a/tests/e2e/tree/sample-split/components/schemas/Error.yaml b/tests/e2e/tree/sample-split/components/schemas/Error.yaml new file mode 100644 index 0000000000..0f39c053d7 --- /dev/null +++ b/tests/e2e/tree/sample-split/components/schemas/Error.yaml @@ -0,0 +1,6 @@ +type: object +properties: + code: + type: integer + message: + type: string diff --git a/tests/e2e/tree/sample-split/components/schemas/MenuItem.yaml b/tests/e2e/tree/sample-split/components/schemas/MenuItem.yaml new file mode 100644 index 0000000000..1cc2fb50d8 --- /dev/null +++ b/tests/e2e/tree/sample-split/components/schemas/MenuItem.yaml @@ -0,0 +1,8 @@ +type: object +properties: + id: + type: string + name: + type: string + price: + type: number diff --git a/tests/e2e/tree/sample-split/components/schemas/Order.yaml b/tests/e2e/tree/sample-split/components/schemas/Order.yaml new file mode 100644 index 0000000000..f3113b0434 --- /dev/null +++ b/tests/e2e/tree/sample-split/components/schemas/Order.yaml @@ -0,0 +1,10 @@ +type: object +properties: + id: + type: string + status: + $ref: OrderStatus.yaml + items: + type: array + items: + $ref: MenuItem.yaml diff --git a/tests/e2e/tree/sample-split/components/schemas/OrderList.yaml b/tests/e2e/tree/sample-split/components/schemas/OrderList.yaml new file mode 100644 index 0000000000..3444416965 --- /dev/null +++ b/tests/e2e/tree/sample-split/components/schemas/OrderList.yaml @@ -0,0 +1,6 @@ +type: object +properties: + items: + type: array + items: + $ref: Order.yaml diff --git a/tests/e2e/tree/sample-split/components/schemas/OrderStatus.yaml b/tests/e2e/tree/sample-split/components/schemas/OrderStatus.yaml new file mode 100644 index 0000000000..b58ba5edee --- /dev/null +++ b/tests/e2e/tree/sample-split/components/schemas/OrderStatus.yaml @@ -0,0 +1,5 @@ +type: string +enum: + - placed + - served + - paid diff --git a/tests/e2e/tree/sample-split/openapi.yaml b/tests/e2e/tree/sample-split/openapi.yaml new file mode 100644 index 0000000000..2141db2eb7 --- /dev/null +++ b/tests/e2e/tree/sample-split/openapi.yaml @@ -0,0 +1,21 @@ +openapi: 3.2.0 +info: + title: Sample Cafe API + version: 1.0.0 +paths: + /orders: + $ref: paths/orders.yaml + /orders/{orderId}: + $ref: paths/orders_{orderId}.yaml +components: + schemas: + Order: + $ref: components/schemas/Order.yaml + OrderStatus: + $ref: components/schemas/OrderStatus.yaml + MenuItem: + $ref: components/schemas/MenuItem.yaml + OrderList: + $ref: components/schemas/OrderList.yaml + Error: + $ref: components/schemas/Error.yaml diff --git a/tests/e2e/tree/sample-split/paths/orders.yaml b/tests/e2e/tree/sample-split/paths/orders.yaml new file mode 100644 index 0000000000..d585d2ac1e --- /dev/null +++ b/tests/e2e/tree/sample-split/paths/orders.yaml @@ -0,0 +1,26 @@ +get: + operationId: listOrders + summary: List orders + responses: + '200': + description: A list of orders. + content: + application/json: + schema: + $ref: ../components/schemas/OrderList.yaml +post: + operationId: createOrder + summary: Create an order + requestBody: + required: true + content: + application/json: + schema: + $ref: ../components/schemas/Order.yaml + responses: + '201': + description: The created order. + content: + application/json: + schema: + $ref: ../components/schemas/Order.yaml diff --git a/tests/e2e/tree/sample-split/paths/orders_{orderId}.yaml b/tests/e2e/tree/sample-split/paths/orders_{orderId}.yaml new file mode 100644 index 0000000000..39d8e30e88 --- /dev/null +++ b/tests/e2e/tree/sample-split/paths/orders_{orderId}.yaml @@ -0,0 +1,32 @@ +get: + operationId: getOrder + summary: Get an order by id + parameters: + - name: orderId + in: path + required: true + schema: + type: string + responses: + '200': + description: The requested order. + content: + application/json: + schema: + $ref: ../components/schemas/Order.yaml +delete: + operationId: cancelOrder + summary: Cancel an order by id + parameters: + - name: orderId + in: path + required: true + schema: + type: string + responses: + '404': + description: Order not found. + content: + application/json: + schema: + $ref: ../components/schemas/Error.yaml diff --git a/tests/e2e/tree/tree-files-json/snapshot.txt b/tests/e2e/tree/tree-files-json/snapshot.txt new file mode 100644 index 0000000000..d9cdb6986b --- /dev/null +++ b/tests/e2e/tree/tree-files-json/snapshot.txt @@ -0,0 +1,138 @@ +{ + "nodes": [ + { + "id": "components/schemas/Error.yaml", + "resolved": true + }, + { + "id": "components/schemas/MenuItem.yaml", + "resolved": true + }, + { + "id": "components/schemas/Order.yaml", + "resolved": true + }, + { + "id": "components/schemas/OrderList.yaml", + "resolved": true + }, + { + "id": "components/schemas/OrderStatus.yaml", + "resolved": true + }, + { + "id": "openapi.yaml", + "resolved": true, + "root": true + }, + { + "id": "paths/orders.yaml", + "resolved": true + }, + { + "id": "paths/orders_{orderId}.yaml", + "resolved": true + } + ], + "links": [ + { + "source": "components/schemas/Order.yaml", + "target": "components/schemas/MenuItem.yaml", + "refs": [ + "MenuItem.yaml" + ] + }, + { + "source": "components/schemas/Order.yaml", + "target": "components/schemas/OrderStatus.yaml", + "refs": [ + "OrderStatus.yaml" + ] + }, + { + "source": "components/schemas/OrderList.yaml", + "target": "components/schemas/Order.yaml", + "refs": [ + "Order.yaml" + ] + }, + { + "source": "openapi.yaml", + "target": "components/schemas/Error.yaml", + "refs": [ + "components/schemas/Error.yaml" + ] + }, + { + "source": "openapi.yaml", + "target": "components/schemas/MenuItem.yaml", + "refs": [ + "components/schemas/MenuItem.yaml" + ] + }, + { + "source": "openapi.yaml", + "target": "components/schemas/Order.yaml", + "refs": [ + "components/schemas/Order.yaml" + ] + }, + { + "source": "openapi.yaml", + "target": "components/schemas/OrderList.yaml", + "refs": [ + "components/schemas/OrderList.yaml" + ] + }, + { + "source": "openapi.yaml", + "target": "components/schemas/OrderStatus.yaml", + "refs": [ + "components/schemas/OrderStatus.yaml" + ] + }, + { + "source": "openapi.yaml", + "target": "paths/orders.yaml", + "refs": [ + "paths/orders.yaml" + ] + }, + { + "source": "openapi.yaml", + "target": "paths/orders_{orderId}.yaml", + "refs": [ + "paths/orders_{orderId}.yaml" + ] + }, + { + "source": "paths/orders.yaml", + "target": "components/schemas/Order.yaml", + "refs": [ + "../components/schemas/Order.yaml" + ] + }, + { + "source": "paths/orders.yaml", + "target": "components/schemas/OrderList.yaml", + "refs": [ + "../components/schemas/OrderList.yaml" + ] + }, + { + "source": "paths/orders_{orderId}.yaml", + "target": "components/schemas/Error.yaml", + "refs": [ + "../components/schemas/Error.yaml" + ] + }, + { + "source": "paths/orders_{orderId}.yaml", + "target": "components/schemas/Order.yaml", + "refs": [ + "../components/schemas/Order.yaml" + ] + } + ] +} + diff --git a/tests/e2e/tree/tree-files-multi-api/snapshot.txt b/tests/e2e/tree/tree-files-multi-api/snapshot.txt new file mode 100644 index 0000000000..e9e3d85f47 --- /dev/null +++ b/tests/e2e/tree/tree-files-multi-api/snapshot.txt @@ -0,0 +1,6 @@ +a.yaml +└── shared.yaml + +b.yaml +└── shared.yaml + diff --git a/tests/e2e/tree/tree-files-stylish/snapshot.txt b/tests/e2e/tree/tree-files-stylish/snapshot.txt new file mode 100644 index 0000000000..77f9d9bab1 --- /dev/null +++ b/tests/e2e/tree/tree-files-stylish/snapshot.txt @@ -0,0 +1,25 @@ +openapi.yaml +├── components/schemas/Error.yaml +├── components/schemas/MenuItem.yaml +├── components/schemas/Order.yaml +│ ├── components/schemas/MenuItem.yaml +│ └── components/schemas/OrderStatus.yaml +├── components/schemas/OrderList.yaml +│ └── components/schemas/Order.yaml +│ ├── components/schemas/MenuItem.yaml +│ └── components/schemas/OrderStatus.yaml +├── components/schemas/OrderStatus.yaml +├── paths/orders.yaml +│ ├── components/schemas/Order.yaml +│ │ ├── components/schemas/MenuItem.yaml +│ │ └── components/schemas/OrderStatus.yaml +│ └── components/schemas/OrderList.yaml +│ └── components/schemas/Order.yaml +│ ├── components/schemas/MenuItem.yaml +│ └── components/schemas/OrderStatus.yaml +└── paths/orders_{orderId}.yaml + ├── components/schemas/Error.yaml + └── components/schemas/Order.yaml + ├── components/schemas/MenuItem.yaml + └── components/schemas/OrderStatus.yaml + diff --git a/tests/e2e/tree/tree-files-used-by/snapshot.txt b/tests/e2e/tree/tree-files-used-by/snapshot.txt new file mode 100644 index 0000000000..cfa3f36f25 --- /dev/null +++ b/tests/e2e/tree/tree-files-used-by/snapshot.txt @@ -0,0 +1,13 @@ +openapi.yaml +├── components/schemas/Order.yaml +├── components/schemas/OrderList.yaml +│ └── components/schemas/Order.yaml +├── paths/orders.yaml +│ ├── components/schemas/Order.yaml +│ └── components/schemas/OrderList.yaml +│ └── components/schemas/Order.yaml +└── paths/orders_{orderId}.yaml + └── components/schemas/Order.yaml + +5 of 8 files affected · affected roots: openapi.yaml + diff --git a/tests/e2e/tree/tree-multi-api-error/snapshot.txt b/tests/e2e/tree/tree-multi-api-error/snapshot.txt new file mode 100644 index 0000000000..ac7fe1d4ef --- /dev/null +++ b/tests/e2e/tree/tree-multi-api-error/snapshot.txt @@ -0,0 +1,3 @@ + +The tree command shows the structure of one API description at a time. Pass a single API, or use --files for the multi-API file-level graph. + diff --git a/tests/e2e/tree/tree-structure-dot/snapshot.txt b/tests/e2e/tree/tree-structure-dot/snapshot.txt new file mode 100644 index 0000000000..090e06319e --- /dev/null +++ b/tests/e2e/tree/tree-structure-dot/snapshot.txt @@ -0,0 +1,28 @@ +digraph tree { + "/orders"; + "/orders/{orderId}"; + "DELETE /orders/{orderId}"; + "GET /orders"; + "GET /orders/{orderId}"; + "POST /orders"; + "openapi.yaml" [shape=box, style=bold]; + "schemas/Error"; + "schemas/MenuItem"; + "schemas/Order"; + "schemas/OrderList"; + "schemas/OrderStatus"; + "/orders" -> "GET /orders"; + "/orders" -> "POST /orders"; + "/orders/{orderId}" -> "DELETE /orders/{orderId}"; + "/orders/{orderId}" -> "GET /orders/{orderId}"; + "DELETE /orders/{orderId}" -> "schemas/Error"; + "GET /orders" -> "schemas/OrderList"; + "GET /orders/{orderId}" -> "schemas/Order"; + "POST /orders" -> "schemas/Order"; + "openapi.yaml" -> "/orders"; + "openapi.yaml" -> "/orders/{orderId}"; + "schemas/Order" -> "schemas/MenuItem"; + "schemas/Order" -> "schemas/OrderStatus"; + "schemas/OrderList" -> "schemas/Order"; +} + diff --git a/tests/e2e/tree/tree-structure-json/snapshot.txt b/tests/e2e/tree/tree-structure-json/snapshot.txt new file mode 100644 index 0000000000..7f2bce283b --- /dev/null +++ b/tests/e2e/tree/tree-structure-json/snapshot.txt @@ -0,0 +1,128 @@ +{ + "docName": "openapi.yaml", + "spec": "oas3_2", + "docDescription": "Sample Cafe API", + "structure": [ + { + "id": "Overview", + "title": "Overview", + "pointer": "#/info", + "file": "openapi.yaml", + "start_line": 3, + "end_line": 4 + }, + { + "id": "Operations", + "title": "Operations", + "pointer": "#/paths", + "file": "openapi.yaml", + "start_line": 6, + "end_line": 9, + "nodes": [ + { + "id": "untagged", + "title": "untagged", + "nodes": [ + { + "id": "GET /orders", + "title": "GET /orders — List orders", + "operationId": "listOrders", + "pointer": "#/get", + "file": "paths/orders.yaml", + "start_line": 2, + "end_line": 10, + "summary": "List orders" + }, + { + "id": "POST /orders", + "title": "POST /orders — Create an order", + "operationId": "createOrder", + "pointer": "#/post", + "file": "paths/orders.yaml", + "start_line": 12, + "end_line": 26, + "summary": "Create an order" + }, + { + "id": "GET /orders/{orderId}", + "title": "GET /orders/{orderId} — Get an order by id", + "operationId": "getOrder", + "pointer": "#/get", + "file": "paths/orders_{orderId}.yaml", + "start_line": 2, + "end_line": 16, + "summary": "Get an order by id" + }, + { + "id": "DELETE /orders/{orderId}", + "title": "DELETE /orders/{orderId} — Cancel an order by id", + "operationId": "cancelOrder", + "pointer": "#/delete", + "file": "paths/orders_{orderId}.yaml", + "start_line": 18, + "end_line": 32, + "summary": "Cancel an order by id" + } + ] + } + ] + }, + { + "id": "Components", + "title": "Components", + "pointer": "#/components", + "file": "openapi.yaml", + "start_line": 11, + "end_line": 21, + "nodes": [ + { + "id": "components/schemas", + "title": "schemas", + "nodes": [ + { + "id": "schemas/Order", + "title": "Order", + "pointer": "#/", + "file": "components/schemas/Order.yaml", + "start_line": 1, + "end_line": 10 + }, + { + "id": "schemas/OrderStatus", + "title": "OrderStatus", + "pointer": "#/", + "file": "components/schemas/OrderStatus.yaml", + "start_line": 1, + "end_line": 5 + }, + { + "id": "schemas/MenuItem", + "title": "MenuItem", + "pointer": "#/", + "file": "components/schemas/MenuItem.yaml", + "start_line": 1, + "end_line": 8 + }, + { + "id": "schemas/OrderList", + "title": "OrderList", + "pointer": "#/", + "file": "components/schemas/OrderList.yaml", + "start_line": 1, + "end_line": 6 + }, + { + "id": "schemas/Error", + "title": "Error", + "pointer": "#/", + "file": "components/schemas/Error.yaml", + "start_line": 1, + "end_line": 6 + } + ] + } + ] + } + ] +} + diff --git a/tests/e2e/tree/tree-structure-level/snapshot.txt b/tests/e2e/tree/tree-structure-level/snapshot.txt new file mode 100644 index 0000000000..2da1d89a32 --- /dev/null +++ b/tests/e2e/tree/tree-structure-level/snapshot.txt @@ -0,0 +1,4 @@ +openapi.yaml +├── /orders … +└── /orders/{orderId} … + diff --git a/tests/e2e/tree/tree-structure-mermaid/snapshot.txt b/tests/e2e/tree/tree-structure-mermaid/snapshot.txt new file mode 100644 index 0000000000..1a6e96a5a4 --- /dev/null +++ b/tests/e2e/tree/tree-structure-mermaid/snapshot.txt @@ -0,0 +1,28 @@ +flowchart LR + n0["/orders"] + n1["/orders/{orderId}"] + n2["DELETE /orders/{orderId}"] + n3["GET /orders"] + n4["GET /orders/{orderId}"] + n5["POST /orders"] + n6["openapi.yaml"]:::root + n7["schemas/Error"] + n8["schemas/MenuItem"] + n9["schemas/Order"] + n10["schemas/OrderList"] + n11["schemas/OrderStatus"] + n0 --> n3 + n0 --> n5 + n1 --> n2 + n1 --> n4 + n2 --> n7 + n3 --> n10 + n4 --> n9 + n5 --> n9 + n6 --> n0 + n6 --> n1 + n9 --> n8 + n9 --> n11 + n10 --> n9 + classDef root font-weight:bold + diff --git a/tests/e2e/tree/tree-structure-operations/snapshot.txt b/tests/e2e/tree/tree-structure-operations/snapshot.txt new file mode 100644 index 0000000000..d4686f30e6 --- /dev/null +++ b/tests/e2e/tree/tree-structure-operations/snapshot.txt @@ -0,0 +1,8 @@ +openapi.yaml +├── /orders +│ ├── GET (listOrders) +│ └── POST (createOrder) +└── /orders/{orderId} + ├── DELETE (cancelOrder) + └── GET (getOrder) + diff --git a/tests/e2e/tree/tree-structure-stylish/snapshot.txt b/tests/e2e/tree/tree-structure-stylish/snapshot.txt new file mode 100644 index 0000000000..73088b4509 --- /dev/null +++ b/tests/e2e/tree/tree-structure-stylish/snapshot.txt @@ -0,0 +1,19 @@ +openapi.yaml +├── /orders +│ ├── GET +│ │ └── schemas/OrderList +│ │ └── schemas/Order +│ │ ├── schemas/MenuItem +│ │ └── schemas/OrderStatus +│ └── POST +│ └── schemas/Order +│ ├── schemas/MenuItem +│ └── schemas/OrderStatus +└── /orders/{orderId} + ├── DELETE + │ └── schemas/Error + └── GET + └── schemas/Order + ├── schemas/MenuItem + └── schemas/OrderStatus + diff --git a/tests/e2e/tree/tree-structure-used-by-file/snapshot.txt b/tests/e2e/tree/tree-structure-used-by-file/snapshot.txt new file mode 100644 index 0000000000..bec97cf1ba --- /dev/null +++ b/tests/e2e/tree/tree-structure-used-by-file/snapshot.txt @@ -0,0 +1,13 @@ +openapi.yaml +├── /orders +│ ├── GET +│ │ └── schemas/OrderList +│ │ └── schemas/Order +│ └── POST +│ └── schemas/Order +└── /orders/{orderId} + └── GET + └── schemas/Order + +3 of 4 operations affected · affected paths: /orders, /orders/{orderId} + diff --git a/tests/e2e/tree/tree-structure-used-by-unknown/snapshot.txt b/tests/e2e/tree/tree-structure-used-by-unknown/snapshot.txt new file mode 100644 index 0000000000..746f562533 --- /dev/null +++ b/tests/e2e/tree/tree-structure-used-by-unknown/snapshot.txt @@ -0,0 +1,3 @@ +No nodes affected. + +schemas/Unknown does not match any path, operation, or component of openapi.yaml. diff --git a/tests/e2e/tree/tree-structure-used-by/snapshot.txt b/tests/e2e/tree/tree-structure-used-by/snapshot.txt new file mode 100644 index 0000000000..bec97cf1ba --- /dev/null +++ b/tests/e2e/tree/tree-structure-used-by/snapshot.txt @@ -0,0 +1,13 @@ +openapi.yaml +├── /orders +│ ├── GET +│ │ └── schemas/OrderList +│ │ └── schemas/Order +│ └── POST +│ └── schemas/Order +└── /orders/{orderId} + └── GET + └── schemas/Order + +3 of 4 operations affected · affected paths: /orders, /orders/{orderId} + diff --git a/tests/e2e/tree/tree-structure-uses-wildcard/snapshot.txt b/tests/e2e/tree/tree-structure-uses-wildcard/snapshot.txt new file mode 100644 index 0000000000..29afd451fe --- /dev/null +++ b/tests/e2e/tree/tree-structure-uses-wildcard/snapshot.txt @@ -0,0 +1,16 @@ +openapi.yaml +├── /orders +│ ├── GET +│ │ └── schemas/OrderList +│ │ └── schemas/Order +│ │ └── schemas/OrderStatus +│ └── POST +│ └── schemas/Order +│ └── schemas/OrderStatus +└── /orders/{orderId} + └── GET + └── schemas/Order + └── schemas/OrderStatus + +3 of 4 operations affected · affected paths: /orders, /orders/{orderId} + diff --git a/tests/e2e/tree/tree.test.ts b/tests/e2e/tree/tree.test.ts new file mode 100644 index 0000000000..b31808b258 --- /dev/null +++ b/tests/e2e/tree/tree.test.ts @@ -0,0 +1,222 @@ +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { getCommandOutput, getParams, cleanupOutput } from '../helpers.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const indexEntryPoint = join(process.cwd(), 'packages/cli/lib/index.js'); + +describe('tree', () => { + const folderPath = __dirname; + const samplePath = join(folderPath, 'sample-split'); + const multiApiPath = join(folderPath, 'multi-api'); + const snapshot = (name: string) => join(folderPath, name, 'snapshot.txt'); + + test('tree prints the document structure', async () => { + const args = getParams(indexEntryPoint, ['tree', 'openapi.yaml']); + const result = getCommandOutput(args, { testPath: samplePath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot(snapshot('tree-structure-stylish')); + }); + + test('tree prints the structure as JSON', async () => { + const args = getParams(indexEntryPoint, ['tree', 'openapi.yaml', '--format=json']); + const result = getCommandOutput(args, { testPath: samplePath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot(snapshot('tree-structure-json')); + }); + + test('tree prints the structure as a mermaid diagram', async () => { + const args = getParams(indexEntryPoint, ['tree', 'openapi.yaml', '--format=mermaid']); + const result = getCommandOutput(args, { testPath: samplePath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot(snapshot('tree-structure-mermaid')); + }); + + test('tree prints the structure as a Graphviz dot graph', async () => { + const args = getParams(indexEntryPoint, ['tree', 'openapi.yaml', '--format=dot']); + const result = getCommandOutput(args, { testPath: samplePath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot(snapshot('tree-structure-dot')); + }); + + test('tree limits the displayed depth with --level', async () => { + const args = getParams(indexEntryPoint, ['tree', 'openapi.yaml', '--level', '1']); + const result = getCommandOutput(args, { testPath: samplePath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot(snapshot('tree-structure-level')); + }); + + test('tree shows only the API surface with --operations', async () => { + const args = getParams(indexEntryPoint, ['tree', 'openapi.yaml', '--operations']); + const result = getCommandOutput(args, { testPath: samplePath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot(snapshot('tree-structure-operations')); + }); + + test('tree expands a --uses wildcard against node ids', async () => { + const args = getParams(indexEntryPoint, ['tree', 'openapi.yaml', '--uses', 'schemas/Order*']); + const result = getCommandOutput(args, { testPath: samplePath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot( + snapshot('tree-structure-uses-wildcard') + ); + }); + + test('tree shows what a component pointer is used by', async () => { + const args = getParams(indexEntryPoint, [ + 'tree', + 'openapi.yaml', + '--uses', + '#/components/schemas/Order', + ]); + const result = getCommandOutput(args, { testPath: samplePath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot(snapshot('tree-structure-used-by')); + }); + + test('tree warns for an unknown used-by input', async () => { + const args = getParams(indexEntryPoint, ['tree', 'openapi.yaml', '--uses', 'schemas/Unknown']); + const result = getCommandOutput(args, { testPath: samplePath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot( + snapshot('tree-structure-used-by-unknown') + ); + }); + + test('tree shows what a component file is used by in the default view', async () => { + const args = getParams(indexEntryPoint, [ + 'tree', + 'openapi.yaml', + '--uses', + 'components/schemas/Order.yaml', + ]); + const result = getCommandOutput(args, { testPath: samplePath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot( + snapshot('tree-structure-used-by-file') + ); + }); + + test('tree --uses filters the JSON index and keeps split components by file', async () => { + const args = getParams(indexEntryPoint, [ + 'tree', + 'openapi.yaml', + '--uses', + 'components/schemas/Order.yaml', + '--format=json', + ]); + const result = getCommandOutput(args, { testPath: samplePath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot(snapshot('uses-json')); + }); + + test('tree --files prints the file-level graph', async () => { + const args = getParams(indexEntryPoint, ['tree', 'openapi.yaml', '--files']); + const result = getCommandOutput(args, { testPath: samplePath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot(snapshot('tree-files-stylish')); + }); + + test('tree --files prints the file-level graph as JSON', async () => { + const args = getParams(indexEntryPoint, ['tree', 'openapi.yaml', '--files', '--format=json']); + const result = getCommandOutput(args, { testPath: samplePath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot(snapshot('tree-files-json')); + }); + + test('tree --files shows what a file is used by', async () => { + const args = getParams(indexEntryPoint, [ + 'tree', + 'openapi.yaml', + '--files', + '--uses', + 'components/schemas/Order.yaml', + ]); + const result = getCommandOutput(args, { testPath: samplePath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot(snapshot('tree-files-used-by')); + }); + + test('tree --files resolves --uses relative to the API root, regardless of cwd', async () => { + const args = getParams(indexEntryPoint, [ + 'tree', + 'sample-split/openapi.yaml', + '--files', + '--uses', + 'components/schemas/Order.yaml', + ]); + // Run from the parent directory so cwd is not the API's directory; the path + // is still resolved relative to the API root, so it matches the same files. + const result = getCommandOutput(args, { testPath: folderPath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot(snapshot('tree-files-used-by')); + }); + + test('tree rejects multiple APIs in the default view', async () => { + const args = getParams(indexEntryPoint, ['tree', 'a.yaml', 'b.yaml']); + const result = getCommandOutput(args, { testPath: multiApiPath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot(snapshot('tree-multi-api-error')); + }); + + test('tree --files merges multiple APIs into one graph', async () => { + const args = getParams(indexEntryPoint, ['tree', 'a.yaml', 'b.yaml', '--files']); + const result = getCommandOutput(args, { testPath: multiApiPath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot(snapshot('tree-files-multi-api')); + }); + + test('tree prints the agent index as JSON', async () => { + const args = getParams(indexEntryPoint, ['tree', 'openapi.yaml', '--format=json']); + const result = getCommandOutput(args, { testPath: join(folderPath, 'index-fixture') }); + await expect(cleanupOutput(result)).toMatchFileSnapshot(snapshot('index-json')); + }); + + test('tree groups the index by paths', async () => { + const args = getParams(indexEntryPoint, [ + 'tree', + 'openapi.yaml', + '--format=json', + '--group-by=paths', + ]); + const result = getCommandOutput(args, { testPath: join(folderPath, 'index-fixture') }); + await expect(cleanupOutput(result)).toMatchFileSnapshot(snapshot('index-json-by-paths')); + }); + + test('tree --node on a branch returns its sub-index', async () => { + const args = getParams(indexEntryPoint, ['tree', 'openapi.yaml', '--node', 'Tickets']); + const result = getCommandOutput(args, { testPath: join(folderPath, 'index-fixture') }); + await expect(cleanupOutput(result)).toMatchFileSnapshot(snapshot('node-branch')); + }); + + test('tree --node on a leaf returns its source and refs', async () => { + const args = getParams(indexEntryPoint, ['tree', 'openapi.yaml', '--node', 'GET /orders']); + const result = getCommandOutput(args, { testPath: join(folderPath, 'sample-split') }); + await expect(cleanupOutput(result)).toMatchFileSnapshot(snapshot('node-leaf')); + }); + + test('tree --node accepts a file#pointer selector', async () => { + const args = getParams(indexEntryPoint, [ + 'tree', + 'openapi.yaml', + '--node', + 'paths/orders.yaml#/get', + ]); + const result = getCommandOutput(args, { testPath: join(folderPath, 'sample-split') }); + await expect(cleanupOutput(result)).toMatchFileSnapshot(snapshot('node-leaf-pointer')); + }); + + test('tree --node --with-deps appends the dependency closure', async () => { + const args = getParams(indexEntryPoint, [ + 'tree', + 'openapi.yaml', + '--node', + 'GET /orders', + '--with-deps', + ]); + const result = getCommandOutput(args, { testPath: join(folderPath, 'sample-split') }); + await expect(cleanupOutput(result)).toMatchFileSnapshot(snapshot('node-with-deps')); + }); + + test('tree --node reports an unknown selector', async () => { + const args = getParams(indexEntryPoint, ['tree', 'openapi.yaml', '--node', 'GET /nowhere']); + const result = getCommandOutput(args, { testPath: join(folderPath, 'sample-split') }); + await expect(cleanupOutput(result)).toMatchFileSnapshot(snapshot('node-unknown')); + }); + + test('tree --uses with --format json warns that webhooks are omitted', async () => { + const args = getParams(indexEntryPoint, [ + 'tree', + 'openapi.yaml', + '--uses', + 'schemas/Ticket', + '--format=json', + ]); + const result = getCommandOutput(args, { testPath: join(folderPath, 'index-fixture') }); + await expect(cleanupOutput(result)).toMatchFileSnapshot(snapshot('uses-json-webhooks-warning')); + }); +}); diff --git a/tests/e2e/tree/uses-json-webhooks-warning/snapshot.txt b/tests/e2e/tree/uses-json-webhooks-warning/snapshot.txt new file mode 100644 index 0000000000..d2dfccc0d2 --- /dev/null +++ b/tests/e2e/tree/uses-json-webhooks-warning/snapshot.txt @@ -0,0 +1,65 @@ +{ + "docName": "openapi.yaml", + "spec": "oas3_1", + "docDescription": "Museum API — Imaginary, but delightful Museum API for interview practice.", + "structure": [ + { + "id": "Operations", + "title": "Operations", + "pointer": "#/paths", + "file": "openapi.yaml", + "start_line": 12, + "end_line": 39, + "nodes": [ + { + "id": "Tickets", + "title": "Tickets", + "pointer": "#/tags/0", + "file": "openapi.yaml", + "start_line": 9, + "end_line": 10, + "summary": "Buy tickets and manage reservations.", + "nodes": [ + { + "id": "POST /tickets", + "title": "POST /tickets — Buy museum tickets", + "operationId": "buyMuseumTickets", + "pointer": "#/paths/~1tickets/post", + "file": "openapi.yaml", + "start_line": 22, + "end_line": 31, + "summary": "Buy museum tickets" + } + ] + } + ] + }, + { + "id": "Components", + "title": "Components", + "pointer": "#/components", + "file": "openapi.yaml", + "start_line": 48, + "end_line": 58, + "nodes": [ + { + "id": "components/schemas", + "title": "schemas", + "nodes": [ + { + "id": "schemas/Ticket", + "title": "Ticket", + "pointer": "#/components/schemas/Ticket", + "file": "openapi.yaml", + "start_line": 50, + "end_line": 54, + "summary": "A ticket for museum entry or special event." + } + ] + } + ] + } + ] +} + +Webhooks are not part of the dependency graph yet, so they are omitted from --uses-filtered output. diff --git a/tests/e2e/tree/uses-json/snapshot.txt b/tests/e2e/tree/uses-json/snapshot.txt new file mode 100644 index 0000000000..74f5929d96 --- /dev/null +++ b/tests/e2e/tree/uses-json/snapshot.txt @@ -0,0 +1,86 @@ +{ + "docName": "openapi.yaml", + "spec": "oas3_2", + "docDescription": "Sample Cafe API", + "structure": [ + { + "id": "Operations", + "title": "Operations", + "pointer": "#/paths", + "file": "openapi.yaml", + "start_line": 6, + "end_line": 9, + "nodes": [ + { + "id": "untagged", + "title": "untagged", + "nodes": [ + { + "id": "GET /orders", + "title": "GET /orders — List orders", + "operationId": "listOrders", + "pointer": "#/get", + "file": "paths/orders.yaml", + "start_line": 2, + "end_line": 10, + "summary": "List orders" + }, + { + "id": "POST /orders", + "title": "POST /orders — Create an order", + "operationId": "createOrder", + "pointer": "#/post", + "file": "paths/orders.yaml", + "start_line": 12, + "end_line": 26, + "summary": "Create an order" + }, + { + "id": "GET /orders/{orderId}", + "title": "GET /orders/{orderId} — Get an order by id", + "operationId": "getOrder", + "pointer": "#/get", + "file": "paths/orders_{orderId}.yaml", + "start_line": 2, + "end_line": 16, + "summary": "Get an order by id" + } + ] + } + ] + }, + { + "id": "Components", + "title": "Components", + "pointer": "#/components", + "file": "openapi.yaml", + "start_line": 11, + "end_line": 21, + "nodes": [ + { + "id": "components/schemas", + "title": "schemas", + "nodes": [ + { + "id": "schemas/Order", + "title": "Order", + "pointer": "#/", + "file": "components/schemas/Order.yaml", + "start_line": 1, + "end_line": 10 + }, + { + "id": "schemas/OrderList", + "title": "OrderList", + "pointer": "#/", + "file": "components/schemas/OrderList.yaml", + "start_line": 1, + "end_line": 6 + } + ] + } + ] + } + ] +} +