From 721d58149a54dc6b507ed7370782527bd7e62289 Mon Sep 17 00:00:00 2001 From: kanoru Date: Thu, 11 Jun 2026 16:37:03 +0300 Subject: [PATCH 01/79] docs: add graph command design spec --- .../specs/2026-06-11-graph-command-design.md | 215 ++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-11-graph-command-design.md diff --git a/docs/superpowers/specs/2026-06-11-graph-command-design.md b/docs/superpowers/specs/2026-06-11-graph-command-design.md new file mode 100644 index 0000000000..1aec8107ce --- /dev/null +++ b/docs/superpowers/specs/2026-06-11-graph-command-design.md @@ -0,0 +1,215 @@ +# `redocly graph` Command — Design + +**Date:** 2026-06-11 +**Branch:** `feat/graph-command` +**Status:** Approved + +## Motivation + +Multi-file OpenAPI projects spread their structure across dozens of files connected by `$ref`. Today there is no way to see that structure without reading the files. Two audiences need it: + +1. **AI tooling (primary driver).** AI Review must know which files are impacted by a change without guessing or reading every file in the repo. A machine-readable dependency graph plus a built-in "what is affected by a change to file X" query answers this in one command call and saves tokens. +2. **Humans.** A `tree`-style view of an API project for quick orientation, and a Mermaid diagram for docs and PR comments (GitHub renders Mermaid natively). + +The data already exists: `resolveDocument()` in `packages/core/src/resolve.ts` produces a `ResolvedRefMap` whose entries identify, for every `$ref`, the source file, the `$ref` string, the target file, and whether the reference crosses file boundaries (`isRemote`). The command surfaces what core already computes on every bundle/lint run. + +## Goals + +- New CLI command `redocly graph` that prints the file-level `$ref` dependency graph of one or more API descriptions. +- Output formats: `stylish` (ASCII tree, default), `json` (machine-readable), `mermaid` (renderable diagram). +- Impact query: `--affected-by ` prints only the subgraph affected by changes to the given files. +- Works for every spec type core can resolve (OpenAPI 2/3.x, AsyncAPI, Arazzo) with no spec-specific logic. + +## Non-goals + +- No changes to `packages/core` — the command consumes existing public core APIs (`BaseResolver`, `resolveDocument`, spec detection/type normalization), following the precedent of the `stats` command. +- No component-level (pointer-level) graph nodes; nodes are files. Edge metadata does include the distinct `$ref` strings, which is enough detail for impact analysis. +- No DOT/Graphviz output in MVP. +- No validation: broken `$ref`s are displayed, not reported as errors — that is `lint`'s job. + +## CLI Surface + +```bash +redocly graph [apis...] # no args: all APIs from redocly.yaml (lint convention) +redocly graph openapi.yaml # explicit root(s) +redocly graph --format # default: stylish +redocly graph --affected-by [...] # impact filter, accepts multiple files +redocly graph --config # standard config flag +``` + +- Registered in `packages/cli/src/index.ts` via yargs, executed through `commandWrapper(handleGraph)` like every other command. +- Multiple roots produce one **merged** graph (shared nodes/edges deduplicated, every root flagged). This is required for trustworthy impact analysis: a shared schema may affect 2 of 5 configured APIs, and the answer must say which. +- Exit codes follow repo convention: `0` success (including "file affects nothing"), `1` execution error (root missing/unparseable), `2` config error. + +## Data Model + +The single contract consumed by all three printers: + +```ts +type DependencyGraph = { + roots: string[]; // root file ids + nodes: GraphNode[]; + edges: GraphEdge[]; // deduplicated file→file edges +}; + +type GraphNode = { + id: string; // path relative to cwd; http(s) refs keep the URL as id + root?: boolean; // entry-point API file + external?: boolean; // http(s) reference + resolved: boolean; // false: referenced but missing/unparseable +}; + +type GraphEdge = { + from: string; + to: string; + refs: string[]; // distinct $ref strings used from `from` to `to` +}; +``` + +Notes: + +- Node ids are stable, cwd-relative paths so output is reproducible in CI and diffable. +- `refs` per edge comes directly from `ResolvedRefMap` entries and tells AI consumers _which_ references create the dependency, not just that one exists. +- Cycles between files are legal and representable (edges form a general directed graph, not a tree). +- Failed resolutions become nodes with `resolved: false` so the graph honestly shows holes without failing the command. + +## Execution Flow + +Mirrors `stats`, minus bundling (the bundle output is not needed — only the resolution pass): + +``` +handleGraph({ argv, config }) + → getFallbackApisOrExit(argv.apis, config) + → one shared BaseResolver(config.resolve) for the whole invocation + → for each root: + resolver.resolveDocument(rootPath) // parse root document + detect spec + normalized types // same helpers stats uses + resolveDocument({ rootDocument, rootType, externalRefResolver }) + → buildGraph(resolvedRefMaps, roots) // pure function → DependencyGraph + → if --affected-by: filterAffected(graph, files) + → printGraph[format](graph) // stdout +``` + +- One shared `BaseResolver` means files shared between roots are read once (resolver caches by absolute path). +- `buildGraph` iterates `ResolvedRefMap` entries: source file comes from the entry key (`makeRefId(sourceAbsoluteRef, $ref)`), target file from the resolved document's `source.absoluteRef`, cross-file edges identified via `isRemote`. Exact field access is verified against `resolve.ts` during implementation. +- Telemetry parity with other commands: `collectSpecData` is called with each parsed root, and `commandWrapper` handles the rest. + +## `--affected-by` Semantics + +Reverse BFS over edges starting from the given files: collect every file that references them, transitively, up to the roots. The result is the induced subgraph (changed files + all transitive dependents + edges among them), rendered in whichever `--format` is active. + +- Input paths are resolved against cwd to absolute form and matched to node ids; output stays cwd-relative. +- Multiple files: the affected sets are unioned. +- `stylish` prunes the tree to affected branches, marks the queried files with a `← changed` suffix, and appends a summary line, e.g. `2 of 6 files affected · affected roots: openapi.yaml`. +- A queried file that is not part of the graph produces a **stderr** warning (`schemas/Unused.yaml is not referenced by any processed API`) and exit code `0` — for AI Review "nothing depends on this" is a legitimate answer, not an error. If no queried file is in the graph, the output is an empty graph in the chosen format (`stylish` prints `No files affected.`). +- stdout stays pure for `json` and `mermaid` (no banners or progress text) so output can be piped. + +## Output Formats + +### stylish (default) + +One tree per root, root filename as the header line: + +``` +openapi.yaml +├── paths/pets.yaml +│ └── components/schemas/Pet.yaml +└── paths/users.yaml + └── components/schemas/User.yaml + ├── components/schemas/Pet.yaml ↺ + └── components/schemas/missing.yaml ✗ not found +``` + +- `↺` — node already expanded earlier in this tree; children are not repeated. This single rule handles both cycles and fan-in (a schema referenced 50 times prints its subtree once), keeping output linear in the number of edges. +- `✗ not found` — unresolved reference (`resolved: false`). +- `(external)` suffix — http(s) URL nodes. + +### json + +The `DependencyGraph` model serialized as-is (2-space indent): + +```json +{ + "roots": ["openapi.yaml"], + "nodes": [ + { "id": "openapi.yaml", "root": true, "resolved": true }, + { "id": "paths/users.yaml", "resolved": true }, + { "id": "components/schemas/User.yaml", "resolved": true } + ], + "edges": [ + { "from": "openapi.yaml", "to": "paths/users.yaml", "refs": ["paths/users.yaml"] }, + { + "from": "paths/users.yaml", + "to": "components/schemas/User.yaml", + "refs": ["../components/schemas/User.yaml"] + } + ] +} +``` + +### mermaid + +`flowchart LR` with stable sequential node ids and roots highlighted: + +``` +flowchart LR + n0["openapi.yaml"]:::root + n1["paths/users.yaml"] + n2["components/schemas/User.yaml"] + n0 --> n1 + n1 --> n2 + classDef root font-weight:bold +``` + +Labels are escaped for Mermaid syntax (quotes, brackets). + +## Error Handling + +| Situation | Behavior | +| -------------------------------------- | ----------------------------------------------------------------------- | +| Root file missing | `getFallbackApisOrExit` reports and exits (existing behavior), exit `1` | +| Root file unparseable | Clear error via `commandWrapper`, exit `1` | +| Broken `$ref` inside the graph | Node with `resolved: false`, command succeeds with exit `0` | +| `--affected-by` file outside the graph | stderr warning, exit `0` | +| Config problems | Standard config error path, exit `2` | + +## File Layout + +``` +packages/cli/src/commands/graph/ +├── index.ts # handleGraph: resolve roots → build → filter → print +├── build-graph.ts # pure: ResolvedRefMap[] + roots → DependencyGraph +├── filter-affected.ts# pure: DependencyGraph + files → induced subgraph +└── print/ + ├── stylish.ts + ├── json.ts + └── mermaid.ts +``` + +Every function carries a concise purpose docstring (repo code-quality standard). No wrapper layers beyond this — handler calls pure functions directly. + +## Testing + +- **Unit** (`packages/cli/src/commands/graph/__tests__/`): + - `build-graph`: edges from a refMap fixture; cycle between two files; external URL node; unresolved ref node. + - `filter-affected`: chain where root `$ref`s B and B `$ref`s C; querying C yields `{root, B, C}`; untouched sibling branch excluded; queried file outside graph → empty result. + - Printers: inline snapshots of all three formats over one small fixture graph. +- **E2E**: one multi-file fixture (root + two path files + one shared schema), snapshots for default tree, `--format=json`, and `--affected-by`, following the existing e2e suite structure. +- Coverage stays above the repo's 71% threshold; no `console.log` added to production paths outside the printers (e2e is snapshot-based). + +## Documentation & Release + +- New page `docs/@v2/commands/graph.md` modeled on `stats.md`: description, usage, options table, examples — including the AI Review scenario (`--affected-by` + `--format=json`). +- Sidebar entry in `docs/@v2/v2.sidebars.yaml`. +- Changeset: `minor` for `@redocly/cli` (new feature; `@redocly/openapi-core` untouched). + +## Decisions Log + +| Decision | Choice | Rationale | +| ------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- | +| Audience | Both human + machine via `--format` | One data model, cheap formatters; follows `stats` precedent | +| Impact query in MVP | Yes, `--affected-by` | It is the stated motivation (AI Review); cheap reverse BFS over already-built edges; output filtering saves tokens | +| Formats | `stylish` + `json` + `mermaid` | Mermaid covers the "graphical" ask and renders natively on GitHub; DOT deferred (YAGNI) | +| Architecture | CLI-only, no core changes | Identical to `stats` pattern; smallest review surface; pure `buildGraph` can move to core later if language-server needs it | +| Multiple roots | Merged graph, lint-style `[apis...]` | Impact analysis must span all configured APIs to be trustworthy | +| Broken refs | Shown, not fatal | Graph reports structure; validation belongs to `lint` | From 0d922617b475603762c6fe23bca2c804350b8654 Mon Sep 17 00:00:00 2001 From: kanoru Date: Thu, 11 Jun 2026 17:13:45 +0300 Subject: [PATCH 02/79] docs: add graph command implementation plan --- .../plans/2026-06-11-graph-command.md | 1321 +++++++++++++++++ 1 file changed, 1321 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-11-graph-command.md diff --git a/docs/superpowers/plans/2026-06-11-graph-command.md b/docs/superpowers/plans/2026-06-11-graph-command.md new file mode 100644 index 0000000000..590ae7afec --- /dev/null +++ b/docs/superpowers/plans/2026-06-11-graph-command.md @@ -0,0 +1,1321 @@ +# `redocly graph` Command Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a `redocly graph` command that prints the file-level `$ref` dependency graph of API descriptions as an ASCII tree (`stylish`), `json`, or `mermaid`, with an `--affected-by` filter that shows only the subgraph impacted by changes to given files. + +**Architecture:** CLI-only (no core changes), mirroring the `stats` command: `BaseResolver` + `resolveDocument()` from `@redocly/openapi-core` produce a `ResolvedRefMap`; a pure `buildGraph()` converts ref maps into a `DependencyGraph` model; a pure `filterAffected()` computes the reverse closure; three pure renderers return strings printed via `logger.output()` (stdout stays clean — `logger.info/warn` go to stderr). + +**Tech Stack:** TypeScript ESM (`.js` import suffixes), yargs, vitest (unit: `packages/cli/src/**/*.test.ts`; e2e: `tests/e2e/**`), Changesets. + +**Spec:** `docs/superpowers/specs/2026-06-11-graph-command-design.md` + +**Key codebase facts (verified):** + +- `ResolvedRefMap = Map`; key = `makeRefId(sourceAbsoluteRef, ref.$ref)` = `` `${sourceAbsoluteRef}::${$ref}` `` (`packages/core/src/utils/make-ref-id.ts`). +- `ResolvedRef` is a union: `{ resolved: true; node; document: Document; nodePointer; isRemote }` or `{ resolved: false; isRemote; document?: Document; error?; ... }`. The type itself is NOT exported from core — only `ResolvedRefMap` is; iterate the map to get values typed structurally. +- `isRemote === true` ⇔ the `$ref` target lives in a DIFFERENT file than the source (`resolve.ts:389-395`). Not http-specific. These are exactly the file→file edges. +- Successful target file = `resolvedRef.document.source.absoluteRef`. Failed file load = `document: undefined` + `error`; recover the attempted path via `resolver.resolveExternalRef(sourceAbsoluteRef, uriPartOf$ref)` (public method, `resolve.ts:101`). +- Root loading: `await externalRefResolver.resolveDocument(null, apiPath, true)` returns `Document | ResolveError | YamlParseError` (both errors extend `Error`). +- Root type derivation (same as `lint.ts`/`stats`): `detectSpec(parsed)` → `normalizeTypes(config.extendTypes(getTypes(specVersion), specVersion), config)` → pass `types.Root` to `resolveDocument({ rootDocument, rootType, externalRefResolver })`. +- Public core exports used: `BaseResolver`, `resolveDocument`, `detectSpec`, `getTypes`, `normalizeTypes`, `Source`, `logger`, `isAbsoluteUrl`, types `Document`, `ResolvedRefMap`. +- `logger.output()` → stdout; `logger.info/warn/error` → stderr. JSON/mermaid purity relies on using ONLY `logger.output` for graph content. +- `CommandArgv` (`packages/cli/src/types.ts:30-45`) is a closed union — `GraphArgv` must be added. +- `getFallbackApisOrExit(argsApis: string[] | undefined, config)` → `Promise` (`{ path, alias?, output? }`); with no args falls back to all APIs from `redocly.yaml`. +- Unit tests: vitest with globals (no `describe/it/expect` imports), files at `packages/cli/src/commands//__tests__/*.test.ts`. `@redocly/openapi-core` resolves to compiled `lib/` → run `npm run compile` before unit tests. +- E2E: `tests/e2e/graph/graph.test.ts` + fixture dirs, runs `node packages/cli/lib/index.js` via helpers `getParams`/`getCommandOutput`/`cleanupOutput`, snapshots via `toMatchFileSnapshot(join(testPath, 'snapshot.txt'))`. Update with `npm run e2e -- -u`. +- Resolution is async/parallel ⇒ map insertion order is nondeterministic. `buildGraph` MUST sort nodes/edges/refs for stable snapshots. Roots keep CLI/config order; stylish tree children are sorted. + +## File Structure + +``` +packages/cli/src/commands/graph/ +├── index.ts # GraphArgv + handleGraph (orchestration only) +├── types.ts # DependencyGraph, GraphNode, GraphEdge, GraphFormat +├── build-graph.ts # pure: ref maps → DependencyGraph +├── filter-affected.ts # pure: graph + changed node ids → induced subgraph +├── print/ +│ ├── stylish.ts # renderStylish(): ASCII trees + markers + summary +│ ├── json.ts # renderJson() +│ └── mermaid.ts # renderMermaid() +└── __tests__/ + ├── build-graph.test.ts + ├── filter-affected.test.ts + └── print.test.ts + +packages/cli/src/types.ts # add GraphArgv to CommandArgv union +packages/cli/src/index.ts # yargs registration + +tests/e2e/graph/ +├── graph.test.ts +└── graph-multi-file/ # fixture + snapshot dirs (see Task 5) + +docs/@v2/commands/graph.md # command docs +docs/@v2/v2.sidebars.yaml # sidebar entry +docs/@v2/commands/index.md # commands list entry +.changeset/graph-command.md # minor release note +``` + +--- + +### Task 0: Baseline compile + +- [ ] **Step 0.1: Compile workspaces so `@redocly/openapi-core` resolves to fresh `lib/`** + +Run: `npm run compile` +Expected: exits 0. (Re-run after any `packages/core` changes; not needed between pure-CLI edits because vitest transpiles CLI `src/` on the fly, but e2e ALWAYS needs a fresh compile of `packages/cli`.) + +--- + +### Task 1: Graph model types + `buildGraph()` + +**Files:** + +- Create: `packages/cli/src/commands/graph/types.ts` +- Create: `packages/cli/src/commands/graph/build-graph.ts` +- Create: `packages/cli/src/commands/graph/__tests__/build-graph.test.ts` + +- [ ] **Step 1.1: Create the model types** + +`packages/cli/src/commands/graph/types.ts`: + +```typescript +export type GraphFormat = 'stylish' | 'json' | 'mermaid'; + +export type GraphNode = { + /** Path relative to cwd; http(s) refs keep the full URL. */ + id: string; + /** Entry-point API file. */ + root?: boolean; + /** Node is an http(s) URL, not a local file. */ + external?: boolean; + /** False: the file is referenced but could not be loaded. */ + resolved: boolean; +}; + +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[]; +}; +``` + +- [ ] **Step 1.2: Write the failing tests** + +`packages/cli/src/commands/graph/__tests__/build-graph.test.ts`: + +```typescript +import { Source, type Document, type ResolvedRefMap } from '@redocly/openapi-core'; +import * as path from 'node:path'; + +import { buildGraph } from '../build-graph.js'; + +const CWD = '/project'; + +/** Creates a minimal core Document for a given absolute path or URL. */ +function makeDocument(absoluteRef: string): Document { + return { source: new Source(absoluteRef, ''), parsed: {} }; +} + +/** Creates a successfully resolved cross-file ResolvedRefMap entry value. */ +function resolvedEntry(targetAbsoluteRef: string, isRemote = true) { + return { + resolved: true as const, + isRemote, + node: {}, + nodePointer: '#/', + document: makeDocument(targetAbsoluteRef), + }; +} + +/** Resolves a $ref uri against the source file directory, like BaseResolver.resolveExternalRef. */ +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 }], { + cwd: 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 }], { + cwd: 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 }, + ], + { cwd: 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 Error('ENOENT'), + }, + ], + ]); + + const graph = buildGraph([{ rootDocument: makeDocument('/project/openapi.yaml'), refMap }], { + cwd: 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 }], { + cwd: 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 }], { + cwd: CWD, + resolveRef, + }); + + expect(graph.edges).toEqual([ + { from: 'a.yaml', to: 'b.yaml', refs: ['b.yaml'] }, + { from: 'b.yaml', to: 'a.yaml', refs: ['a.yaml'] }, + ]); + }); +}); +``` + +- [ ] **Step 1.3: Run the tests to verify they fail** + +Run: `npm run unit -- packages/cli/src/commands/graph/__tests__/build-graph.test.ts` +Expected: FAIL — cannot find module `../build-graph.js`. + +- [ ] **Step 1.4: Implement `buildGraph`** + +`packages/cli/src/commands/graph/build-graph.ts`: + +```typescript +import { isAbsoluteUrl, type Document, type ResolvedRefMap } from '@redocly/openapi-core'; +import * as path from 'node:path'; + +import type { DependencyGraph, GraphEdge, GraphNode } from './types.js'; + +/** Converts an absolute file path or URL into a stable node id (cwd-relative path; URLs as-is). */ +function toNodeId(absoluteRef: string, cwd: string): string { + return isAbsoluteUrl(absoluteRef) ? absoluteRef : path.relative(cwd, absoluteRef); +} + +/** + * Builds the file-level dependency graph from the resolver's ref maps of one or more roots. + * Only cross-file refs (isRemote) become edges; nodes/edges/refs are sorted for stable output. + */ +export function buildGraph( + resolutions: Array<{ rootDocument: Document; refMap: ResolvedRefMap }>, + options: { cwd: string; resolveRef: (base: string, uri: string) => string } +): DependencyGraph { + const { cwd, 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, cwd), 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, cwd); + const to = toNodeId(targetAbsolute, cwd); + 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); + } + } + + // Codepoint comparison (not localeCompare): deterministic across Node ICU builds → stable snapshots. + const byString = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0); + + return { + roots: resolutions.map(({ rootDocument }) => toNodeId(rootDocument.source.absoluteRef, cwd)), + nodes: [...nodes.values()].sort((a, b) => byString(a.id, b.id)), + edges: [...edges.values()] + .map((edge) => ({ ...edge, refs: [...edge.refs].sort() })) + .sort((a, b) => byString(a.from, b.from) || byString(a.to, b.to)), + }; +} +``` + +Note on node shape: `root`/`external` are set only when true (optional props), so `toEqual` fixtures in Step 1.2 list them only where expected. + +- [ ] **Step 1.5: Run the tests to verify they pass** + +Run: `npm run unit -- packages/cli/src/commands/graph/__tests__/build-graph.test.ts` +Expected: 6 passed. + +- [ ] **Step 1.6: Commit** + +```bash +git add packages/cli/src/commands/graph +git commit -m "feat: add dependency graph builder for graph command" +``` + +--- + +### Task 2: `filterAffected()` + +**Files:** + +- Create: `packages/cli/src/commands/graph/filter-affected.ts` +- Create: `packages/cli/src/commands/graph/__tests__/filter-affected.test.ts` + +- [ ] **Step 2.1: Write the failing tests** + +`packages/cli/src/commands/graph/__tests__/filter-affected.test.ts`: + +```typescript +import { filterAffected } 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: [] }); + }); +}); +``` + +- [ ] **Step 2.2: Run the tests to verify they fail** + +Run: `npm run unit -- packages/cli/src/commands/graph/__tests__/filter-affected.test.ts` +Expected: FAIL — cannot find module `../filter-affected.js`. + +- [ ] **Step 2.3: Implement `filterAffected`** + +`packages/cli/src/commands/graph/filter-affected.ts`: + +```typescript +import type { DependencyGraph } from './types.js'; + +/** + * Returns the induced subgraph affected by changes to the given files: + * the changed nodes plus every transitive dependent (reverse closure up to the roots). + * `changedIds` must already be node ids of the graph (cwd-relative paths). + */ +export function filterAffected(graph: DependencyGraph, changedIds: string[]): DependencyGraph { + const dependentsByTarget = new Map(); + for (const edge of graph.edges) { + const dependents = dependentsByTarget.get(edge.to) ?? []; + dependents.push(edge.from); + dependentsByTarget.set(edge.to, dependents); + } + + const affected = new Set(changedIds); + const queue = [...affected]; + while (queue.length > 0) { + const current = queue.shift()!; + for (const dependent of dependentsByTarget.get(current) ?? []) { + if (!affected.has(dependent)) { + affected.add(dependent); + queue.push(dependent); + } + } + } + + 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)), + }; +} +``` + +- [ ] **Step 2.4: Run the tests to verify they pass** + +Run: `npm run unit -- packages/cli/src/commands/graph/__tests__/filter-affected.test.ts` +Expected: 3 passed. + +- [ ] **Step 2.5: Commit** + +```bash +git add packages/cli/src/commands/graph/filter-affected.ts packages/cli/src/commands/graph/__tests__/filter-affected.test.ts +git commit -m "feat: add affected-files filter for graph command" +``` + +--- + +### Task 3: Renderers (`stylish`, `json`, `mermaid`) + +**Files:** + +- Create: `packages/cli/src/commands/graph/print/stylish.ts` +- Create: `packages/cli/src/commands/graph/print/json.ts` +- Create: `packages/cli/src/commands/graph/print/mermaid.ts` +- Create: `packages/cli/src/commands/graph/__tests__/print.test.ts` + +Renderers are pure (`graph → string`); the handler prints via `logger.output()`. No `console.log` anywhere (e2e is snapshot-based). + +- [ ] **Step 3.1: Write the failing tests** + +`packages/cli/src/commands/graph/__tests__/print.test.ts`: + +```typescript +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 repeat, broken-ref, and external markers', () => { + expect(renderStylish(graph)).toMatchInlineSnapshot(); + }); + + it('marks changed files and 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, { changed: ['components/Pet.yaml'], totalNodeCount: 7 }) + ).toMatchInlineSnapshot(); + }); + + it('reports when nothing is affected', () => { + expect( + renderStylish({ roots: [], nodes: [], edges: [] }, { changed: [], totalNodeCount: 7 }) + ).toMatchInlineSnapshot(`"No files affected."`); + }); +}); + +describe('renderJson', () => { + it('serializes the graph model as-is', () => { + const parsed = JSON.parse(renderJson(graph)); + expect(parsed.roots).toEqual(['openapi.yaml']); + expect(parsed.nodes).toHaveLength(7); + expect(parsed.edges).toHaveLength(7); + }); +}); + +describe('renderMermaid', () => { + it('renders a flowchart with stable ids and a root class', () => { + expect(renderMermaid(graph)).toMatchInlineSnapshot(); + }); +}); +``` + +(Empty `toMatchInlineSnapshot()` calls are filled automatically on the first passing run — see Step 3.4.) + +- [ ] **Step 3.2: Run the tests to verify they fail** + +Run: `npm run unit -- packages/cli/src/commands/graph/__tests__/print.test.ts` +Expected: FAIL — cannot find module `../print/json.js`. + +- [ ] **Step 3.3: Implement the three renderers** + +`packages/cli/src/commands/graph/print/json.ts`: + +```typescript +import type { DependencyGraph } from '../types.js'; + +/** Serializes the dependency graph as pretty-printed JSON. */ +export function renderJson(graph: DependencyGraph): string { + return JSON.stringify(graph, null, 2); +} +``` + +`packages/cli/src/commands/graph/print/mermaid.ts`: + +```typescript +import type { DependencyGraph } from '../types.js'; + +/** Renders the dependency graph as a Mermaid flowchart definition. */ +export function renderMermaid(graph: DependencyGraph): string { + const mermaidIds = new Map(graph.nodes.map((node, index) => [node.id, `n${index}`])); + const escapeLabel = (label: string) => label.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'); +} +``` + +`packages/cli/src/commands/graph/print/stylish.ts`: + +```typescript +import type { DependencyGraph } from '../types.js'; + +export type StylishOptions = { + /** Node ids queried via --affected-by that exist in the graph. */ + changed?: string[]; + /** Node count of the unfiltered graph; enables the affected summary line. */ + totalNodeCount?: number; +}; + +/** + * Renders one ASCII tree per root. A node already expanded in the current tree + * is printed with `↺` and not expanded again (handles cycles and fan-in). + */ +export function renderStylish(graph: DependencyGraph, options: StylishOptions = {}): string { + if (graph.nodes.length === 0) { + return '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(); + } + + const nodesById = new Map(graph.nodes.map((node) => [node.id, node])); + const changed = new Set(options.changed ?? []); + const lines: string[] = []; + + const label = (id: string, isRepeat: boolean): string => { + const node = nodesById.get(id); + let text = id; + if (node?.external) text += ' (external)'; + if (node && !node.resolved) text += ' ✗ not found'; + if (isRepeat) text += ' ↺'; + if (changed.has(id)) text += ' ← changed'; + return text; + }; + + const renderSubtree = (id: string, prefix: string, printed: Set) => { + const children = childrenByNode.get(id) ?? []; + children.forEach((child, index) => { + const isLast = index === children.length - 1; + const isRepeat = printed.has(child); + lines.push(`${prefix}${isLast ? '└── ' : '├── '}${label(child, isRepeat)}`); + if (!isRepeat) { + printed.add(child); + renderSubtree(child, `${prefix}${isLast ? ' ' : '│ '}`, printed); + } + }); + }; + + graph.roots.forEach((root, index) => { + if (index > 0) lines.push(''); + lines.push(label(root, false)); + renderSubtree(root, '', new Set([root])); + }); + + if (options.totalNodeCount !== undefined) { + lines.push(''); + lines.push( + `${graph.nodes.length} of ${options.totalNodeCount} files affected · affected roots: ${ + graph.roots.join(', ') || 'none' + }` + ); + } + + return lines.join('\n'); +} +``` + +- [ ] **Step 3.4: Run the tests, let vitest fill the inline snapshots, then review them** + +Run: `npm run unit -- packages/cli/src/commands/graph/__tests__/print.test.ts -u` +Expected: 5 passed; empty `toMatchInlineSnapshot()` calls now contain the rendered output. + +Manually verify the filled snapshots look exactly like this (tree shape, markers, summary): + +``` +openapi.yaml +├── paths/pets.yaml +│ └── components/Pet.yaml +└── paths/users.yaml + └── components/User.yaml + ├── components/Pet.yaml ↺ + ├── components/missing.yaml ✗ not found + └── https://example.com/shared.yaml (external) +``` + +and for affected mode (`components/Pet.yaml` queried): + +``` +openapi.yaml +├── paths/pets.yaml +│ └── components/Pet.yaml ← changed +└── paths/users.yaml + └── components/User.yaml + └── components/Pet.yaml ↺ ← changed + +5 of 7 files affected · affected roots: openapi.yaml +``` + +and mermaid (node order follows graph.nodes order): + +``` +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 +``` + +If the output differs from the spec's intent (wrong markers, missing summary), fix the renderer, not the snapshot. + +- [ ] **Step 3.5: Run all graph unit tests together** + +Run: `npm run unit -- packages/cli/src/commands/graph` +Expected: build-graph (6) + filter-affected (3) + print (5) all pass. + +- [ ] **Step 3.6: Commit** + +```bash +git add packages/cli/src/commands/graph +git commit -m "feat: add graph command output renderers" +``` + +--- + +### Task 4: Handler + CLI registration + +**Files:** + +- Create: `packages/cli/src/commands/graph/index.ts` +- Modify: `packages/cli/src/types.ts` (CommandArgv union, imports at top) +- Modify: `packages/cli/src/index.ts` (import + `.command()` block) + +- [ ] **Step 4.1: Implement the handler** + +`packages/cli/src/commands/graph/index.ts`: + +```typescript +import { + BaseResolver, + detectSpec, + getTypes, + logger, + normalizeTypes, + resolveDocument, + type Document, + type ResolvedRefMap, +} from '@redocly/openapi-core'; +import * as path from 'node:path'; + +import type { 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 } from './filter-affected.js'; +import { renderJson } from './print/json.js'; +import { renderMermaid } from './print/mermaid.js'; +import { renderStylish, type StylishOptions } from './print/stylish.js'; +import type { GraphFormat } from './types.js'; + +export type GraphArgv = { + apis?: string[]; + format: GraphFormat; + 'affected-by'?: string[]; +} & VerifyConfigOptions; + +/** Resolves the given API descriptions and prints their file-level $ref dependency graph. */ +export async function handleGraph({ argv, config, collectSpecData }: CommandArgs) { + const apis = await getFallbackApisOrExit(argv.apis, config); + const externalRefResolver = new BaseResolver(config.resolve); + const cwd = process.cwd(); + + const resolutions: Array<{ rootDocument: Document; refMap: ResolvedRefMap }> = []; + for (const { path: apiPath } of apis) { + const rootDocument = await externalRefResolver.resolveDocument(null, apiPath, true); + if (rootDocument instanceof Error) { + return exitWithError(`Failed to load ${apiPath}: ${rootDocument.message}`); + } + collectSpecData?.(rootDocument.parsed); + const specVersion = detectSpec(rootDocument.parsed); + const types = normalizeTypes(config.extendTypes(getTypes(specVersion), specVersion), config); + const refMap = await resolveDocument({ + rootDocument: rootDocument as Document, + rootType: types.Root, + externalRefResolver, + }); + resolutions.push({ rootDocument: rootDocument as Document, refMap }); + } + + const graph = buildGraph(resolutions, { + cwd, + resolveRef: (base, uri) => externalRefResolver.resolveExternalRef(base, uri), + }); + + let printedGraph = graph; + let stylishOptions: StylishOptions = {}; + if (argv['affected-by']) { + const changedIds = argv['affected-by'].map((file) => + path.relative(cwd, path.resolve(cwd, file)) + ); + const knownIds = new Set(graph.nodes.map((node) => node.id)); + 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 = { changed: knownChanged, totalNodeCount: graph.nodes.length }; + } + + switch (argv.format) { + case 'json': + logger.output(renderJson(printedGraph) + '\n'); + break; + case 'mermaid': + logger.output(renderMermaid(printedGraph) + '\n'); + break; + default: + logger.output(renderStylish(printedGraph, stylishOptions) + '\n'); + } +} +``` + +- [ ] **Step 4.2: Add `GraphArgv` to the `CommandArgv` union** + +In `packages/cli/src/types.ts`, add the import after the `GenerateArazzoCommandArgv` import (line 7): + +```typescript +import type { GraphArgv } from './commands/graph/index.js'; +``` + +and extend the union (after `| StatsArgv`): + +```typescript +export type CommandArgv = StatsArgv | GraphArgv | SplitArgv; +``` + +(rest of the union unchanged). + +- [ ] **Step 4.3: Register the command in yargs** + +In `packages/cli/src/index.ts`: + +Add the import after `import { handleGenerateArazzo, ... } from './commands/generate-arazzo.js';`: + +```typescript +import { handleGraph } from './commands/graph/index.js'; +import type { GraphFormat } from './commands/graph/types.js'; +``` + +Insert this `.command()` block immediately after the existing `stats` command block (after its closing `)` around line 76): + +```typescript + .command( + 'graph [apis...]', + 'Show the $ref dependency graph of API description files.', + (yargs) => + yargs + .env('REDOCLY_CLI_GRAPH') + .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'] as ReadonlyArray, + default: 'stylish' as GraphFormat, + }, + 'affected-by': { + description: + 'Show only the part of the graph affected by changes to the given files.', + array: true, + type: 'string', + requiresArg: true, + }, + }), + (argv) => { + commandWrapper(handleGraph)(argv); + } + ) +``` + +- [ ] **Step 4.4: Typecheck and compile** + +Run: `npm run typecheck && npm run compile` +Expected: both exit 0. If `rootDocument instanceof Error` narrowing complains (`ResolveError`/`YamlParseError` are `Error` subclasses), keep the `as Document` casts as written above — they mirror `lint.ts`. + +- [ ] **Step 4.5: Smoke-run the wired command** + +(Single-file spec: the graph is just the root node — this only verifies registration, resolution, and clean output. Multi-file behavior is covered by Task 1 unit tests and Task 5 e2e.) + +Run: `npm run cli -- graph tests/e2e/join/multiple-tags-in-same-files/foo.yaml 2>/dev/null` +Expected: stdout is exactly one tree line `tests/e2e/join/multiple-tags-in-same-files/foo.yaml` (no stack trace). + +Run: `npm run cli -- graph tests/e2e/join/multiple-tags-in-same-files/foo.yaml --format=json 2>/dev/null` +Expected: valid JSON with `roots`, `nodes`, `edges` keys and nothing else on stdout. + +- [ ] **Step 4.6: Run the full unit suite** + +Run: `npm run unit` +Expected: all suites pass (graph tests included, nothing else broken). + +- [ ] **Step 4.7: Commit** + +```bash +git add packages/cli/src/commands/graph packages/cli/src/types.ts packages/cli/src/index.ts +git commit -m "feat: register graph command in CLI" +``` + +--- + +### Task 5: E2E tests with a multi-file fixture + +**Files:** + +- Create: `tests/e2e/graph/graph.test.ts` +- Create: `tests/e2e/graph/graph-multi-file/openapi.yaml` +- Create: `tests/e2e/graph/graph-multi-file/paths/pets.yaml` +- Create: `tests/e2e/graph/graph-multi-file/paths/users.yaml` +- Create: `tests/e2e/graph/graph-multi-file/components/schemas/Pet.yaml` +- Create: `tests/e2e/graph/graph-multi-file/components/schemas/User.yaml` +- Create: `tests/e2e/graph/graph-multi-file/components/schemas/Address.yaml` +- Generated: `snapshot.txt` in `graph-stylish/`, `graph-json/`, `graph-affected-by/` (see Step 5.3 — the three test dirs share one fixture via relative path) + +Fixture exercises: nesting (root → paths → schemas), fan-in (`Pet.yaml` referenced from `pets.yaml` and `User.yaml` → `↺` marker), affected-branch pruning (`Address.yaml` only affects the users branch). + +- [ ] **Step 5.1: Create the fixture files** + +`tests/e2e/graph/graph-multi-file/openapi.yaml`: + +```yaml +openapi: 3.0.0 +info: + title: Graph fixture + version: 1.0.0 +paths: + /pets: + $ref: paths/pets.yaml + /users: + $ref: paths/users.yaml +``` + +`tests/e2e/graph/graph-multi-file/paths/pets.yaml`: + +```yaml +get: + summary: List pets + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: ../components/schemas/Pet.yaml +``` + +`tests/e2e/graph/graph-multi-file/paths/users.yaml`: + +```yaml +get: + summary: List users + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: ../components/schemas/User.yaml +``` + +`tests/e2e/graph/graph-multi-file/components/schemas/Pet.yaml`: + +```yaml +type: object +properties: + name: + type: string +``` + +`tests/e2e/graph/graph-multi-file/components/schemas/User.yaml`: + +```yaml +type: object +properties: + address: + $ref: Address.yaml + pet: + $ref: Pet.yaml +``` + +`tests/e2e/graph/graph-multi-file/components/schemas/Address.yaml`: + +```yaml +type: object +properties: + city: + type: string +``` + +- [ ] **Step 5.2: Write the e2e test** + +`tests/e2e/graph/graph.test.ts` (imports mirror `tests/e2e/stats/stats.test.ts` exactly — ESM, so `__dirname` is derived via `fileURLToPath`; `describe/test/expect` are vitest globals, no import): + +```typescript +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('graph', () => { + const folderPath = __dirname; + const fixturePath = join(folderPath, 'graph-multi-file'); + + test('graph should print a stylish tree', async () => { + const args = getParams(indexEntryPoint, ['graph', 'openapi.yaml']); + const result = getCommandOutput(args, { testPath: fixturePath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot( + join(folderPath, 'graph-stylish', 'snapshot.txt') + ); + }); + + test('graph should print pure JSON', async () => { + const args = getParams(indexEntryPoint, ['graph', 'openapi.yaml', '--format=json']); + const result = getCommandOutput(args, { testPath: fixturePath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot( + join(folderPath, 'graph-json', 'snapshot.txt') + ); + }); + + test('graph should print only the affected subgraph', async () => { + const args = getParams(indexEntryPoint, [ + 'graph', + 'openapi.yaml', + '--affected-by', + 'components/schemas/Address.yaml', + ]); + const result = getCommandOutput(args, { testPath: fixturePath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot( + join(folderPath, 'graph-affected-by', 'snapshot.txt') + ); + }); +}); +``` + +- [ ] **Step 5.3: Compile and generate snapshots** + +Run: `npm run compile && npm run e2e -- tests/e2e/graph/graph.test.ts -u` +Expected: 3 passed, three `snapshot.txt` files created. + +- [ ] **Step 5.4: Review the generated snapshots against the spec** + +`graph-stylish/snapshot.txt` must contain exactly this tree (children sorted; `Pet.yaml` expanded once, repeated with `↺`): + +``` +openapi.yaml +├── paths/pets.yaml +│ └── components/schemas/Pet.yaml +└── paths/users.yaml + └── components/schemas/User.yaml + ├── components/schemas/Address.yaml + └── components/schemas/Pet.yaml ↺ +``` + +`graph-json/snapshot.txt` must be valid JSON only (6 nodes, 6 edges, `"roots": ["openapi.yaml"]`, edge objects carry `refs` arrays). + +`graph-affected-by/snapshot.txt` must show only the users branch plus the summary: + +``` +openapi.yaml +└── paths/users.yaml + └── components/schemas/User.yaml + └── components/schemas/Address.yaml ← changed + +4 of 6 files affected · affected roots: openapi.yaml +``` + +If a snapshot deviates (e.g. unsorted children, missing marker), fix the source, re-run with `-u`, and re-review. + +- [ ] **Step 5.5: Run the whole e2e suite** + +Run: `npm run e2e` +Expected: all pass (no other suites affected). + +- [ ] **Step 5.6: Commit** + +```bash +git add tests/e2e/graph +git commit -m "test: add graph command e2e tests" +``` + +--- + +### Task 6: Docs, sidebar, commands index, changeset + +**Files:** + +- Create: `docs/@v2/commands/graph.md` +- Modify: `docs/@v2/v2.sidebars.yaml` (Commands group, alphabetical: between `generate-arazzo` and `join`) +- Modify: `docs/@v2/commands/index.md` (API management commands list, between `bundle` and `join`) +- Create: `.changeset/graph-command.md` + +- [ ] **Step 6.1: Write the command docs page** + +`docs/@v2/commands/graph.md` (structure mirrors `stats.md`: title, Introduction, Usage, Options table, Examples): + +````markdown +# `graph` + +## Introduction + +The `graph` command prints the file-level dependency graph of an API description: which files reference which other files through `$ref`. It works with multi-file OpenAPI, AsyncAPI, and Arazzo descriptions. + +Use it to: + +- get a quick `tree`-style overview of a multi-file API description; +- find out which files are affected by a change to a shared file (`--affected-by`) — for example, in CI or automated code review; +- feed exact file relationships to tooling as JSON or render them as a Mermaid diagram. + +## Usage + +```bash +redocly graph +redocly graph +redocly graph [--format=] [--affected-by=] [--config=] +``` +```` + +If you don't pass any API to the command, it processes all APIs defined in your Redocly configuration file and prints one merged graph. + +## Options + +| Option | Type | Description | +| ------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| apis | [string] | Paths to API description files. Defaults to all APIs from the Redocly configuration file. | +| --affected-by | [string] | Show only the part of the graph affected by changes to the given files: the files themselves plus everything that references them. | +| --config | string | Specify the path to the [Redocly configuration file](../configuration/index.md). | +| --format | string | Output format: `stylish` (default, tree view), `json`, or `mermaid`. | +| --help | boolean | Show help. | +| --lint-config | string | Specify the severity level for the configuration file.
**Possible values:** `warn`, `error`, `off`. Default value is `warn`. | +| --version | boolean | Show version number. | + +## Examples + +### Print the dependency tree + +```bash +redocly graph openapi.yaml +``` + +``` +openapi.yaml +├── paths/pets.yaml +│ └── components/schemas/Pet.yaml +└── paths/users.yaml + └── components/schemas/User.yaml + ├── components/schemas/Address.yaml + └── components/schemas/Pet.yaml ↺ +``` + +The `↺` marker means the file was already expanded earlier in the tree, so its references are not repeated. Files that cannot be resolved are marked with `✗ not found`, and references to URLs are marked with `(external)`. + +### Find files affected by a change + +Pass one or more changed files to `--affected-by` to see only the impacted part of the graph — useful in CI and automated review to decide what needs attention without reading every file: + +```bash +redocly graph openapi.yaml --affected-by components/schemas/Address.yaml +``` + +``` +openapi.yaml +└── paths/users.yaml + └── components/schemas/User.yaml + └── components/schemas/Address.yaml ← changed + +4 of 6 files affected · affected roots: openapi.yaml +``` + +If a file in `--affected-by` is not referenced by any processed API, the command prints a warning to stderr and exits with code `0` — "nothing depends on this file" is a valid answer. + +### Machine-readable output + +```bash +redocly graph openapi.yaml --format=json +``` + +Prints the graph as JSON with `roots`, `nodes` (including `resolved` and `external` flags), and `edges` (including the exact `$ref` strings). Only the JSON is written to stdout, so the output is safe to pipe. + +```bash +redocly graph openapi.yaml --format=mermaid +``` + +Prints a [Mermaid](https://mermaid.js.org/) `flowchart` definition. GitHub renders Mermaid code blocks in Markdown automatically, so you can paste the output into a pull request comment or documentation page to get a diagram. + +```` + +- [ ] **Step 6.2: Add the sidebar entry** + +In `docs/@v2/v2.sidebars.yaml`, inside the `Commands` group items, insert between `generate-arazzo` and `join`: + +```yaml + - label: graph + page: commands/graph.md +```` + +- [ ] **Step 6.3: Add the commands index entry** + +In `docs/@v2/commands/index.md`, in the `API management commands:` list, insert between the `bundle` and `join` lines: + +```markdown +- [`graph`](graph.md) Show the `$ref` dependency graph of API description files. +``` + +- [ ] **Step 6.4: Create the changeset** + +`.changeset/graph-command.md`: + +```markdown +--- +'@redocly/cli': minor +--- + +Added the `graph` command that prints the file-level `$ref` dependency graph of API descriptions as a tree (`stylish`), `json`, or `mermaid` output. The `--affected-by` option filters the graph to the files impacted by changes to the given files. +``` + +- [ ] **Step 6.5: Full verification** + +Run: `npm test` +Expected: compile, typecheck, unit, and e2e all pass. + +- [ ] **Step 6.6: Commit** + +```bash +git add docs/@v2/commands/graph.md docs/@v2/v2.sidebars.yaml docs/@v2/commands/index.md .changeset/graph-command.md +git commit -m "docs: document graph command and add changeset" +``` + +--- + +## Rollback + +Every task is an isolated commit on `feat/graph-command`; revert any of them with `git revert `. The feature adds one new command and touches shared files only additively (`types.ts` union member, `index.ts` command block, docs lists), so reverting the branch removes the feature completely. + +## Out of Scope (per spec) + +- Core package changes, DOT/Graphviz output, component-level nodes, validation behavior (broken refs stay non-fatal). From 64d963231a0d11b4d16ee86c755c5f5ee76c1a3a Mon Sep 17 00:00:00 2001 From: kanoru Date: Thu, 11 Jun 2026 17:23:11 +0300 Subject: [PATCH 03/79] feat: add dependency graph builder for graph command --- .../graph/__tests__/build-graph.test.ts | 166 ++++++++++++++++++ .../cli/src/commands/graph/build-graph.ts | 69 ++++++++ packages/cli/src/commands/graph/types.ts | 25 +++ 3 files changed, 260 insertions(+) create mode 100644 packages/cli/src/commands/graph/__tests__/build-graph.test.ts create mode 100644 packages/cli/src/commands/graph/build-graph.ts create mode 100644 packages/cli/src/commands/graph/types.ts diff --git a/packages/cli/src/commands/graph/__tests__/build-graph.test.ts b/packages/cli/src/commands/graph/__tests__/build-graph.test.ts new file mode 100644 index 0000000000..39caac5337 --- /dev/null +++ b/packages/cli/src/commands/graph/__tests__/build-graph.test.ts @@ -0,0 +1,166 @@ +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'; + +/** Creates a minimal core Document for a given absolute path or URL. */ +function makeDocument(absoluteRef: string): Document { + return { source: new Source(absoluteRef, ''), parsed: {} }; +} + +/** Creates a successfully resolved cross-file ResolvedRefMap entry value. */ +function resolvedEntry(targetAbsoluteRef: string, isRemote = true) { + return { + resolved: true as const, + isRemote, + node: {}, + nodePointer: '#/', + document: makeDocument(targetAbsoluteRef), + }; +} + +/** Resolves a $ref uri against the source file directory, like BaseResolver.resolveExternalRef. */ +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 }], { + cwd: 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 }], { + cwd: 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 }, + ], + { cwd: 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 }], { + cwd: 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 }], { + cwd: 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 }], { + cwd: 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/graph/build-graph.ts b/packages/cli/src/commands/graph/build-graph.ts new file mode 100644 index 0000000000..8469117895 --- /dev/null +++ b/packages/cli/src/commands/graph/build-graph.ts @@ -0,0 +1,69 @@ +import { isAbsoluteUrl, slash, type Document, type ResolvedRefMap } from '@redocly/openapi-core'; +import * as path from 'node:path'; + +import type { DependencyGraph, GraphEdge, GraphNode } from './types.js'; + +/** Converts an absolute file path or URL into a stable node id (cwd-relative posix path; URLs as-is). */ +function toNodeId(absoluteRef: string, cwd: string): string { + return isAbsoluteUrl(absoluteRef) ? absoluteRef : slash(path.relative(cwd, absoluteRef)); +} + +/** + * Builds the file-level dependency graph from the resolver's ref maps of one or more roots. + * Only cross-file refs (isRemote) become edges; nodes/edges/refs are sorted for stable output. + */ +export function buildGraph( + resolutions: Array<{ rootDocument: Document; refMap: ResolvedRefMap }>, + options: { cwd: string; resolveRef: (base: string, uri: string) => string } +): DependencyGraph { + const { cwd, resolveRef } = options; + const nodes = new Map(); + const edges = new Map(); + + /** Merges-or-creates a node, OR-ing its resolved/root/external flags. */ + 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, cwd), 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, cwd); + const to = toNodeId(targetAbsolute, cwd); + 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); + } + } + + // Codepoint comparison (not localeCompare): deterministic across Node ICU builds → stable snapshots. + const byString = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0); + + return { + roots: resolutions.map(({ rootDocument }) => toNodeId(rootDocument.source.absoluteRef, cwd)), + nodes: [...nodes.values()].sort((a, b) => byString(a.id, b.id)), + edges: [...edges.values()] + .map((edge) => ({ ...edge, refs: [...edge.refs].sort() })) + .sort((a, b) => byString(a.from, b.from) || byString(a.to, b.to)), + }; +} diff --git a/packages/cli/src/commands/graph/types.ts b/packages/cli/src/commands/graph/types.ts new file mode 100644 index 0000000000..036b6c2e2b --- /dev/null +++ b/packages/cli/src/commands/graph/types.ts @@ -0,0 +1,25 @@ +export type GraphFormat = 'stylish' | 'json' | 'mermaid'; + +export type GraphNode = { + /** Path relative to cwd; http(s) refs keep the full URL. */ + id: string; + /** Entry-point API file. */ + root?: boolean; + /** Node is an http(s) URL, not a local file. */ + external?: boolean; + /** False: the file is referenced but could not be loaded. */ + resolved: boolean; +}; + +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[]; +}; From 5e061ab93f09c74143117bcfd7fd309d6397d592 Mon Sep 17 00:00:00 2001 From: kanoru Date: Thu, 11 Jun 2026 17:35:20 +0300 Subject: [PATCH 04/79] docs: sync graph plan with review fixes (slash, ResolveError, typecheck steps) --- .../plans/2026-06-11-graph-command.md | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/plans/2026-06-11-graph-command.md b/docs/superpowers/plans/2026-06-11-graph-command.md index 590ae7afec..3d288afee1 100644 --- a/docs/superpowers/plans/2026-06-11-graph-command.md +++ b/docs/superpowers/plans/2026-06-11-graph-command.md @@ -18,7 +18,7 @@ - Successful target file = `resolvedRef.document.source.absoluteRef`. Failed file load = `document: undefined` + `error`; recover the attempted path via `resolver.resolveExternalRef(sourceAbsoluteRef, uriPartOf$ref)` (public method, `resolve.ts:101`). - Root loading: `await externalRefResolver.resolveDocument(null, apiPath, true)` returns `Document | ResolveError | YamlParseError` (both errors extend `Error`). - Root type derivation (same as `lint.ts`/`stats`): `detectSpec(parsed)` → `normalizeTypes(config.extendTypes(getTypes(specVersion), specVersion), config)` → pass `types.Root` to `resolveDocument({ rootDocument, rootType, externalRefResolver })`. -- Public core exports used: `BaseResolver`, `resolveDocument`, `detectSpec`, `getTypes`, `normalizeTypes`, `Source`, `logger`, `isAbsoluteUrl`, types `Document`, `ResolvedRefMap`. +- Public core exports used: `BaseResolver`, `resolveDocument`, `detectSpec`, `getTypes`, `normalizeTypes`, `Source`, `ResolveError`, `logger`, `isAbsoluteUrl`, `slash`, types `Document`, `ResolvedRefMap`. - `logger.output()` → stdout; `logger.info/warn/error` → stderr. JSON/mermaid purity relies on using ONLY `logger.output` for graph content. - `CommandArgv` (`packages/cli/src/types.ts:30-45`) is a closed union — `GraphArgv` must be added. - `getFallbackApisOrExit(argsApis: string[] | undefined, config)` → `Promise` (`{ path, alias?, output? }`); with no args falls back to all APIs from `redocly.yaml`. @@ -112,7 +112,7 @@ export type DependencyGraph = { `packages/cli/src/commands/graph/__tests__/build-graph.test.ts`: ```typescript -import { Source, type Document, type ResolvedRefMap } from '@redocly/openapi-core'; +import { ResolveError, Source, type Document, type ResolvedRefMap } from '@redocly/openapi-core'; import * as path from 'node:path'; import { buildGraph } from '../build-graph.js'; @@ -223,7 +223,7 @@ describe('buildGraph', () => { resolved: false as const, isRemote: true, document: undefined, - error: new Error('ENOENT'), + error: new ResolveError(new Error('ENOENT')), }, ], ]); @@ -290,14 +290,14 @@ Expected: FAIL — cannot find module `../build-graph.js`. `packages/cli/src/commands/graph/build-graph.ts`: ```typescript -import { isAbsoluteUrl, type Document, type ResolvedRefMap } from '@redocly/openapi-core'; +import { isAbsoluteUrl, slash, type Document, type ResolvedRefMap } from '@redocly/openapi-core'; import * as path from 'node:path'; import type { DependencyGraph, GraphEdge, GraphNode } from './types.js'; -/** Converts an absolute file path or URL into a stable node id (cwd-relative path; URLs as-is). */ +/** Converts an absolute file path or URL into a stable node id (cwd-relative posix path; URLs as-is). */ function toNodeId(absoluteRef: string, cwd: string): string { - return isAbsoluteUrl(absoluteRef) ? absoluteRef : path.relative(cwd, absoluteRef); + return isAbsoluteUrl(absoluteRef) ? absoluteRef : slash(path.relative(cwd, absoluteRef)); } /** @@ -312,6 +312,7 @@ export function buildGraph( const nodes = new Map(); const edges = new Map(); + /** Merges-or-creates a node, OR-ing its resolved/root/external flags. */ const upsertNode = (id: string, resolved: boolean, root?: boolean) => { const node = nodes.get(id) ?? { id, resolved: false }; if (resolved) node.resolved = true; @@ -367,6 +368,9 @@ Note on node shape: `root`/`external` are set only when true (optional props), s Run: `npm run unit -- packages/cli/src/commands/graph/__tests__/build-graph.test.ts` Expected: 6 passed. +Also run: `npm run typecheck` +Expected: exit 0 (vitest does not typecheck — catch type errors now, not in Task 4). + - [ ] **Step 1.6: Commit** ```bash @@ -488,6 +492,9 @@ export function filterAffected(graph: DependencyGraph, changedIds: string[]): De Run: `npm run unit -- packages/cli/src/commands/graph/__tests__/filter-affected.test.ts` Expected: 3 passed. +Also run: `npm run typecheck` +Expected: exit 0. + - [ ] **Step 2.5: Commit** ```bash @@ -780,6 +787,9 @@ If the output differs from the spec's intent (wrong markers, missing summary), f Run: `npm run unit -- packages/cli/src/commands/graph` Expected: build-graph (6) + filter-affected (3) + print (5) all pass. +Also run: `npm run typecheck` +Expected: exit 0. + - [ ] **Step 3.6: Commit** ```bash From a9a894ec750b02ae03d9b46d368a42027e1b5af4 Mon Sep 17 00:00:00 2001 From: kanoru Date: Thu, 11 Jun 2026 17:39:35 +0300 Subject: [PATCH 05/79] feat: add affected-files filter for graph command --- .../graph/__tests__/filter-affected.test.ts | 67 +++++++++++++++++++ .../cli/src/commands/graph/filter-affected.ts | 33 +++++++++ 2 files changed, 100 insertions(+) create mode 100644 packages/cli/src/commands/graph/__tests__/filter-affected.test.ts create mode 100644 packages/cli/src/commands/graph/filter-affected.ts diff --git a/packages/cli/src/commands/graph/__tests__/filter-affected.test.ts b/packages/cli/src/commands/graph/__tests__/filter-affected.test.ts new file mode 100644 index 0000000000..309744b568 --- /dev/null +++ b/packages/cli/src/commands/graph/__tests__/filter-affected.test.ts @@ -0,0 +1,67 @@ +import { filterAffected } 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: [] }); + }); +}); diff --git a/packages/cli/src/commands/graph/filter-affected.ts b/packages/cli/src/commands/graph/filter-affected.ts new file mode 100644 index 0000000000..a3014da5e6 --- /dev/null +++ b/packages/cli/src/commands/graph/filter-affected.ts @@ -0,0 +1,33 @@ +import type { DependencyGraph } from './types.js'; + +/** + * Returns the induced subgraph affected by changes to the given files: + * the changed nodes plus every transitive dependent (reverse closure up to the roots). + * `changedIds` must already be node ids of the graph (cwd-relative paths). + */ +export function filterAffected(graph: DependencyGraph, changedIds: string[]): DependencyGraph { + const dependentsByTarget = new Map(); + for (const edge of graph.edges) { + const dependents = dependentsByTarget.get(edge.to) ?? []; + dependents.push(edge.from); + dependentsByTarget.set(edge.to, dependents); + } + + const affected = new Set(changedIds); + const queue = [...affected]; + while (queue.length > 0) { + const current = queue.shift()!; + for (const dependent of dependentsByTarget.get(current) ?? []) { + if (!affected.has(dependent)) { + affected.add(dependent); + queue.push(dependent); + } + } + } + + 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)), + }; +} From d5edfc563bca3844a8f427b64c34df411f39f8d2 Mon Sep 17 00:00:00 2001 From: kanoru Date: Thu, 11 Jun 2026 17:48:26 +0300 Subject: [PATCH 06/79] feat: add graph command output renderers --- .../commands/graph/__tests__/print.test.ts | 138 ++++++++++++++++++ packages/cli/src/commands/graph/print/json.ts | 6 + .../cli/src/commands/graph/print/mermaid.ts | 22 +++ .../cli/src/commands/graph/print/stylish.ts | 74 ++++++++++ 4 files changed, 240 insertions(+) create mode 100644 packages/cli/src/commands/graph/__tests__/print.test.ts create mode 100644 packages/cli/src/commands/graph/print/json.ts create mode 100644 packages/cli/src/commands/graph/print/mermaid.ts create mode 100644 packages/cli/src/commands/graph/print/stylish.ts diff --git a/packages/cli/src/commands/graph/__tests__/print.test.ts b/packages/cli/src/commands/graph/__tests__/print.test.ts new file mode 100644 index 0000000000..7f30fe0864 --- /dev/null +++ b/packages/cli/src/commands/graph/__tests__/print.test.ts @@ -0,0 +1,138 @@ +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 repeat, 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 ✗ not found + └── https://example.com/shared.yaml (external)" + `); + }); + + it('marks changed files and 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, { changed: ['components/Pet.yaml'], totalNodeCount: 7 })) + .toMatchInlineSnapshot(` + "openapi.yaml + ├── paths/pets.yaml + │ └── components/Pet.yaml ← changed + └── paths/users.yaml + └── components/User.yaml + └── components/Pet.yaml ↺ ← changed + + 5 of 7 files affected · affected roots: openapi.yaml" + `); + }); + + it('reports when nothing is affected', () => { + expect( + renderStylish({ roots: [], nodes: [], edges: [] }, { changed: [], totalNodeCount: 7 }) + ).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" + `); + }); +}); + +describe('renderJson', () => { + it('serializes the graph model as-is', () => { + const parsed = JSON.parse(renderJson(graph)); + expect(parsed.roots).toEqual(['openapi.yaml']); + expect(parsed.nodes).toHaveLength(7); + expect(parsed.edges).toHaveLength(7); + }); +}); + +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" + `); + }); +}); diff --git a/packages/cli/src/commands/graph/print/json.ts b/packages/cli/src/commands/graph/print/json.ts new file mode 100644 index 0000000000..e7a0a73c26 --- /dev/null +++ b/packages/cli/src/commands/graph/print/json.ts @@ -0,0 +1,6 @@ +import type { DependencyGraph } from '../types.js'; + +/** Serializes the dependency graph as pretty-printed JSON. */ +export function renderJson(graph: DependencyGraph): string { + return JSON.stringify(graph, null, 2); +} diff --git a/packages/cli/src/commands/graph/print/mermaid.ts b/packages/cli/src/commands/graph/print/mermaid.ts new file mode 100644 index 0000000000..dad8833cd2 --- /dev/null +++ b/packages/cli/src/commands/graph/print/mermaid.ts @@ -0,0 +1,22 @@ +import type { DependencyGraph } from '../types.js'; + +/** Renders the dependency graph as a Mermaid flowchart definition. */ +export function renderMermaid(graph: DependencyGraph): string { + const mermaidIds = new Map(graph.nodes.map((node, index) => [node.id, `n${index}`])); + const escapeLabel = (label: string) => label.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/graph/print/stylish.ts b/packages/cli/src/commands/graph/print/stylish.ts new file mode 100644 index 0000000000..feaa0d3c68 --- /dev/null +++ b/packages/cli/src/commands/graph/print/stylish.ts @@ -0,0 +1,74 @@ +import type { DependencyGraph } from '../types.js'; + +export type StylishOptions = { + /** Node ids queried via --affected-by that exist in the graph. */ + changed?: string[]; + /** Node count of the unfiltered graph; enables the affected summary line. */ + totalNodeCount?: number; +}; + +/** + * Renders one ASCII tree per root. A node already expanded in the current tree + * is printed with `↺` and not expanded again (handles cycles and fan-in). + */ +export function renderStylish(graph: DependencyGraph, options: StylishOptions = {}): string { + if (graph.nodes.length === 0) { + return '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(); + } + + const nodesById = new Map(graph.nodes.map((node) => [node.id, node])); + const changed = new Set(options.changed ?? []); + const lines: string[] = []; + + /** Formats one node line: id plus external/broken/repeat/changed markers. */ + const label = (id: string, isRepeat: boolean): string => { + const node = nodesById.get(id); + let text = id; + if (node?.external) text += ' (external)'; + if (node && !node.resolved) text += ' ✗ not found'; + if (isRepeat) text += ' ↺'; + if (changed.has(id)) text += ' ← changed'; + return text; + }; + + /** Recursively prints the children of a node with tree connectors. */ + const renderSubtree = (id: string, prefix: string, printed: Set) => { + const children = childrenByNode.get(id) ?? []; + children.forEach((child, index) => { + const isLast = index === children.length - 1; + const isRepeat = printed.has(child); + lines.push(`${prefix}${isLast ? '└── ' : '├── '}${label(child, isRepeat)}`); + if (!isRepeat) { + printed.add(child); + renderSubtree(child, `${prefix}${isLast ? ' ' : '│ '}`, printed); + } + }); + }; + + graph.roots.forEach((root, index) => { + if (index > 0) lines.push(''); + lines.push(label(root, false)); + renderSubtree(root, '', new Set([root])); + }); + + if (options.totalNodeCount !== undefined) { + lines.push(''); + lines.push( + `${graph.nodes.length} of ${options.totalNodeCount} files affected · affected roots: ${ + graph.roots.join(', ') || 'none' + }` + ); + } + + return lines.join('\n'); +} From 09d4e634fe16f84d96fb2a626f7b4e98b203e533 Mon Sep 17 00:00:00 2001 From: kanoru Date: Thu, 11 Jun 2026 17:56:36 +0300 Subject: [PATCH 07/79] docs: reconcile mermaid label escaping in graph spec with implementation --- docs/superpowers/specs/2026-06-11-graph-command-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-06-11-graph-command-design.md b/docs/superpowers/specs/2026-06-11-graph-command-design.md index 1aec8107ce..ee10ff9269 100644 --- a/docs/superpowers/specs/2026-06-11-graph-command-design.md +++ b/docs/superpowers/specs/2026-06-11-graph-command-design.md @@ -161,7 +161,7 @@ flowchart LR classDef root font-weight:bold ``` -Labels are escaped for Mermaid syntax (quotes, brackets). +Labels are double-quoted (Mermaid's mechanism for special characters such as brackets); literal `"` inside a label is escaped as `#quot;`. ## Error Handling From 94b72e452edfc1dc786b44e0e01dabf146915234 Mon Sep 17 00:00:00 2001 From: kanoru Date: Thu, 11 Jun 2026 18:06:38 +0300 Subject: [PATCH 08/79] feat: register graph command in CLI --- packages/cli/src/commands/graph/index.ts | 86 ++++++++++++++++++++++++ packages/cli/src/commands/lint.ts | 7 +- packages/cli/src/index.ts | 32 +++++++++ packages/cli/src/types.ts | 2 + 4 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/commands/graph/index.ts diff --git a/packages/cli/src/commands/graph/index.ts b/packages/cli/src/commands/graph/index.ts new file mode 100644 index 0000000000..07fce12421 --- /dev/null +++ b/packages/cli/src/commands/graph/index.ts @@ -0,0 +1,86 @@ +import { + BaseResolver, + detectSpec, + getTypes, + logger, + normalizeTypes, + resolveDocument, + slash, + type Document, + type ResolvedRefMap, +} from '@redocly/openapi-core'; +import * as path from 'node:path'; + +import type { 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 } from './filter-affected.js'; +import { renderJson } from './print/json.js'; +import { renderMermaid } from './print/mermaid.js'; +import { renderStylish, type StylishOptions } from './print/stylish.js'; +import type { GraphFormat } from './types.js'; + +export type GraphArgv = { + apis?: string[]; + format: GraphFormat; + 'affected-by'?: string[]; +} & VerifyConfigOptions; + +/** Resolves the given API descriptions and prints their file-level $ref dependency graph. */ +export async function handleGraph({ argv, config, collectSpecData }: CommandArgs) { + const apis = await getFallbackApisOrExit(argv.apis, config); + const externalRefResolver = new BaseResolver(config.resolve); + const cwd = process.cwd(); + + const resolutions: Array<{ rootDocument: Document; refMap: ResolvedRefMap }> = []; + for (const { path: apiPath } of apis) { + const rootDocument = await externalRefResolver.resolveDocument(null, apiPath, true); + if (rootDocument instanceof Error) { + return exitWithError(`Failed to load ${apiPath}: ${rootDocument.message}`); + } + collectSpecData?.(rootDocument.parsed); + const specVersion = detectSpec(rootDocument.parsed); + const types = normalizeTypes(config.extendTypes(getTypes(specVersion), specVersion), config); + const refMap = await resolveDocument({ + rootDocument: rootDocument, + rootType: types.Root, + externalRefResolver, + }); + resolutions.push({ rootDocument: rootDocument, refMap }); + } + + const graph = buildGraph(resolutions, { + cwd, + resolveRef: (base, uri) => externalRefResolver.resolveExternalRef(base, uri), + }); + + let printedGraph = graph; + let stylishOptions: StylishOptions = {}; + if (argv['affected-by']) { + const changedIds = argv['affected-by'].map((file) => + slash(path.relative(cwd, path.resolve(cwd, file))) + ); + const knownIds = new Set(graph.nodes.map((node) => node.id)); + 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 = { changed: knownChanged, totalNodeCount: graph.nodes.length }; + } + + switch (argv.format) { + case 'json': + logger.output(renderJson(printedGraph) + '\n'); + break; + case 'mermaid': + logger.output(renderMermaid(printedGraph) + '\n'); + break; + default: + logger.output(renderStylish(printedGraph, stylishOptions) + '\n'); + } +} diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index 34179987a8..d27f2a3eee 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -143,7 +143,12 @@ 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' + ) { // these are single-document formats, so a separate config-lint document would break the output return; } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index ec6beb77f6..b8f0f82dcd 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -16,6 +16,8 @@ import { handleGenerateArazzo, type GenerateArazzoCommandArgv, } from './commands/generate-arazzo.js'; +import { handleGraph } from './commands/graph/index.js'; +import type { GraphFormat } from './commands/graph/types.js'; import { handleJoin } from './commands/join/index.js'; import { handleLint } from './commands/lint.js'; import { PRODUCT_PLANS } from './commands/preview-project/constants.js'; @@ -74,6 +76,36 @@ yargs(hideBin(process.argv)) commandWrapper(handleStats)(argv); } ) + .command( + 'graph [apis...]', + 'Show the $ref dependency graph of API description files.', + (yargs) => + yargs + .env('REDOCLY_CLI_GRAPH') + .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'] as ReadonlyArray, + default: 'stylish' as GraphFormat, + }, + 'affected-by': { + description: 'Show only the part of the graph affected by changes to the given files.', + array: true, + type: 'string', + requiresArg: true, + }, + }), + (argv) => { + commandWrapper(handleGraph)(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 9e72beed47..87b4e3026c 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -5,6 +5,7 @@ import type { BuildDocsArgv } from './commands/build-docs/types.js'; import type { BundleArgv } from './commands/bundle.js'; import type { EjectArgv } from './commands/eject.js'; import type { GenerateArazzoCommandArgv } from './commands/generate-arazzo.js'; +import type { GraphArgv } from './commands/graph/index.js'; import type { JoinArgv } from './commands/join/types.js'; import type { LintArgv } from './commands/lint.js'; import type { PreviewProjectArgv } from './commands/preview-project/types.js'; @@ -29,6 +30,7 @@ export const outputExtensions = ['json', 'yaml', 'yml'] as const; export type OutputExtension = (typeof outputExtensions)[number]; export type CommandArgv = | StatsArgv + | GraphArgv | SplitArgv | JoinArgv | LintArgv From 3acea6324753f35015653b599f3a9e424bd18d22 Mon Sep 17 00:00:00 2001 From: kanoru Date: Thu, 11 Jun 2026 20:44:39 +0300 Subject: [PATCH 09/79] docs: document repeated --affected-by flag syntax in graph spec and plan --- .../plans/2026-06-11-graph-command.md | 20 +++++++++---------- .../specs/2026-06-11-graph-command-design.md | 4 ++-- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/superpowers/plans/2026-06-11-graph-command.md b/docs/superpowers/plans/2026-06-11-graph-command.md index 3d288afee1..d4bbc1f788 100644 --- a/docs/superpowers/plans/2026-06-11-graph-command.md +++ b/docs/superpowers/plans/2026-06-11-graph-command.md @@ -1206,7 +1206,7 @@ Use it to: ```bash redocly graph redocly graph -redocly graph [--format=] [--affected-by=] [--config=] +redocly graph [--format=] [--affected-by=] [--config=] ``` ```` @@ -1214,15 +1214,15 @@ If you don't pass any API to the command, it processes all APIs defined in your ## Options -| Option | Type | Description | -| ------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------- | -| apis | [string] | Paths to API description files. Defaults to all APIs from the Redocly configuration file. | -| --affected-by | [string] | Show only the part of the graph affected by changes to the given files: the files themselves plus everything that references them. | -| --config | string | Specify the path to the [Redocly configuration file](../configuration/index.md). | -| --format | string | Output format: `stylish` (default, tree view), `json`, or `mermaid`. | -| --help | boolean | Show help. | -| --lint-config | string | Specify the severity level for the configuration file.
**Possible values:** `warn`, `error`, `off`. Default value is `warn`. | -| --version | boolean | Show version number. | +| Option | Type | Description | +| ------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| apis | [string] | Paths to API description files. Defaults to all APIs from the Redocly configuration file. | +| --affected-by | [string] | Show only the part of the graph affected by changes to the given files: the files themselves plus everything that references them. Repeat the option to pass several files: `--affected-by a.yaml --affected-by b.yaml`. | +| --config | string | Specify the path to the [Redocly configuration file](../configuration/index.md). | +| --format | string | Output format: `stylish` (default, tree view), `json`, or `mermaid`. | +| --help | boolean | Show help. | +| --lint-config | string | Specify the severity level for the configuration file.
**Possible values:** `warn`, `error`, `off`. Default value is `warn`. | +| --version | boolean | Show version number. | ## Examples diff --git a/docs/superpowers/specs/2026-06-11-graph-command-design.md b/docs/superpowers/specs/2026-06-11-graph-command-design.md index ee10ff9269..c8963c441a 100644 --- a/docs/superpowers/specs/2026-06-11-graph-command-design.md +++ b/docs/superpowers/specs/2026-06-11-graph-command-design.md @@ -33,7 +33,7 @@ The data already exists: `resolveDocument()` in `packages/core/src/resolve.ts` p redocly graph [apis...] # no args: all APIs from redocly.yaml (lint convention) redocly graph openapi.yaml # explicit root(s) redocly graph --format # default: stylish -redocly graph --affected-by [...] # impact filter, accepts multiple files +redocly graph --affected-by [--affected-by ] # impact filter; repeat the flag per file redocly graph --config # standard config flag ``` @@ -99,7 +99,7 @@ handleGraph({ argv, config }) Reverse BFS over edges starting from the given files: collect every file that references them, transitively, up to the roots. The result is the induced subgraph (changed files + all transitive dependents + edges among them), rendered in whichever `--format` is active. - Input paths are resolved against cwd to absolute form and matched to node ids; output stays cwd-relative. -- Multiple files: the affected sets are unioned. +- Multiple files: the affected sets are unioned. The flag is passed once per file (`--affected-by a.yaml --affected-by b.yaml`) — the CLI's global `greedy-arrays: false` parser setting means space-separated values after one flag would be read as extra API positionals. - `stylish` prunes the tree to affected branches, marks the queried files with a `← changed` suffix, and appends a summary line, e.g. `2 of 6 files affected · affected roots: openapi.yaml`. - A queried file that is not part of the graph produces a **stderr** warning (`schemas/Unused.yaml is not referenced by any processed API`) and exit code `0` — for AI Review "nothing depends on this" is a legitimate answer, not an error. If no queried file is in the graph, the output is an empty graph in the chosen format (`stylish` prints `No files affected.`). - stdout stays pure for `json` and `mermaid` (no banners or progress text) so output can be piped. From a725935762cc6c146278150fe21cd88e97a8fe80 Mon Sep 17 00:00:00 2001 From: kanoru Date: Thu, 11 Jun 2026 21:02:48 +0300 Subject: [PATCH 10/79] test: add graph command e2e tests --- .../graph-affected-by-unknown/snapshot.txt | 3 + .../e2e/graph/graph-affected-by/snapshot.txt | 7 ++ tests/e2e/graph/graph-json/snapshot.txt | 77 +++++++++++++++++++ .../components/schemas/Address.yaml | 4 + .../components/schemas/Pet.yaml | 4 + .../components/schemas/User.yaml | 6 ++ tests/e2e/graph/graph-multi-file/openapi.yaml | 9 +++ .../graph/graph-multi-file/paths/pets.yaml | 9 +++ .../graph/graph-multi-file/paths/users.yaml | 9 +++ tests/e2e/graph/graph-stylish/snapshot.txt | 8 ++ tests/e2e/graph/graph.test.ts | 54 +++++++++++++ 11 files changed, 190 insertions(+) create mode 100644 tests/e2e/graph/graph-affected-by-unknown/snapshot.txt create mode 100644 tests/e2e/graph/graph-affected-by/snapshot.txt create mode 100644 tests/e2e/graph/graph-json/snapshot.txt create mode 100644 tests/e2e/graph/graph-multi-file/components/schemas/Address.yaml create mode 100644 tests/e2e/graph/graph-multi-file/components/schemas/Pet.yaml create mode 100644 tests/e2e/graph/graph-multi-file/components/schemas/User.yaml create mode 100644 tests/e2e/graph/graph-multi-file/openapi.yaml create mode 100644 tests/e2e/graph/graph-multi-file/paths/pets.yaml create mode 100644 tests/e2e/graph/graph-multi-file/paths/users.yaml create mode 100644 tests/e2e/graph/graph-stylish/snapshot.txt create mode 100644 tests/e2e/graph/graph.test.ts diff --git a/tests/e2e/graph/graph-affected-by-unknown/snapshot.txt b/tests/e2e/graph/graph-affected-by-unknown/snapshot.txt new file mode 100644 index 0000000000..b95300c98b --- /dev/null +++ b/tests/e2e/graph/graph-affected-by-unknown/snapshot.txt @@ -0,0 +1,3 @@ +No files affected. + +components/schemas/Unknown.yaml is not referenced by any of the processed APIs. diff --git a/tests/e2e/graph/graph-affected-by/snapshot.txt b/tests/e2e/graph/graph-affected-by/snapshot.txt new file mode 100644 index 0000000000..03262a004d --- /dev/null +++ b/tests/e2e/graph/graph-affected-by/snapshot.txt @@ -0,0 +1,7 @@ +openapi.yaml +└── paths/users.yaml + └── components/schemas/User.yaml + └── components/schemas/Address.yaml ← changed + +4 of 6 files affected · affected roots: openapi.yaml + diff --git a/tests/e2e/graph/graph-json/snapshot.txt b/tests/e2e/graph/graph-json/snapshot.txt new file mode 100644 index 0000000000..befe6c2a7d --- /dev/null +++ b/tests/e2e/graph/graph-json/snapshot.txt @@ -0,0 +1,77 @@ +{ + "roots": [ + "openapi.yaml" + ], + "nodes": [ + { + "id": "components/schemas/Address.yaml", + "resolved": true + }, + { + "id": "components/schemas/Pet.yaml", + "resolved": true + }, + { + "id": "components/schemas/User.yaml", + "resolved": true + }, + { + "id": "openapi.yaml", + "resolved": true, + "root": true + }, + { + "id": "paths/pets.yaml", + "resolved": true + }, + { + "id": "paths/users.yaml", + "resolved": true + } + ], + "edges": [ + { + "from": "components/schemas/User.yaml", + "to": "components/schemas/Address.yaml", + "refs": [ + "Address.yaml" + ] + }, + { + "from": "components/schemas/User.yaml", + "to": "components/schemas/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/schemas/Pet.yaml", + "refs": [ + "../components/schemas/Pet.yaml" + ] + }, + { + "from": "paths/users.yaml", + "to": "components/schemas/User.yaml", + "refs": [ + "../components/schemas/User.yaml" + ] + } + ] +} + diff --git a/tests/e2e/graph/graph-multi-file/components/schemas/Address.yaml b/tests/e2e/graph/graph-multi-file/components/schemas/Address.yaml new file mode 100644 index 0000000000..04800108d3 --- /dev/null +++ b/tests/e2e/graph/graph-multi-file/components/schemas/Address.yaml @@ -0,0 +1,4 @@ +type: object +properties: + city: + type: string diff --git a/tests/e2e/graph/graph-multi-file/components/schemas/Pet.yaml b/tests/e2e/graph/graph-multi-file/components/schemas/Pet.yaml new file mode 100644 index 0000000000..5cb91cda73 --- /dev/null +++ b/tests/e2e/graph/graph-multi-file/components/schemas/Pet.yaml @@ -0,0 +1,4 @@ +type: object +properties: + name: + type: string diff --git a/tests/e2e/graph/graph-multi-file/components/schemas/User.yaml b/tests/e2e/graph/graph-multi-file/components/schemas/User.yaml new file mode 100644 index 0000000000..ef95a3500e --- /dev/null +++ b/tests/e2e/graph/graph-multi-file/components/schemas/User.yaml @@ -0,0 +1,6 @@ +type: object +properties: + address: + $ref: Address.yaml + pet: + $ref: Pet.yaml diff --git a/tests/e2e/graph/graph-multi-file/openapi.yaml b/tests/e2e/graph/graph-multi-file/openapi.yaml new file mode 100644 index 0000000000..7272a04de6 --- /dev/null +++ b/tests/e2e/graph/graph-multi-file/openapi.yaml @@ -0,0 +1,9 @@ +openapi: 3.0.0 +info: + title: Graph fixture + version: 1.0.0 +paths: + /pets: + $ref: paths/pets.yaml + /users: + $ref: paths/users.yaml diff --git a/tests/e2e/graph/graph-multi-file/paths/pets.yaml b/tests/e2e/graph/graph-multi-file/paths/pets.yaml new file mode 100644 index 0000000000..162bb2b0ab --- /dev/null +++ b/tests/e2e/graph/graph-multi-file/paths/pets.yaml @@ -0,0 +1,9 @@ +get: + summary: List pets + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: ../components/schemas/Pet.yaml diff --git a/tests/e2e/graph/graph-multi-file/paths/users.yaml b/tests/e2e/graph/graph-multi-file/paths/users.yaml new file mode 100644 index 0000000000..bd276a8888 --- /dev/null +++ b/tests/e2e/graph/graph-multi-file/paths/users.yaml @@ -0,0 +1,9 @@ +get: + summary: List users + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: ../components/schemas/User.yaml diff --git a/tests/e2e/graph/graph-stylish/snapshot.txt b/tests/e2e/graph/graph-stylish/snapshot.txt new file mode 100644 index 0000000000..83d15d2d6d --- /dev/null +++ b/tests/e2e/graph/graph-stylish/snapshot.txt @@ -0,0 +1,8 @@ +openapi.yaml +├── paths/pets.yaml +│ └── components/schemas/Pet.yaml +└── paths/users.yaml + └── components/schemas/User.yaml + ├── components/schemas/Address.yaml + └── components/schemas/Pet.yaml ↺ + diff --git a/tests/e2e/graph/graph.test.ts b/tests/e2e/graph/graph.test.ts new file mode 100644 index 0000000000..bfbd4739ac --- /dev/null +++ b/tests/e2e/graph/graph.test.ts @@ -0,0 +1,54 @@ +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('graph', () => { + const folderPath = __dirname; + const fixturePath = join(folderPath, 'graph-multi-file'); + + test('graph should print a stylish tree', async () => { + const args = getParams(indexEntryPoint, ['graph', 'openapi.yaml']); + const result = getCommandOutput(args, { testPath: fixturePath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot( + join(folderPath, 'graph-stylish', 'snapshot.txt') + ); + }); + + test('graph should print pure JSON', async () => { + const args = getParams(indexEntryPoint, ['graph', 'openapi.yaml', '--format=json']); + const result = getCommandOutput(args, { testPath: fixturePath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot( + join(folderPath, 'graph-json', 'snapshot.txt') + ); + }); + + test('graph should print only the affected subgraph', async () => { + const args = getParams(indexEntryPoint, [ + 'graph', + 'openapi.yaml', + '--affected-by', + 'components/schemas/Address.yaml', + ]); + const result = getCommandOutput(args, { testPath: fixturePath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot( + join(folderPath, 'graph-affected-by', 'snapshot.txt') + ); + }); + + test('graph should warn when the affected-by file is not in the graph', async () => { + const args = getParams(indexEntryPoint, [ + 'graph', + 'openapi.yaml', + '--affected-by', + 'components/schemas/Unknown.yaml', + ]); + const result = getCommandOutput(args, { testPath: fixturePath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot( + join(folderPath, 'graph-affected-by-unknown', 'snapshot.txt') + ); + }); +}); From 3b65a5129fce07656e94d8cb195d754e65eba4de Mon Sep 17 00:00:00 2001 From: kanoru Date: Thu, 11 Jun 2026 21:44:27 +0300 Subject: [PATCH 11/79] docs: document graph command and add changeset --- .changeset/graph-command.md | 5 +++ docs/@v2/commands/graph.md | 86 +++++++++++++++++++++++++++++++++++++ docs/@v2/commands/index.md | 1 + docs/@v2/v2.sidebars.yaml | 2 + 4 files changed, 94 insertions(+) create mode 100644 .changeset/graph-command.md create mode 100644 docs/@v2/commands/graph.md diff --git a/.changeset/graph-command.md b/.changeset/graph-command.md new file mode 100644 index 0000000000..837c338772 --- /dev/null +++ b/.changeset/graph-command.md @@ -0,0 +1,5 @@ +--- +'@redocly/cli': minor +--- + +Added the `graph` command that prints the file-level `$ref` dependency graph of API descriptions as a tree (`stylish`), `json`, or `mermaid` output. The `--affected-by` option filters the graph to the files impacted by changes to the given files. diff --git a/docs/@v2/commands/graph.md b/docs/@v2/commands/graph.md new file mode 100644 index 0000000000..253608c43f --- /dev/null +++ b/docs/@v2/commands/graph.md @@ -0,0 +1,86 @@ +# `graph` + +## Introduction + +The `graph` command prints the file-level dependency graph of an API description: which files reference which other files through `$ref`. It works with multi-file OpenAPI, AsyncAPI, and Arazzo descriptions. + +Use it to: + +- get a quick `tree`-style overview of a multi-file API description; +- find out which files are affected by a change to a shared file (`--affected-by`) — for example, in CI or automated code review; +- feed exact file relationships to tooling as JSON or render them as a Mermaid diagram. + +## Usage + +```bash +redocly graph +redocly graph +redocly graph [--format=] [--affected-by=] [--config=] +``` + +If you don't pass any API to the command, it processes all APIs defined in your Redocly configuration file and prints them as a single graph with shared files deduplicated — one tree per API root in the default view. + +## Options + +| Option | Type | Description | +| ------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| apis | [string] | Paths to API description files. Defaults to all APIs from the Redocly configuration file. | +| --affected-by | [string] | Show only the part of the graph affected by changes to the given files: the files themselves plus everything that references them. Repeat the option to pass several files: `--affected-by a.yaml --affected-by b.yaml`. | +| --config | string | Specify the path to the [Redocly configuration file](../configuration/index.md). | +| --format | string | Output format: `stylish` (default, tree view), `json`, or `mermaid`. | +| --help | boolean | Show help. | +| --lint-config | string | Specify the severity level for the configuration file.
**Possible values:** `warn`, `error`, `off`. Default value is `warn`. | +| --version | boolean | Show version number. | + +## Examples + +### Print the dependency tree + +```bash +redocly graph openapi.yaml +``` + +``` +openapi.yaml +├── paths/pets.yaml +│ └── components/schemas/Pet.yaml +└── paths/users.yaml + └── components/schemas/User.yaml + ├── components/schemas/Address.yaml + └── components/schemas/Pet.yaml ↺ +``` + +The `↺` marker means the file was already expanded earlier in the tree, so its references are not repeated. Files that cannot be resolved are marked with `✗ not found`, and references to URLs are marked with `(external)`. + +### Find files affected by a change + +Pass a changed file to `--affected-by` to see only the impacted part of the graph — useful in CI and automated review to decide what needs attention without reading every file. Repeat the option to pass several changed files at once. + +```bash +redocly graph openapi.yaml --affected-by components/schemas/Address.yaml +``` + +``` +openapi.yaml +└── paths/users.yaml + └── components/schemas/User.yaml + └── components/schemas/Address.yaml ← changed + +4 of 6 files affected · affected roots: openapi.yaml +``` + +If a file passed to `--affected-by` is not referenced by any processed API, the command prints a warning to stderr and exits with code `0` — "nothing depends on this file" is a valid answer. + +### Machine-readable output + +```bash +redocly graph openapi.yaml --format=json +``` + +Prints the graph as JSON with `roots`, `nodes` (including `resolved` and `external` flags), and `edges` (including the exact `$ref` strings). Only the JSON is written to stdout, so the output is safe to pipe. + +```bash +redocly graph openapi.yaml --format=mermaid +``` + +Prints a [Mermaid](https://mermaid.js.org/) `flowchart` definition. GitHub renders Mermaid code blocks in Markdown automatically, so you can paste the output into a pull request comment or documentation page to get a diagram. diff --git a/docs/@v2/commands/index.md b/docs/@v2/commands/index.md index add90a21f3..4cec5d9e74 100644 --- a/docs/@v2/commands/index.md +++ b/docs/@v2/commands/index.md @@ -14,6 +14,7 @@ Documentation commands: API management commands: - [`bundle`](bundle.md) Bundle API description. +- [`graph`](graph.md) Show the `$ref` dependency graph of API description files. - [`join`](join.md) Join API descriptions [experimental feature]. - [`score`](score.md) Score an API for integration simplicity and AI agent readiness. - [`split`](split.md) Split API description into a multi-file structure. diff --git a/docs/@v2/v2.sidebars.yaml b/docs/@v2/v2.sidebars.yaml index 2e11a05018..515c7eb706 100644 --- a/docs/@v2/v2.sidebars.yaml +++ b/docs/@v2/v2.sidebars.yaml @@ -18,6 +18,8 @@ page: commands/eject.md - label: generate-arazzo page: commands/generate-arazzo.md + - label: graph + page: commands/graph.md - label: join page: commands/join.md - label: lint From 4afc7612b31c74069419e84bffd929a3f676b96b Mon Sep 17 00:00:00 2001 From: kanoru Date: Thu, 11 Jun 2026 21:56:47 +0300 Subject: [PATCH 12/79] docs: align spec warning wording with implementation --- docs/superpowers/specs/2026-06-11-graph-command-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-06-11-graph-command-design.md b/docs/superpowers/specs/2026-06-11-graph-command-design.md index c8963c441a..7472e134ed 100644 --- a/docs/superpowers/specs/2026-06-11-graph-command-design.md +++ b/docs/superpowers/specs/2026-06-11-graph-command-design.md @@ -101,7 +101,7 @@ Reverse BFS over edges starting from the given files: collect every file that re - Input paths are resolved against cwd to absolute form and matched to node ids; output stays cwd-relative. - Multiple files: the affected sets are unioned. The flag is passed once per file (`--affected-by a.yaml --affected-by b.yaml`) — the CLI's global `greedy-arrays: false` parser setting means space-separated values after one flag would be read as extra API positionals. - `stylish` prunes the tree to affected branches, marks the queried files with a `← changed` suffix, and appends a summary line, e.g. `2 of 6 files affected · affected roots: openapi.yaml`. -- A queried file that is not part of the graph produces a **stderr** warning (`schemas/Unused.yaml is not referenced by any processed API`) and exit code `0` — for AI Review "nothing depends on this" is a legitimate answer, not an error. If no queried file is in the graph, the output is an empty graph in the chosen format (`stylish` prints `No files affected.`). +- A queried file that is not part of the graph produces a **stderr** warning (`schemas/Unused.yaml is not referenced by any of the processed APIs.`) and exit code `0` — for AI Review "nothing depends on this" is a legitimate answer, not an error. If no queried file is in the graph, the output is an empty graph in the chosen format (`stylish` prints `No files affected.`). - stdout stays pure for `json` and `mermaid` (no banners or progress text) so output can be piped. ## Output Formats From b02b2f25ad44e6a0389ae9ed27aad31c917a6683 Mon Sep 17 00:00:00 2001 From: kanoru Date: Fri, 12 Jun 2026 19:10:06 +0300 Subject: [PATCH 13/79] docs: add tree command rework spec and plan --- .../plans/2026-06-12-tree-command-rework.md | 126 ++++++++++++++++ .../2026-06-12-tree-command-rework-design.md | 138 ++++++++++++++++++ 2 files changed, 264 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-12-tree-command-rework.md create mode 100644 docs/superpowers/specs/2026-06-12-tree-command-rework-design.md diff --git a/docs/superpowers/plans/2026-06-12-tree-command-rework.md b/docs/superpowers/plans/2026-06-12-tree-command-rework.md new file mode 100644 index 0000000000..f2bda73f30 --- /dev/null +++ b/docs/superpowers/plans/2026-06-12-tree-command-rework.md @@ -0,0 +1,126 @@ +# `redocly tree` Rework Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Rework `redocly graph` into `redocly tree`: default mode shows the internal structure of one API description (root → paths → operations → component chains); the existing file-level graph moves behind `--files` unchanged. + +**Spec:** `docs/superpowers/specs/2026-06-12-tree-command-rework-design.md` (the contract — read it first). + +**Architecture:** CLI-only. New pure modules `node-id.ts` (pointer→node mapping), `build-structure.ts` (walkDocument-based builder using the stats pattern + a `ref` visitor for every `$ref` site), `match-affected-by.ts` (input matcher). Reused untouched: `filter-affected.ts`, `print/json.ts`, `print/mermaid.ts`, `build-graph.ts` (files mode). Adjusted: `types.ts` (additive `kind`/`file`), `print/stylish.ts` (caller-provided `summary`/`emptyMessage`), handler (mode dispatch). + +**Verified mechanics (trust these):** + +- `walkDocument` fires `ref` enter visitors at EVERY ref site (`packages/core/src/walk.ts` ~182-209): visitor `(node, ctx, resolved)` where `ctx.location`/`ctx.rawLocation` = ref-site Location (`source.absoluteRef` + `pointer`), `resolved = { node, location: resolvedLocation, error }` with target Location (`source.absoluteRef` + `nodePointer`). Cycles terminate via the walker's seen-node dedup. +- Type-enter visitors receive `rawLocation` = the ref-site location for `$ref`'d nodes — pointer-prefix checks on `PathItem`/`Operation` hooks are reliable and immune to callbacks/webhooks misattribution. +- `normalizeVisitors` ignores visitor keys absent from the spec's type map → `PathItem`/`Operation` hooks are inert for AsyncAPI/Arazzo. +- PathItem methods across specs: `get put post delete options head patch trace query x-query`. +- Core public barrel: `unescapePointerFragment`, `escapePointerFragment`, `isAbsoluteUrl`, `slash`, `Source`, `normalizeVisitors`, `walkDocument`, `normalizeTypes`, `getTypes`, `detectSpec`, `resolveDocument`, `BaseResolver`, `logger`, types `Document`, `ResolvedRefMap`, `NormalizedNodeType`, `WalkContext`. NOT exported: `parsePointer`/`parseRef`/`joinPointer` → `node-id.ts` carries a tiny local `parsePointerSegments`. +- Unit-test harness for walking without fs: see `packages/cli/src/commands/score/__tests__/collect-metrics-helper.ts` (`Source` + parsed object + `normalizeTypes(getTypes(v), {})` + `resolveDocument` + `WalkContext`). +- Files referencing the command outside its folder: `packages/cli/src/index.ts` (yargs block), `packages/cli/src/types.ts` (union), docs/changeset/e2e. `lint.ts` guard keys on format values only — unchanged. +- Sidebar: `tree` sorts after `translate` (currently last in the Commands group). +- Known pre-existing failures (NOT ours): build-docs e2e bundle-size drift; respect-core `entity.test.ts` timeout flake; occasional `local-json-server` flake. + +## Node model & id scheme + +See spec. Summary: `GraphNode += kind?: 'root'|'path'|'operation'|'component'|'file'; file?: string` (default mode only). Ids: `openapi.yaml` / `/pets` / `GET /pets` / `schemas/Pet` (root-file component, no `#/`) / `definitions/Pet` (OAS2) / `webhooks/newPet` (fallback, first two segments) / `schemas/pet.yaml` (whole foreign file) / `common.yaml#/components/schemas/Pet` (component in foreign file). Nested target pointers normalize to the top-level component. Self-edges kept. Post-build prune to root-reachable. Default mode = exactly one API (else `exitWithError` suggesting `--files`). + +## `node-id.ts` contract (T3) + +```ts +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', +]); + +/** '#/paths/~1pets/get' -> ['paths', '/pets', 'get'] */ +export function parsePointerSegments(pointer: string): string[]; + +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[]; +}; + +/** Maps a pointer within the ROOT document to its owning tree node. */ +export function mapRootPointer(pointer: string, rootId: string): MappedNode; +// paths/{p} -> { id: p, kind: 'path', ancestry: [] } +// paths/{p}/{method}.. -> { id: `${METHOD} ${p}`, kind: 'operation', ancestry: [p] } +// paths/{p}/.. -> { id: p, kind: 'path', ancestry: [] } (path-level params etc.) +// components/{t}/{n}.. -> { id: `${t}/${n}`, kind: 'component' } (no ancestry) +// OAS2 {section}/{n}.. -> { id: `${section}/${n}`, kind: 'component' } +// '' -> { id: rootId, kind: 'root' } +// anything else -> { id: first two segments (or one), kind: 'component', ancestry: [] } + +/** Maps a location in a NON-root file: component inside it or the whole file. */ +export function mapForeignLocation(fileId: string, pointer: string): MappedNode & { file: string }; +// components-section pointer (depth 3) or OAS2 section (depth 2) -> { id: `${fileId}#/`, kind: 'component' } +// otherwise -> { id: fileId, kind: 'file' } +``` + +## `build-structure.ts` contract (T4) + +```ts +export function buildStructure(options: { + document: Document; + types: Record; + resolvedRefMap: ResolvedRefMap; + ctx: WalkContext; + cwd: string; + resolveRef: (base: string, uri: string) => string; // BaseResolver.resolveExternalRef in prod +}): DependencyGraph; +``` + +- Visitor: `PathItem.enter` / `Operation.enter` act only when site is root file AND pointer has exactly 2 / 3 segments starting with `paths` (3rd ∈ OPERATION_METHODS) → materialize node + spine edges (refs `[]`). +- `ref.enter(refNode, ctx, resolved)`: owner = map(ctx.location), target = `resolved.location` ? map(resolved.location) : unresolved-target derivation (raw `$ref` split on `#`; empty uri → root-pointer mapping; else `resolveRef(siteFile, uri)` → file/foreign mapping; node `resolved:false`). Edge owner→target collects distinct `$ref` strings. `isAbsoluteUrl` ids → `external: true`. +- `materialize(mapped)` upserts node with kind/file and wires `root → ...ancestry → node` spine edges when `ancestry !== undefined`. +- Post-build: BFS-prune to root-reachable; codepoint-sort nodes/edges/refs (same comparator as `build-graph.ts`); `roots: [rootId]`. + +## `match-affected-by.ts` contract (T6) + +```ts +export function matchAffectedBy( + graph: DependencyGraph, + inputs: string[], + options: { cwd: string; rootId: string } +): { changedIds: string[]; markerIds: string[]; notes: string[]; warnings: string[] }; +``` + +Rules per input (first match wins): exact id → pointer (`#...` via mapRootPointer) → file path (`slash(path.relative(cwd, path.resolve(cwd, input)))`; equals rootId → ALL node ids changed, marker = root only, note) → bare component name (last segment match over `kind:'component'`; ambiguous → all + note). No match → warning. Handler logs notes/warnings via `logger.warn` (stderr), exit 0. + +## Stylish options (T5) + +`StylishOptions = { changed?: string[]; summary?: string; emptyMessage?: string }` — renderer appends `summary` after a blank line when set; empty graph returns `emptyMessage ?? 'No files affected.'`. Handler composes: files mode summary byte-identical to today; default mode `N of M operations affected · affected paths: ` (fallback `N of M nodes affected` when the full graph has zero `kind:'operation'` nodes); default empty message `No nodes affected.` + +## Tasks (TDD, one commit each; verification: `npm run compile` before unit/e2e) + +- **T1** Spec+plan docs (this file + spec) → `docs: add tree command rework spec and plan` +- **T2** Mechanical rename: `git mv packages/cli/src/commands/graph packages/cli/src/commands/tree`; `git mv tests/e2e/graph tests/e2e/tree`; symbols `handleGraph→handleTree`, `GraphArgv→TreeArgv`, `GraphFormat→TreeFormat`; yargs `'tree [apis...]'` + desc `Display the structure of an API description as a tree.` + `.env('REDOCLY_CLI_TREE')`; union import/member in `packages/cli/src/types.ts`; e2e test runs `'tree'`, snapshot dirs `graph-*` → `tree-files-*` (содержимое unchanged), fixture dir stays `graph-multi-file` → rename to `tree-multi-file` (update test paths). Verify: typecheck + unit + e2e (tests/e2e/tree). Commit `refactor: rename graph command to tree`. +- **T3** `node-id.ts` + `__tests__/node-id.test.ts` (~10 cases: escaping `~1/~0`; root/path/operation/path-level/component/OAS2/fallback/x-query; foreign component canonical id; foreign whole-file). Commit `feat: add pointer-to-node mapping for the tree structure view`. +- **T4** `types.ts` additive fields; `build-structure.ts` + `__tests__/build-structure.test.ts` (~12 cases listed in spec Testing section; harness per score pattern with injected `resolveRef`). Commit `feat: add internal-structure builder for the tree command`. +- **T5** stylish options refactor + adapt 2 print tests (summary now caller-provided string). Commit `refactor: make stylish summary and empty message caller-provided`. +- **T6** `match-affected-by.ts` + ~7 tests. Commit `feat: match affected-by inputs against tree nodes`. +- **T7** Handler rework (`--files` dispatch keeps today's path verbatim incl. summary text; default mode: single-API guard → buildStructure → matcher → filterAffected → summary → render) + yargs `files` boolean option + `--affected-by` description update. Verify: files-mode e2e snapshots UNCHANGED; manual smoke. Commit `feat: make document structure the default tree view behind --files fallback`. +- **T8** E2E: new `tests/e2e/tree/tree-single-file/openapi.yaml` (paths `/pets` GET+POST, `/pets/{petId}` GET, `/users` GET; `components.schemas`: `Pet→Address`, `PetInput→Pet`, `User→Address` (fan-in `↺`), `parameters/PetId` referenced at path level, unused `Orphan` (proves pruning)); tests: default stylish, json, `--affected-by '#/components/schemas/Address'`, `--affected-by Address`, `--affected-by schemas/Unknown` (warning), multi-file default mode, multi-file default `--affected-by components/schemas/Address.yaml` (file input → impacted operations); 4 files-mode tests kept. Commit `test: cover tree structure mode end to end`. +- **T9** `git mv docs/@v2/commands/graph.md docs/@v2/commands/tree.md` + rewrite per spec; sidebar move after `translate`; commands index line `- [\`tree\`](tree.md) Display the structure of an API description as a tree.`; rewrite `.changeset/graph-command.md`. Check `grep -rn "commands/graph" docs/`clean. Commit`docs: document the tree command and update the changeset`. +- **T10** `npm test` (known pre-existing failures excepted) + `grep -rni "redocly graph\|REDOCLY_CLI_GRAPH\|handleGraph\|GraphArgv" packages docs tests` clean + final whole-feature review. + +## Risks + +- YAML anchor-shared operation objects enumerate once (walker dedup) — accepted. +- `$ref`'d path-item walker ordering — early T4 test; ref-visitor spine creation is the fallback. +- Files-mode snapshot content diffs = regression signal (only dir names change). diff --git a/docs/superpowers/specs/2026-06-12-tree-command-rework-design.md b/docs/superpowers/specs/2026-06-12-tree-command-rework-design.md new file mode 100644 index 0000000000..c1deee5702 --- /dev/null +++ b/docs/superpowers/specs/2026-06-12-tree-command-rework-design.md @@ -0,0 +1,138 @@ +# `redocly tree` Command Rework — Design + +**Date:** 2026-06-12 +**Branch:** `feat/graph-command` (rework on top of the existing implementation; the PR stays open) +**Supersedes:** the file-level-only design in `2026-06-11-graph-command-design.md` (kept for history) +**Status:** Approved + +## Motivation (PR feedback) + +The shipped `graph` command shows only the file-level `$ref` graph, which is useful only for split specs. Review feedback: + +1. The command must be named **`tree`**. +2. The primary case is a spec in **one file**. The command must show the structure of the **OpenAPI document itself** — paths, operations, and their component dependency chains — and `--affected-by` must answer "which paths/operations are impacted" even for a single-file spec. +3. The file-level view is the less useful mode and is demoted behind a flag. + +## Decisions (confirmed with the user) + +- **One command `tree`.** Default mode = internal document structure. `--files` flag = the existing file-level graph, unchanged. +- Stylish depth in default mode: root file → `/pets` → `GET`/`POST` → transitive component chains, with the existing `↺` repeat marker. +- `--affected-by` accepts a component pointer (`#/components/schemas/Pet`), a shorthand (`schemas/Pet`, bare `Pet`), or a file path. Output is the affected subgraph; the summary reports affected operations and paths. + +## Goals + +- `redocly tree [api]` prints the internal structure tree of one API description (any spec type core resolves). +- `--files` preserves today's multi-API file-level graph byte-for-byte (snapshots are the regression guard). +- All three formats (`stylish` default, `json`, `mermaid`) work in both modes; `json`/`mermaid` stdout stays pure. +- `--affected-by` works in both modes; in default mode it reports impacted operations/paths. + +## Non-goals + +- No `packages/core` changes. +- No exploding of operations defined inside a `$ref`'d path-item _file_ — the file node represents them (documented limitation). +- Orphan (unreachable from root) components are pruned from the default-mode graph — unused-component detection stays `lint`'s job. +- No changes to `--files` mode semantics. + +## Node model + +`DependencyGraph`/`GraphEdge` stay as-is. `GraphNode` gains optional fields set only by the structure builder (files mode emits objects identical to today): + +```ts +export type NodeKind = 'root' | 'path' | 'operation' | 'component' | 'file'; + +type GraphNode = { + id: string; + root?: boolean; + external?: boolean; + resolved: boolean; + kind?: NodeKind; // default mode only + file?: string; // cwd-relative source file of the node; default mode only +}; +``` + +Id scheme (id = display label; renderers print ids directly): + +| Node | id | kind | +| --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | ----------- | +| Root API document | `openapi.yaml` (cwd-relative, as in files mode) | `root` | +| Path | `/pets` (unescaped pointer fragment) | `path` | +| Operation | `GET /pets` (uppercased method + space + path) | `operation` | +| Component in root file (OAS3/AsyncAPI/Arazzo) | `schemas/Pet` (`components/` wrapper dropped, no `#/` prefix) | `component` | +| Component in root file (OAS2 sections) | `definitions/Pet`, `parameters/limitParam`, `responses/NotFound`, `securityDefinitions/api_key` | `component` | +| Generic root-level fallback (`webhooks/*`, `channels/*`, `workflows/*`, `servers/0`, …) | first two pointer segments (or one) | `component` | +| Whole external file | `schemas/pet.yaml` (cwd-relative; URLs as-is + `external`) | `file` | +| Component inside another file | `common.yaml#/components/schemas/Pet` (copy-pasteable as `$ref`) | `component` | + +Disambiguation is structural: path ids start with `/`, operation ids contain a space, file ids contain an extension or `#`. + +- Nested target pointers normalize to their top-level component (`#/components/schemas/Pet/properties/x` → `schemas/Pet`). +- Self-edges (recursive schemas) are kept → stylish renders `schemas/Pet ↺` as its own child. +- After building, the graph is pruned to nodes reachable from the root so all three formats agree. +- **Default mode processes exactly one API.** Multiple APIs (args or config fallback) → clear error suggesting a single API or `--files`. Rationale: path/component ids from different documents would collide and merge wrongly. + +## Structure builder + +New `build-structure.ts` + pure `node-id.ts`, using `walkDocument` exactly like `stats` (resolveDocument → normalizeVisitors → walkDocument): + +- `PathItem` / `Operation` enter hooks create the root → path → operation spine. They act only when `rawLocation` is in the root file AND the pointer is exactly `/paths/{p}` (2 segments) or `/paths/{p}/{method}` (3 segments, method ∈ get/put/post/delete/options/head/patch/trace/query/x-query). This stateless pointer check is immune to callback/webhook false positives. For AsyncAPI/Arazzo these visitor keys don't exist in the type map and are silently ignored — the command stays spec-agnostic. +- A `ref` enter hook fires at every `$ref` site. Owner = mapped site location; target = mapped resolved-target location. Edge owner→target collects distinct `$ref` strings (dedup as in files mode). Spine edges carry `refs: []`. +- Ownership mapping (`node-id.ts`): + - `#/paths/~1pets/get/...` → `GET /pets`; `#/paths/~1pets/parameters/0` → `/pets`; `#/components/schemas/User/properties/address` → `schemas/User`; callback sites map to the outer operation; other root-level sites → generic fallback node with a root spine edge. + - Site/target in a non-root file → file node, or `file#/components/...`-style component node when the pointer addresses a component section inside that file. +- Unresolved refs: target id derived from the raw `$ref` (same-file pointer → `mapRootPointer`; uri part → injected `resolveRef(siteFile, uri)`), node upserted `resolved: false` (`✗ not found` marker reused). `isAbsoluteUrl` targets → `external: true`. +- Deterministic output: same codepoint sorting as the files-mode builder. + +## `--affected-by` matching (default mode) + +Pure `match-affected-by.ts`; per input, first rule that matches wins: + +1. **Exact node id** (`schemas/Pet`, `/pets`, `GET /pets`, file ids, URLs). +2. **Pointer form** (starts with `#`): mapped via `mapRootPointer` (`#/components/schemas/Pet`; bonus: `#/paths/~1pets/get`). +3. **File path**: normalized cwd-relative; matches all nodes with `node.file === rel`. Passing the root file itself → the whole tree is affected: `changedIds` = all nodes, the `← changed` marker goes on the root only, stderr note ` is the root document — the whole tree is affected.` +4. **Bare component name** (`Pet`): all `kind: 'component'` nodes whose last `/`-segment equals it. Ambiguous → match ALL + stderr note listing the matches (impact analysis must over-report, not under-report). + +Unknown input → stderr warning (` does not match any path, operation, or component of .`), exit 0. Files mode keeps today's matching and warning text verbatim. + +## Renderer adjustments + +Only `print/stylish.ts` changes. `StylishOptions` becomes `{ changed?: string[]; summary?: string; emptyMessage?: string }` — the handler composes the summary: + +- Files mode: byte-identical summary to today (`N of M files affected · affected roots: ...`). +- Default mode: `N of M operations affected · affected paths: /pets, /users` (counted via `kind`), falling back to `N of M nodes affected` when the document has no operations (AsyncAPI/Arazzo). Empty result: `No nodes affected.` (files mode keeps `No files affected.`). + +`json.ts` / `mermaid.ts` unchanged (`kind`/`file` appear additively in json; mermaid labels are quoted so spaces in `GET /pets` are safe). + +## CLI surface + +```bash +redocly tree [api] # default: internal structure (exactly one API) +redocly tree --files [apis...] # file-level $ref graph (multi-API supported, today's behavior) +redocly tree --format +redocly tree --affected-by [--affected-by ] # repeat per input +redocly tree --config +``` + +- Command name `tree`, description `Display the structure of an API description as a tree.`, env prefix `REDOCLY_CLI_TREE`. +- `TreeArgv` replaces `GraphArgv` in the `CommandArgv` union; `TreeFormat` replaces `GraphFormat` (same values — the `lint.ts` mermaid guard is untouched). +- Exit codes unchanged: 0 success (incl. "affects nothing"), 1 execution error (incl. multi-API in default mode), 2 config error. + +## Error handling + +| Situation | Behavior | +| ------------------------------------- | ----------------------------------------------------------- | +| Multiple APIs in default mode | `exitWithError`: pass a single API or use `--files`, exit 1 | +| Root missing/unparseable | unchanged (clear error, exit 1) | +| Broken `$ref` | `resolved: false` node, exit 0 | +| `--affected-by` input matches nothing | stderr warning, exit 0 | +| Root file passed to `--affected-by` | full tree + root marked, stderr note, exit 0 | + +## Testing + +- Unit: `node-id.ts` (~10 mapping cases incl. `~1`/`~0` escaping, OAS2 sections, foreign files), `build-structure.ts` (~12 cases via the score test-harness pattern: spine enumeration, op→component edges, transitive chains, nested-pointer normalization, path-level params, self-edges, callback attribution, webhook fallback, unresolved, external URL, pruning, OAS2), `match-affected-by.ts` (~7 cases), adapted stylish tests. Existing `build-graph` (6) and `filter-affected` (5) tests survive unchanged. +- E2E: new primary single-file fixture (paths + component chains + fan-in + path-level param + pruned orphan): default stylish, json, pointer input, bare-name input, unknown input; multi-file fixture in default mode (cross-file blend) and with a file input (headline AI-review case); the 4 existing files-mode tests kept with **unchanged snapshot content** (regression guard). + +## Documentation & release + +- `docs/@v2/commands/graph.md` → `tree.md`, rewritten: structure mode first, `--files` section, all `--affected-by` input forms, markers legend, non-OpenAPI note, ref'd-path-item-file limitation. +- Sidebar entry moves to after `translate` (alphabetical); commands index line updated. +- `.changeset/graph-command.md` rewritten in place (still `'@redocly/cli': minor` — the command was never released). From 5cbdc4834eab9f54a12ec20d5bc9209b49f8e9c6 Mon Sep 17 00:00:00 2001 From: kanoru Date: Fri, 12 Jun 2026 19:14:34 +0300 Subject: [PATCH 14/79] refactor: rename graph command to tree --- .../__tests__/build-graph.test.ts | 0 .../__tests__/filter-affected.test.ts | 0 .../{graph => tree}/__tests__/print.test.ts | 0 .../commands/{graph => tree}/build-graph.ts | 0 .../{graph => tree}/filter-affected.ts | 0 .../cli/src/commands/{graph => tree}/index.ts | 10 +++---- .../commands/{graph => tree}/print/json.ts | 0 .../commands/{graph => tree}/print/mermaid.ts | 0 .../commands/{graph => tree}/print/stylish.ts | 0 .../cli/src/commands/{graph => tree}/types.ts | 2 +- packages/cli/src/index.ts | 16 +++++------ packages/cli/src/types.ts | 4 +-- .../snapshot.txt | 0 .../tree-files-affected-by}/snapshot.txt | 0 .../tree-files-json}/snapshot.txt | 0 .../tree-files-stylish}/snapshot.txt | 0 .../components/schemas/Address.yaml | 0 .../components/schemas/Pet.yaml | 0 .../components/schemas/User.yaml | 0 .../tree-multi-file}/openapi.yaml | 0 .../tree-multi-file}/paths/pets.yaml | 0 .../tree-multi-file}/paths/users.yaml | 0 .../graph.test.ts => tree/tree.test.ts} | 28 +++++++++---------- 23 files changed, 30 insertions(+), 30 deletions(-) rename packages/cli/src/commands/{graph => tree}/__tests__/build-graph.test.ts (100%) rename packages/cli/src/commands/{graph => tree}/__tests__/filter-affected.test.ts (100%) rename packages/cli/src/commands/{graph => tree}/__tests__/print.test.ts (100%) rename packages/cli/src/commands/{graph => tree}/build-graph.ts (100%) rename packages/cli/src/commands/{graph => tree}/filter-affected.ts (100%) rename packages/cli/src/commands/{graph => tree}/index.ts (92%) rename packages/cli/src/commands/{graph => tree}/print/json.ts (100%) rename packages/cli/src/commands/{graph => tree}/print/mermaid.ts (100%) rename packages/cli/src/commands/{graph => tree}/print/stylish.ts (100%) rename packages/cli/src/commands/{graph => tree}/types.ts (90%) rename tests/e2e/{graph/graph-affected-by-unknown => tree/tree-files-affected-by-unknown}/snapshot.txt (100%) rename tests/e2e/{graph/graph-affected-by => tree/tree-files-affected-by}/snapshot.txt (100%) rename tests/e2e/{graph/graph-json => tree/tree-files-json}/snapshot.txt (100%) rename tests/e2e/{graph/graph-stylish => tree/tree-files-stylish}/snapshot.txt (100%) rename tests/e2e/{graph/graph-multi-file => tree/tree-multi-file}/components/schemas/Address.yaml (100%) rename tests/e2e/{graph/graph-multi-file => tree/tree-multi-file}/components/schemas/Pet.yaml (100%) rename tests/e2e/{graph/graph-multi-file => tree/tree-multi-file}/components/schemas/User.yaml (100%) rename tests/e2e/{graph/graph-multi-file => tree/tree-multi-file}/openapi.yaml (100%) rename tests/e2e/{graph/graph-multi-file => tree/tree-multi-file}/paths/pets.yaml (100%) rename tests/e2e/{graph/graph-multi-file => tree/tree-multi-file}/paths/users.yaml (100%) rename tests/e2e/{graph/graph.test.ts => tree/tree.test.ts} (60%) diff --git a/packages/cli/src/commands/graph/__tests__/build-graph.test.ts b/packages/cli/src/commands/tree/__tests__/build-graph.test.ts similarity index 100% rename from packages/cli/src/commands/graph/__tests__/build-graph.test.ts rename to packages/cli/src/commands/tree/__tests__/build-graph.test.ts diff --git a/packages/cli/src/commands/graph/__tests__/filter-affected.test.ts b/packages/cli/src/commands/tree/__tests__/filter-affected.test.ts similarity index 100% rename from packages/cli/src/commands/graph/__tests__/filter-affected.test.ts rename to packages/cli/src/commands/tree/__tests__/filter-affected.test.ts diff --git a/packages/cli/src/commands/graph/__tests__/print.test.ts b/packages/cli/src/commands/tree/__tests__/print.test.ts similarity index 100% rename from packages/cli/src/commands/graph/__tests__/print.test.ts rename to packages/cli/src/commands/tree/__tests__/print.test.ts diff --git a/packages/cli/src/commands/graph/build-graph.ts b/packages/cli/src/commands/tree/build-graph.ts similarity index 100% rename from packages/cli/src/commands/graph/build-graph.ts rename to packages/cli/src/commands/tree/build-graph.ts diff --git a/packages/cli/src/commands/graph/filter-affected.ts b/packages/cli/src/commands/tree/filter-affected.ts similarity index 100% rename from packages/cli/src/commands/graph/filter-affected.ts rename to packages/cli/src/commands/tree/filter-affected.ts diff --git a/packages/cli/src/commands/graph/index.ts b/packages/cli/src/commands/tree/index.ts similarity index 92% rename from packages/cli/src/commands/graph/index.ts rename to packages/cli/src/commands/tree/index.ts index 07fce12421..f608c1e14e 100644 --- a/packages/cli/src/commands/graph/index.ts +++ b/packages/cli/src/commands/tree/index.ts @@ -20,16 +20,16 @@ import { filterAffected } from './filter-affected.js'; import { renderJson } from './print/json.js'; import { renderMermaid } from './print/mermaid.js'; import { renderStylish, type StylishOptions } from './print/stylish.js'; -import type { GraphFormat } from './types.js'; +import type { TreeFormat } from './types.js'; -export type GraphArgv = { +export type TreeArgv = { apis?: string[]; - format: GraphFormat; + format: TreeFormat; 'affected-by'?: string[]; } & VerifyConfigOptions; -/** Resolves the given API descriptions and prints their file-level $ref dependency graph. */ -export async function handleGraph({ argv, config, collectSpecData }: CommandArgs) { +/** Resolves the given API descriptions and prints their file-level $ref dependency tree. */ +export async function handleTree({ argv, config, collectSpecData }: CommandArgs) { const apis = await getFallbackApisOrExit(argv.apis, config); const externalRefResolver = new BaseResolver(config.resolve); const cwd = process.cwd(); diff --git a/packages/cli/src/commands/graph/print/json.ts b/packages/cli/src/commands/tree/print/json.ts similarity index 100% rename from packages/cli/src/commands/graph/print/json.ts rename to packages/cli/src/commands/tree/print/json.ts diff --git a/packages/cli/src/commands/graph/print/mermaid.ts b/packages/cli/src/commands/tree/print/mermaid.ts similarity index 100% rename from packages/cli/src/commands/graph/print/mermaid.ts rename to packages/cli/src/commands/tree/print/mermaid.ts diff --git a/packages/cli/src/commands/graph/print/stylish.ts b/packages/cli/src/commands/tree/print/stylish.ts similarity index 100% rename from packages/cli/src/commands/graph/print/stylish.ts rename to packages/cli/src/commands/tree/print/stylish.ts diff --git a/packages/cli/src/commands/graph/types.ts b/packages/cli/src/commands/tree/types.ts similarity index 90% rename from packages/cli/src/commands/graph/types.ts rename to packages/cli/src/commands/tree/types.ts index 036b6c2e2b..80fa436ca4 100644 --- a/packages/cli/src/commands/graph/types.ts +++ b/packages/cli/src/commands/tree/types.ts @@ -1,4 +1,4 @@ -export type GraphFormat = 'stylish' | 'json' | 'mermaid'; +export type TreeFormat = 'stylish' | 'json' | 'mermaid'; export type GraphNode = { /** Path relative to cwd; http(s) refs keep the full URL. */ diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index b8f0f82dcd..81193bc0da 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -16,8 +16,6 @@ import { handleGenerateArazzo, type GenerateArazzoCommandArgv, } from './commands/generate-arazzo.js'; -import { handleGraph } from './commands/graph/index.js'; -import type { GraphFormat } from './commands/graph/types.js'; import { handleJoin } from './commands/join/index.js'; import { handleLint } from './commands/lint.js'; import { PRODUCT_PLANS } from './commands/preview-project/constants.js'; @@ -33,6 +31,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'; @@ -77,11 +77,11 @@ yargs(hideBin(process.argv)) } ) .command( - 'graph [apis...]', - 'Show the $ref dependency graph of API description files.', + 'tree [apis...]', + 'Display the structure of an API description as a tree.', (yargs) => yargs - .env('REDOCLY_CLI_GRAPH') + .env('REDOCLY_CLI_TREE') .positional('apis', { array: true, type: 'string' }) .option({ config: { description: 'Path to the config file.', type: 'string' }, @@ -92,8 +92,8 @@ yargs(hideBin(process.argv)) }, format: { description: 'Use a specific output format.', - choices: ['stylish', 'json', 'mermaid'] as ReadonlyArray, - default: 'stylish' as GraphFormat, + choices: ['stylish', 'json', 'mermaid'] as ReadonlyArray, + default: 'stylish' as TreeFormat, }, 'affected-by': { description: 'Show only the part of the graph affected by changes to the given files.', @@ -103,7 +103,7 @@ yargs(hideBin(process.argv)) }, }), (argv) => { - commandWrapper(handleGraph)(argv); + commandWrapper(handleTree)(argv); } ) .command( diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index 87b4e3026c..7e225d173b 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -5,7 +5,6 @@ import type { BuildDocsArgv } from './commands/build-docs/types.js'; import type { BundleArgv } from './commands/bundle.js'; import type { EjectArgv } from './commands/eject.js'; import type { GenerateArazzoCommandArgv } from './commands/generate-arazzo.js'; -import type { GraphArgv } from './commands/graph/index.js'; import type { JoinArgv } from './commands/join/types.js'; import type { LintArgv } from './commands/lint.js'; import type { PreviewProjectArgv } from './commands/preview-project/types.js'; @@ -13,6 +12,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'; @@ -30,7 +30,7 @@ export const outputExtensions = ['json', 'yaml', 'yml'] as const; export type OutputExtension = (typeof outputExtensions)[number]; export type CommandArgv = | StatsArgv - | GraphArgv + | TreeArgv | SplitArgv | JoinArgv | LintArgv diff --git a/tests/e2e/graph/graph-affected-by-unknown/snapshot.txt b/tests/e2e/tree/tree-files-affected-by-unknown/snapshot.txt similarity index 100% rename from tests/e2e/graph/graph-affected-by-unknown/snapshot.txt rename to tests/e2e/tree/tree-files-affected-by-unknown/snapshot.txt diff --git a/tests/e2e/graph/graph-affected-by/snapshot.txt b/tests/e2e/tree/tree-files-affected-by/snapshot.txt similarity index 100% rename from tests/e2e/graph/graph-affected-by/snapshot.txt rename to tests/e2e/tree/tree-files-affected-by/snapshot.txt diff --git a/tests/e2e/graph/graph-json/snapshot.txt b/tests/e2e/tree/tree-files-json/snapshot.txt similarity index 100% rename from tests/e2e/graph/graph-json/snapshot.txt rename to tests/e2e/tree/tree-files-json/snapshot.txt diff --git a/tests/e2e/graph/graph-stylish/snapshot.txt b/tests/e2e/tree/tree-files-stylish/snapshot.txt similarity index 100% rename from tests/e2e/graph/graph-stylish/snapshot.txt rename to tests/e2e/tree/tree-files-stylish/snapshot.txt diff --git a/tests/e2e/graph/graph-multi-file/components/schemas/Address.yaml b/tests/e2e/tree/tree-multi-file/components/schemas/Address.yaml similarity index 100% rename from tests/e2e/graph/graph-multi-file/components/schemas/Address.yaml rename to tests/e2e/tree/tree-multi-file/components/schemas/Address.yaml diff --git a/tests/e2e/graph/graph-multi-file/components/schemas/Pet.yaml b/tests/e2e/tree/tree-multi-file/components/schemas/Pet.yaml similarity index 100% rename from tests/e2e/graph/graph-multi-file/components/schemas/Pet.yaml rename to tests/e2e/tree/tree-multi-file/components/schemas/Pet.yaml diff --git a/tests/e2e/graph/graph-multi-file/components/schemas/User.yaml b/tests/e2e/tree/tree-multi-file/components/schemas/User.yaml similarity index 100% rename from tests/e2e/graph/graph-multi-file/components/schemas/User.yaml rename to tests/e2e/tree/tree-multi-file/components/schemas/User.yaml diff --git a/tests/e2e/graph/graph-multi-file/openapi.yaml b/tests/e2e/tree/tree-multi-file/openapi.yaml similarity index 100% rename from tests/e2e/graph/graph-multi-file/openapi.yaml rename to tests/e2e/tree/tree-multi-file/openapi.yaml diff --git a/tests/e2e/graph/graph-multi-file/paths/pets.yaml b/tests/e2e/tree/tree-multi-file/paths/pets.yaml similarity index 100% rename from tests/e2e/graph/graph-multi-file/paths/pets.yaml rename to tests/e2e/tree/tree-multi-file/paths/pets.yaml diff --git a/tests/e2e/graph/graph-multi-file/paths/users.yaml b/tests/e2e/tree/tree-multi-file/paths/users.yaml similarity index 100% rename from tests/e2e/graph/graph-multi-file/paths/users.yaml rename to tests/e2e/tree/tree-multi-file/paths/users.yaml diff --git a/tests/e2e/graph/graph.test.ts b/tests/e2e/tree/tree.test.ts similarity index 60% rename from tests/e2e/graph/graph.test.ts rename to tests/e2e/tree/tree.test.ts index bfbd4739ac..e0494f16f1 100644 --- a/tests/e2e/graph/graph.test.ts +++ b/tests/e2e/tree/tree.test.ts @@ -6,49 +6,49 @@ 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('graph', () => { +describe('tree', () => { const folderPath = __dirname; - const fixturePath = join(folderPath, 'graph-multi-file'); + const fixturePath = join(folderPath, 'tree-multi-file'); - test('graph should print a stylish tree', async () => { - const args = getParams(indexEntryPoint, ['graph', 'openapi.yaml']); + test('tree should print a stylish tree', async () => { + const args = getParams(indexEntryPoint, ['tree', 'openapi.yaml']); const result = getCommandOutput(args, { testPath: fixturePath }); await expect(cleanupOutput(result)).toMatchFileSnapshot( - join(folderPath, 'graph-stylish', 'snapshot.txt') + join(folderPath, 'tree-files-stylish', 'snapshot.txt') ); }); - test('graph should print pure JSON', async () => { - const args = getParams(indexEntryPoint, ['graph', 'openapi.yaml', '--format=json']); + test('tree should print pure JSON', async () => { + const args = getParams(indexEntryPoint, ['tree', 'openapi.yaml', '--format=json']); const result = getCommandOutput(args, { testPath: fixturePath }); await expect(cleanupOutput(result)).toMatchFileSnapshot( - join(folderPath, 'graph-json', 'snapshot.txt') + join(folderPath, 'tree-files-json', 'snapshot.txt') ); }); - test('graph should print only the affected subgraph', async () => { + test('tree should print only the affected subgraph', async () => { const args = getParams(indexEntryPoint, [ - 'graph', + 'tree', 'openapi.yaml', '--affected-by', 'components/schemas/Address.yaml', ]); const result = getCommandOutput(args, { testPath: fixturePath }); await expect(cleanupOutput(result)).toMatchFileSnapshot( - join(folderPath, 'graph-affected-by', 'snapshot.txt') + join(folderPath, 'tree-files-affected-by', 'snapshot.txt') ); }); - test('graph should warn when the affected-by file is not in the graph', async () => { + test('tree should warn when the affected-by file is not in the graph', async () => { const args = getParams(indexEntryPoint, [ - 'graph', + 'tree', 'openapi.yaml', '--affected-by', 'components/schemas/Unknown.yaml', ]); const result = getCommandOutput(args, { testPath: fixturePath }); await expect(cleanupOutput(result)).toMatchFileSnapshot( - join(folderPath, 'graph-affected-by-unknown', 'snapshot.txt') + join(folderPath, 'tree-files-affected-by-unknown', 'snapshot.txt') ); }); }); From 643906da517c6479ee7efca7e2c8c9b14a5bbdd3 Mon Sep 17 00:00:00 2001 From: kanoru Date: Fri, 12 Jun 2026 19:20:05 +0300 Subject: [PATCH 15/79] feat: add pointer-to-node mapping for the tree structure view --- .../commands/tree/__tests__/node-id.test.ts | 139 ++++++++++++++++++ packages/cli/src/commands/tree/node-id.ts | 77 ++++++++++ packages/cli/src/commands/tree/types.ts | 2 + 3 files changed, 218 insertions(+) create mode 100644 packages/cli/src/commands/tree/__tests__/node-id.test.ts create mode 100644 packages/cli/src/commands/tree/node-id.ts diff --git a/packages/cli/src/commands/tree/__tests__/node-id.test.ts b/packages/cli/src/commands/tree/__tests__/node-id.test.ts new file mode 100644 index 0000000000..d8c2083e46 --- /dev/null +++ b/packages/cli/src/commands/tree/__tests__/node-id.test.ts @@ -0,0 +1,139 @@ +import { mapForeignLocation, mapRootPointer, parsePointerSegments } from '../node-id.js'; + +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', + }); + }); +}); 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..d43f2959b6 --- /dev/null +++ b/packages/cli/src/commands/tree/node-id.ts @@ -0,0 +1,77 @@ +import { escapePointerFragment, unescapePointerFragment } from '@redocly/openapi-core'; + +import type { NodeKind } from './types.js'; + +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[]; +}; + +/** Splits a JSON pointer like '#/paths/~1pets/get' into unescaped segments: ['paths', '/pets', 'get']. */ +export function parsePointerSegments(pointer: string): string[] { + return pointer + .replace(/^#?\/?/, '') + .split('/') + .filter(Boolean) + .map(unescapePointerFragment); +} + +/** Maps a pointer within the root document to the tree node that owns it. */ +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 a non-root file to a component inside it or to the whole file. */ +export function mapForeignLocation(fileId: string, pointer: string): MappedNode & { file: string } { + const segments = parsePointerSegments(pointer); + const componentDepth = + segments[0] === 'components' ? 3 : OAS2_COMPONENT_SECTIONS.has(segments[0]) ? 2 : 0; + if (componentDepth > 0 && segments.length >= componentDepth) { + const canonical = segments.slice(0, componentDepth).map(escapePointerFragment).join('/'); + return { id: `${fileId}#/${canonical}`, kind: 'component', file: fileId }; + } + return { id: fileId, kind: 'file', file: fileId }; +} diff --git a/packages/cli/src/commands/tree/types.ts b/packages/cli/src/commands/tree/types.ts index 80fa436ca4..b8d4b5817e 100644 --- a/packages/cli/src/commands/tree/types.ts +++ b/packages/cli/src/commands/tree/types.ts @@ -1,5 +1,7 @@ export type TreeFormat = 'stylish' | 'json' | 'mermaid'; +export type NodeKind = 'root' | 'path' | 'operation' | 'component' | 'file'; + export type GraphNode = { /** Path relative to cwd; http(s) refs keep the full URL. */ id: string; From 0fd392eaff31233ae62d021b8379436de3fb54ae Mon Sep 17 00:00:00 2001 From: kanoru Date: Fri, 12 Jun 2026 19:57:53 +0300 Subject: [PATCH 16/79] feat: add internal-structure builder for the tree command --- .../tree/__tests__/build-structure.test.ts | 500 ++++++++++++++++++ .../cli/src/commands/tree/build-structure.ts | 234 ++++++++ packages/cli/src/commands/tree/types.ts | 4 + 3 files changed, 738 insertions(+) create mode 100644 packages/cli/src/commands/tree/__tests__/build-structure.test.ts create mode 100644 packages/cli/src/commands/tree/build-structure.ts 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..87f55bbf5b --- /dev/null +++ b/packages/cli/src/commands/tree/__tests__/build-structure.test.ts @@ -0,0 +1,500 @@ +import { + BaseResolver, + detectSpec, + getTypes, + normalizeTypes, + resolveDocument, + Source, + type Document, + type ResolvedRefMap, + type WalkContext, +} from '@redocly/openapi-core'; +import * as path from 'node:path'; + +import { buildStructure } from '../build-structure.js'; +import type { DependencyGraph } from '../types.js'; + +const CWD = '/project'; +const ROOT_ABS = '/project/openapi.yaml'; + +/** The value type stored in a ResolvedRefMap (not exported from the barrel, so derived here). */ +type ResolvedRef = ResolvedRefMap extends Map ? V : never; + +/** Resolves and builds the structure graph for a parsed root document, like the real command does. */ +async function structureOf( + parsed: Record, + options?: { + mutateRefMap?: (refMap: ResolvedRefMap) => void; + externalRefResolver?: BaseResolver; + } +): Promise { + const document = { source: new Source(ROOT_ABS, ''), parsed } as Document; + const specVersion = detectSpec(parsed); + const types = normalizeTypes(getTypes(specVersion), {}); + const externalRefResolver = options?.externalRefResolver ?? new BaseResolver(); + const resolvedRefMap = await resolveDocument({ + rootDocument: document, + rootType: types.Root, + externalRefResolver, + }); + options?.mutateRefMap?.(resolvedRefMap); + const ctx = { problems: [], specVersion, visitorsData: {} } as unknown as WalkContext; + return buildStructure({ + document, + types, + resolvedRefMap, + ctx, + cwd: CWD, + resolveRef: (base, uri) => path.resolve(path.dirname(base), uri), + }); +} + +/** Returns the refs of the edge from `from` to `to`, or undefined when the edge is absent. */ +function edgeRefs(graph: DependencyGraph, from: string, to: string): string[] | undefined { + return graph.edges.find((edge) => edge.from === from && edge.to === to)?.refs; +} + +describe('buildStructure', () => { + 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 } } }, + }, + }, + }, + }, + }, + }, + { + externalRefResolver: new OfflineResolver(), + mutateRefMap: (refMap) => { + // Make the resolution deterministic regardless of how the offline resolver populated it. + refMap.set(ROOT_ABS + '::' + URL_REF, { + resolved: true, + isRemote: true, + node: {}, + nodePointer: '#/components/schemas/S', + document: { source: new Source('https://example.com/shared.yaml', ''), parsed: {} }, + } as ResolvedRef); + }, + } + ); + + 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', + ]); + }); +}); diff --git a/packages/cli/src/commands/tree/build-structure.ts b/packages/cli/src/commands/tree/build-structure.ts new file mode 100644 index 0000000000..5b22cff6eb --- /dev/null +++ b/packages/cli/src/commands/tree/build-structure.ts @@ -0,0 +1,234 @@ +import { + isAbsoluteUrl, + normalizeVisitors, + slash, + walkDocument, + type Document, + type Location, + type NormalizedNodeType, + type Oas3Visitor, + type ResolvedRefMap, + type WalkContext, +} from '@redocly/openapi-core'; +import * as path from 'node:path'; + +import { + mapForeignLocation, + mapRootPointer, + parsePointerSegments, + type MappedNode, +} from './node-id.js'; +import type { DependencyGraph, GraphEdge, GraphNode } from './types.js'; + +const OPERATION_METHODS = new Set([ + 'get', + 'put', + 'post', + 'delete', + 'options', + 'head', + 'patch', + 'trace', + 'query', + 'x-query', +]); + +/** + * Builds the internal structure graph of one API description: root -> paths -> operations and the + * component dependency chains reached through every `$ref`. The result is pruned to nodes reachable + * from the root and sorted by codepoint so all three renderers agree byte-for-byte. + */ +export function buildStructure(options: { + document: Document; + types: Record; + resolvedRefMap: ResolvedRefMap; + ctx: WalkContext; + cwd: string; + resolveRef: (base: string, uri: string) => string; +}): DependencyGraph { + const { document, types, resolvedRefMap, ctx, cwd, resolveRef } = options; + + const rootAbs = document.source.absoluteRef; + const rootId = isAbsoluteUrl(rootAbs) ? rootAbs : slash(path.relative(cwd, rootAbs)); + + const nodes = new Map(); + const edges = new Map(); + + /** + * Adds or updates a node. `resolved` is OR-ed; `kind`/`file` take the latest mapping — + * distinct mappings of one id are expected to agree (a component literally named like a + * sibling file path is the known, accepted exception: last writer in document order wins). + */ + const upsertNode = (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); + }; + + /** Adds (or extends) a directed edge, deduping by `from -> to` and collecting distinct refs. */ + 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); + }; + + /** Converts a non-root file's absolute ref into its node id (URLs as-is, else cwd-relative). */ + const toFileId = (absoluteRef: string): string => + isAbsoluteUrl(absoluteRef) ? absoluteRef : slash(path.relative(cwd, absoluteRef)); + + /** + * Materializes the node for a resolved Location and, when the mapping carries an ancestry, + * wires the structural spine `root -> ancestry[0] -> ... -> node` (spine edges carry no refs). + * Returns the node id so callers can attach `$ref` edges to it. + */ + const nodeFor = (location: Location): string => { + const inRootFile = location.source.absoluteRef === rootAbs; + const mapped: MappedNode & { file: string } = inRootFile + ? { ...mapRootPointer(location.pointer, rootId), file: rootId } + : mapForeignLocation(toFileId(location.source.absoluteRef), location.pointer); + + upsertNode(mapped, true); + wireSpine(mapped); + return mapped.id; + }; + + /** Wires root -> ...ancestry -> node spine edges when the mapping requests a structural link. */ + const wireSpine = (mapped: MappedNode) => { + if (mapped.ancestry === undefined) return; + let previous = rootId; + for (const ancestorId of mapped.ancestry) { + upsertNode({ id: ancestorId, kind: 'path', file: rootId }, true); + addEdge(previous, ancestorId); + previous = ancestorId; + } + addEdge(previous, mapped.id); + }; + + /** + * Derives the target id for an unresolved `$ref` from its raw string: a same-file fragment maps + * through the root/foreign pointer mappers; a uri part resolves against the ref site's file. + * The node is upserted as unresolved (and external for URLs). + */ + const unresolvedTargetId = (siteLocation: Location, refString: string): string => { + const hashIndex = refString.indexOf('#'); + const uri = hashIndex === -1 ? refString : refString.slice(0, hashIndex); + const fragment = hashIndex === -1 ? undefined : refString.slice(hashIndex + 1); + const siteFile = siteLocation.source.absoluteRef; + + let mapped: MappedNode & { file: string }; + if (uri === '') { + const pointer = '#' + (fragment ?? '/'); + mapped = + siteFile === rootAbs + ? { ...mapRootPointer(pointer, rootId), file: rootId } + : mapForeignLocation(toFileId(siteFile), pointer); + } else { + const fileId = toFileId(resolveRef(siteFile, uri)); + mapped = + fragment !== undefined + ? mapForeignLocation(fileId, '#' + fragment) + : { id: fileId, kind: 'file', file: fileId }; + } + + upsertNode(mapped, false); + return mapped.id; + }; + + // PathItem/Operation build the spine; the ref hook wires every dependency edge. Keys absent from + // a non-OpenAPI type map (AsyncAPI/Arazzo) are silently ignored by normalizeVisitors. + const visitor: Oas3Visitor = { + PathItem: { + enter(_node, vctx) { + if (vctx.rawLocation.source.absoluteRef !== rootAbs) return; + const segments = parsePointerSegments(vctx.rawLocation.pointer); + if (segments.length === 2 && segments[0] === 'paths') { + nodeFor(vctx.rawLocation); + } + }, + }, + Operation: { + enter(_node, vctx) { + if (vctx.rawLocation.source.absoluteRef !== rootAbs) return; + const segments = parsePointerSegments(vctx.rawLocation.pointer); + if ( + segments.length === 3 && + segments[0] === 'paths' && + OPERATION_METHODS.has(segments[2]) + ) { + nodeFor(vctx.rawLocation); + } + }, + }, + ref: { + enter(refNode, vctx, resolved) { + const ownerId = nodeFor(vctx.location); + const refString = String(refNode.$ref); + const targetId = resolved.location + ? nodeFor(resolved.location) + : unresolvedTargetId(vctx.location, refString); + addEdge(ownerId, targetId, refString); + }, + }, + }; + + // Root node: always present, marks the entry point of the structure. + upsertNode({ 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 prune(rootId, nodes, edges); +} + +/** + * Drops nodes unreachable from the root via directed BFS over the edges, then codepoint-sorts the + * nodes (id), edges (from, then to), and each edge's refs — the same comparator as build-graph.ts. + */ +function prune( + rootId: string, + nodes: Map, + edges: Map +): DependencyGraph { + const adjacency = new Map(); + for (const { from, to } of edges.values()) { + const targets = adjacency.get(from) ?? []; + targets.push(to); + adjacency.set(from, targets); + } + + const reachable = new Set([rootId]); + const queue = [rootId]; + while (queue.length > 0) { + const current = queue.shift()!; + for (const next of adjacency.get(current) ?? []) { + if (!reachable.has(next)) { + reachable.add(next); + queue.push(next); + } + } + } + + // Codepoint comparison (not localeCompare): deterministic across Node ICU builds → stable output. + const byString = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0); + + return { + roots: [rootId], + nodes: [...nodes.values()] + .filter((node) => reachable.has(node.id)) + .sort((a, b) => byString(a.id, b.id)), + edges: [...edges.values()] + .filter((edge) => reachable.has(edge.from) && reachable.has(edge.to)) + .map((edge) => ({ ...edge, refs: [...edge.refs].sort(byString) })) + .sort((a, b) => byString(a.from, b.from) || byString(a.to, b.to)), + }; +} diff --git a/packages/cli/src/commands/tree/types.ts b/packages/cli/src/commands/tree/types.ts index b8d4b5817e..cb97dd93c6 100644 --- a/packages/cli/src/commands/tree/types.ts +++ b/packages/cli/src/commands/tree/types.ts @@ -11,6 +11,10 @@ export type GraphNode = { 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; + /** Cwd-relative source file the node is defined in; absent in --files mode. */ + file?: string; }; export type GraphEdge = { From 6c9072f35902d016b1df45d7ee0170b0cfc351d8 Mon Sep 17 00:00:00 2001 From: kanoru Date: Fri, 12 Jun 2026 20:20:43 +0300 Subject: [PATCH 17/79] refactor: make stylish summary and empty message caller-provided --- .../src/commands/tree/__tests__/print.test.ts | 10 +++++++--- packages/cli/src/commands/tree/index.ts | 7 ++++++- packages/cli/src/commands/tree/print/stylish.ts | 16 +++++++--------- 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/commands/tree/__tests__/print.test.ts b/packages/cli/src/commands/tree/__tests__/print.test.ts index 7f30fe0864..24328e14af 100644 --- a/packages/cli/src/commands/tree/__tests__/print.test.ts +++ b/packages/cli/src/commands/tree/__tests__/print.test.ts @@ -62,8 +62,12 @@ describe('renderStylish', () => { ], }; - expect(renderStylish(affected, { changed: ['components/Pet.yaml'], totalNodeCount: 7 })) - .toMatchInlineSnapshot(` + expect( + renderStylish(affected, { + changed: ['components/Pet.yaml'], + summary: '5 of 7 files affected · affected roots: openapi.yaml', + }) + ).toMatchInlineSnapshot(` "openapi.yaml ├── paths/pets.yaml │ └── components/Pet.yaml ← changed @@ -77,7 +81,7 @@ describe('renderStylish', () => { it('reports when nothing is affected', () => { expect( - renderStylish({ roots: [], nodes: [], edges: [] }, { changed: [], totalNodeCount: 7 }) + renderStylish({ roots: [], nodes: [], edges: [] }, { changed: [] }) ).toMatchInlineSnapshot(`"No files affected."`); }); diff --git a/packages/cli/src/commands/tree/index.ts b/packages/cli/src/commands/tree/index.ts index f608c1e14e..e9c481e226 100644 --- a/packages/cli/src/commands/tree/index.ts +++ b/packages/cli/src/commands/tree/index.ts @@ -70,7 +70,12 @@ export async function handleTree({ argv, config, collectSpecData }: CommandArgs< } const knownChanged = changedIds.filter((id) => knownIds.has(id)); printedGraph = filterAffected(graph, knownChanged); - stylishOptions = { changed: knownChanged, totalNodeCount: graph.nodes.length }; + stylishOptions = { + changed: knownChanged, + summary: `${printedGraph.nodes.length} of ${graph.nodes.length} files affected · affected roots: ${ + printedGraph.roots.join(', ') || 'none' + }`, + }; } switch (argv.format) { diff --git a/packages/cli/src/commands/tree/print/stylish.ts b/packages/cli/src/commands/tree/print/stylish.ts index feaa0d3c68..20e0b6a317 100644 --- a/packages/cli/src/commands/tree/print/stylish.ts +++ b/packages/cli/src/commands/tree/print/stylish.ts @@ -3,8 +3,10 @@ import type { DependencyGraph } from '../types.js'; export type StylishOptions = { /** Node ids queried via --affected-by that exist in the graph. */ changed?: string[]; - /** Node count of the unfiltered graph; enables the affected summary line. */ - totalNodeCount?: number; + /** Pre-composed summary line; appended after a blank line when set. */ + summary?: string; + /** Message returned for an empty graph. */ + emptyMessage?: string; }; /** @@ -13,7 +15,7 @@ export type StylishOptions = { */ export function renderStylish(graph: DependencyGraph, options: StylishOptions = {}): string { if (graph.nodes.length === 0) { - return 'No files affected.'; + return options.emptyMessage ?? 'No files affected.'; } const childrenByNode = new Map(); @@ -61,13 +63,9 @@ export function renderStylish(graph: DependencyGraph, options: StylishOptions = renderSubtree(root, '', new Set([root])); }); - if (options.totalNodeCount !== undefined) { + if (options.summary !== undefined) { lines.push(''); - lines.push( - `${graph.nodes.length} of ${options.totalNodeCount} files affected · affected roots: ${ - graph.roots.join(', ') || 'none' - }` - ); + lines.push(options.summary); } return lines.join('\n'); From 9473d775ec87c117984c7ea8b62c2bdcbc859cb4 Mon Sep 17 00:00:00 2001 From: kanoru Date: Fri, 12 Jun 2026 20:28:57 +0300 Subject: [PATCH 18/79] feat: match affected-by inputs against tree nodes --- .../tree/__tests__/match-affected-by.test.ts | 198 ++++++++++++++++++ .../src/commands/tree/match-affected-by.ts | 103 +++++++++ 2 files changed, 301 insertions(+) create mode 100644 packages/cli/src/commands/tree/__tests__/match-affected-by.test.ts create mode 100644 packages/cli/src/commands/tree/match-affected-by.ts 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..c8c54d02b4 --- /dev/null +++ b/packages/cli/src/commands/tree/__tests__/match-affected-by.test.ts @@ -0,0 +1,198 @@ +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'], + markerIds: ['schemas/Address'], + notes: [], + warnings: [], + }); + }); + + it('case 2: exact id wins over bare-name logic — no ambiguity note', () => { + // 'schemas/Pet' is an exact id match; even though 'Pet' (bare) would match multiple, + // the exact match short-circuits and no ambiguity note is emitted. + expect(matchAffectedBy(graph, ['schemas/Pet'], { cwd: CWD, rootId: ROOT_ID })).toEqual({ + changedIds: ['schemas/Pet'], + markerIds: ['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'], + markerIds: ['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'], + markerIds: ['GET /pets'], + notes: [], + warnings: [], + }); + }); + + it('case 4a: file path resolves to a non-root file', () => { + expect(matchAffectedBy(graph, ['common.yaml'], { cwd: CWD, rootId: ROOT_ID })).toEqual({ + changedIds: ['common.yaml#/components/schemas/Pet'], + markerIds: ['common.yaml#/components/schemas/Pet'], + notes: [], + warnings: [], + }); + }); + + it('case 4b: file path with ./ prefix normalizes to same result', () => { + expect(matchAffectedBy(graph, ['./common.yaml'], { cwd: CWD, rootId: ROOT_ID })).toEqual({ + changedIds: ['common.yaml#/components/schemas/Pet'], + markerIds: ['common.yaml#/components/schemas/Pet'], + notes: [], + warnings: [], + }); + }); + + it('case 4c: absolute file path normalizes to same result', () => { + expect(matchAffectedBy(graph, ['/project/common.yaml'], { cwd: CWD, rootId: ROOT_ID })).toEqual( + { + changedIds: ['common.yaml#/components/schemas/Pet'], + markerIds: ['common.yaml#/components/schemas/Pet'], + notes: [], + warnings: [], + } + ); + }); + + it('case 5: root file — changedIds gets all ids, markerIds only rootId, note emitted', () => { + const result = matchAffectedBy(graph, ['openapi.yaml'], { cwd: CWD, rootId: ROOT_ID }); + + const allIds = graph.nodes.map((n) => n.id); + expect(result.changedIds).toEqual(allIds); + expect(result.markerIds).toEqual(['openapi.yaml']); + expect(result.warnings).toEqual([]); + 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 }); + + // All three Pet components in the graph + expect(result.changedIds).toEqual([ + 'common.yaml#/components/schemas/Pet', + 'parameters/Pet', + 'schemas/Pet', + ]); + expect(result.markerIds).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'], + markerIds: ['schemas/Address'], + notes: [], + warnings: [], + }); + }); + + it('case 7: unknown input — empty arrays + warning', () => { + expect(matchAffectedBy(graph, ['Ghost'], { cwd: CWD, rootId: ROOT_ID })).toEqual({ + changedIds: [], + markerIds: [], + 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.markerIds).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 }); + + // Pet (bare) matches 3 ids; schemas/Pet is already among them → deduped + expect(result.changedIds).toEqual([ + 'common.yaml#/components/schemas/Pet', + 'parameters/Pet', + 'schemas/Pet', + ]); + expect(result.markerIds).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: [], + markerIds: [], + 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: [], + markerIds: [], + notes: [], + warnings: ['pets does not match any path, operation, or component of openapi.yaml.'], + }); + }); +}); 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..f9ee5e4b95 --- /dev/null +++ b/packages/cli/src/commands/tree/match-affected-by.ts @@ -0,0 +1,103 @@ +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 = { + /** Node ids to seed the reverse-closure filter with. */ + changedIds: string[]; + /** Node ids that get the `← changed` marker in stylish output. */ + markerIds: string[]; + /** Informational stderr notes (root-file expansion, ambiguous matches). */ + notes: string[]; + /** Stderr warnings for inputs that matched nothing. */ + warnings: string[]; +}; + +/** Matches raw --affected-by inputs (node id, pointer, file path, or bare component name) against structure-graph nodes. */ +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 markerSet = new Set(); + const notes: string[] = []; + const warnings: string[] = []; + + /** Appends ids to changedSet and markerSet, preserving first-seen order. */ + function addIds(ids: string[]): void { + for (const id of ids) { + changedSet.add(id); + markerSet.add(id); + } + } + + for (const input of inputs) { + // Pre-compute the cwd-relative path for every input; used in Rules 1 and 3. + const rel = slash(path.relative(cwd, path.resolve(cwd, input))); + + // Rule 1: exact node id — but skip when the input also resolves to the root file, + // so that passing the root filename triggers Rule 3's whole-tree expansion instead. + if (nodeIds.has(input) && rel !== rootId) { + addIds([input]); + continue; + } + + // Rule 2: pointer form + if (input.startsWith('#')) { + const mapped = mapRootPointer(input, rootId); + if (nodeIds.has(mapped.id)) { + addIds([mapped.id]); + continue; + } + } + + // Rule 3: file path + if (rel === rootId) { + // Special: entire tree is affected + for (const node of graph.nodes) { + changedSet.add(node.id); + } + markerSet.add(rootId); + notes.push(`${rootId} is the root document — the whole tree is affected.`); + continue; + } + const fileMatches = graph.nodes.filter((n) => n.file === rel).map((n) => n.id); + if (fileMatches.length > 0) { + addIds(fileMatches); + continue; + } + + // Rule 4: bare component name (no '/', no '#') + 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) { + addIds(componentMatches); + if (componentMatches.length > 1) { + notes.push( + `"${input}" matches multiple components: ${componentMatches.join(', ')} — including all of them.` + ); + } + continue; + } + } + + // No rule matched + warnings.push(`${input} does not match any path, operation, or component of ${rootId}.`); + } + + return { + changedIds: Array.from(changedSet), + markerIds: Array.from(markerSet), + notes, + warnings, + }; +} From f9a14d58641d6d62d18793cb5bd0174cff387739 Mon Sep 17 00:00:00 2001 From: kanoru Date: Fri, 12 Jun 2026 20:40:41 +0300 Subject: [PATCH 19/79] feat: make document structure the default tree view behind --files fallback --- packages/cli/src/commands/tree/index.ts | 195 +++++++++++++++++++++--- packages/cli/src/index.ts | 8 +- tests/e2e/tree/tree.test.ts | 6 +- 3 files changed, 189 insertions(+), 20 deletions(-) diff --git a/packages/cli/src/commands/tree/index.ts b/packages/cli/src/commands/tree/index.ts index e9c481e226..c24763a2d1 100644 --- a/packages/cli/src/commands/tree/index.ts +++ b/packages/cli/src/commands/tree/index.ts @@ -6,8 +6,12 @@ import { normalizeTypes, resolveDocument, slash, + type CollectFn, type Document, + type NormalizedNodeType, type ResolvedRefMap, + type SpecVersion, + type WalkContext, } from '@redocly/openapi-core'; import * as path from 'node:path'; @@ -16,39 +20,104 @@ 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 { buildStructure } from './build-structure.js'; import { filterAffected } from './filter-affected.js'; +import { matchAffectedBy } from './match-affected-by.js'; import { renderJson } from './print/json.js'; import { renderMermaid } from './print/mermaid.js'; import { renderStylish, type StylishOptions } from './print/stylish.js'; -import type { TreeFormat } from './types.js'; +import type { DependencyGraph, TreeFormat } from './types.js'; export type TreeArgv = { apis?: string[]; format: TreeFormat; 'affected-by'?: string[]; + files?: boolean; } & VerifyConfigOptions; -/** Resolves the given API descriptions and prints their file-level $ref dependency tree. */ +/** Resolves the given API descriptions and prints their dependency tree. */ export async function handleTree({ argv, config, collectSpecData }: CommandArgs) { const apis = await getFallbackApisOrExit(argv.apis, config); const externalRefResolver = new BaseResolver(config.resolve); const cwd = process.cwd(); + if (argv.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, + }); +} + +/** Loads and resolves one API description: parses the root, detects the spec, and resolves all refs. */ +async function resolveApi({ + apiPath, + config, + collectSpecData, + externalRefResolver, +}: { + apiPath: string; + config: CommandArgs['config']; + collectSpecData?: CollectFn; + externalRefResolver: BaseResolver; +}): Promise<{ + rootDocument: Document; + specVersion: SpecVersion; + types: Record; + refMap: ResolvedRefMap; +}> { + const rootDocument = await externalRefResolver.resolveDocument(null, apiPath, true); + if (rootDocument instanceof Error) { + return exitWithError(`Failed to load ${apiPath}: ${rootDocument.message}`); + } + collectSpecData?.(rootDocument.parsed); + const specVersion = detectSpec(rootDocument.parsed); + const types = normalizeTypes(config.extendTypes(getTypes(specVersion), specVersion), config); + const refMap = await resolveDocument({ + rootDocument, + rootType: types.Root, + externalRefResolver, + }); + return { rootDocument, specVersion, types, refMap }; +} + +/** Resolves all given APIs and prints their file-level $ref dependency graph. */ +async function handleFilesMode({ + apis, + argv, + config, + collectSpecData, + externalRefResolver, + cwd, +}: { + apis: Array<{ path: string }>; + argv: TreeArgv; + config: CommandArgs['config']; + collectSpecData: CommandArgs['collectSpecData']; + externalRefResolver: BaseResolver; + cwd: string; +}): Promise { const resolutions: Array<{ rootDocument: Document; refMap: ResolvedRefMap }> = []; for (const { path: apiPath } of apis) { - const rootDocument = await externalRefResolver.resolveDocument(null, apiPath, true); - if (rootDocument instanceof Error) { - return exitWithError(`Failed to load ${apiPath}: ${rootDocument.message}`); - } - collectSpecData?.(rootDocument.parsed); - const specVersion = detectSpec(rootDocument.parsed); - const types = normalizeTypes(config.extendTypes(getTypes(specVersion), specVersion), config); - const refMap = await resolveDocument({ - rootDocument: rootDocument, - rootType: types.Root, + const { rootDocument, refMap } = await resolveApi({ + apiPath, + config, + collectSpecData, externalRefResolver, }); - resolutions.push({ rootDocument: rootDocument, refMap }); + resolutions.push({ rootDocument, refMap }); } const graph = buildGraph(resolutions, { @@ -78,14 +147,106 @@ export async function handleTree({ argv, config, collectSpecData }: CommandArgs< }; } - switch (argv.format) { + renderOutput(printedGraph, argv.format, stylishOptions); +} + +/** Resolves a single API and prints its internal document structure tree. */ +async function handleStructureMode({ + api, + argv, + config, + collectSpecData, + externalRefResolver, + cwd, +}: { + api: { path: string }; + argv: TreeArgv; + config: CommandArgs['config']; + collectSpecData: CommandArgs['collectSpecData']; + externalRefResolver: BaseResolver; + cwd: string; +}): Promise { + const { + rootDocument, + specVersion, + types, + refMap: resolvedRefMap, + } = await resolveApi({ + apiPath: api.path, + config, + collectSpecData, + externalRefResolver, + }); + + const ctx: WalkContext = { + problems: [], + specVersion, + config, + visitorsData: {}, + }; + + const graph = buildStructure({ + document: rootDocument, + types, + resolvedRefMap, + ctx, + cwd, + resolveRef: (base, uri) => externalRefResolver.resolveExternalRef(base, uri), + }); + + const rootId = graph.roots[0]; + + let printedGraph = graph; + let stylishOptions: StylishOptions = {}; + + if (argv['affected-by']) { + const match = matchAffectedBy(graph, argv['affected-by'], { 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 = { + changed: match.markerIds, + summary, + emptyMessage: 'No nodes affected.', + }; + } + + renderOutput(printedGraph, argv.format, stylishOptions); +} + +/** Emits the graph in the requested format to logger.output. */ +function renderOutput( + graph: DependencyGraph, + format: TreeFormat, + stylishOptions: StylishOptions +): void { + switch (format) { case 'json': - logger.output(renderJson(printedGraph) + '\n'); + logger.output(renderJson(graph) + '\n'); break; case 'mermaid': - logger.output(renderMermaid(printedGraph) + '\n'); + logger.output(renderMermaid(graph) + '\n'); break; default: - logger.output(renderStylish(printedGraph, stylishOptions) + '\n'); + logger.output(renderStylish(graph, stylishOptions) + '\n'); } } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 81193bc0da..1a282681a4 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -96,11 +96,17 @@ yargs(hideBin(process.argv)) default: 'stylish' as TreeFormat, }, 'affected-by': { - description: 'Show only the part of the graph affected by changes to the given files.', + description: + 'Show only the part of the tree affected by changes to the given components, paths, or files.', 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); diff --git a/tests/e2e/tree/tree.test.ts b/tests/e2e/tree/tree.test.ts index e0494f16f1..4b05acbd72 100644 --- a/tests/e2e/tree/tree.test.ts +++ b/tests/e2e/tree/tree.test.ts @@ -11,7 +11,7 @@ describe('tree', () => { const fixturePath = join(folderPath, 'tree-multi-file'); test('tree should print a stylish tree', async () => { - const args = getParams(indexEntryPoint, ['tree', 'openapi.yaml']); + const args = getParams(indexEntryPoint, ['tree', 'openapi.yaml', '--files']); const result = getCommandOutput(args, { testPath: fixturePath }); await expect(cleanupOutput(result)).toMatchFileSnapshot( join(folderPath, 'tree-files-stylish', 'snapshot.txt') @@ -19,7 +19,7 @@ describe('tree', () => { }); test('tree should print pure JSON', async () => { - const args = getParams(indexEntryPoint, ['tree', 'openapi.yaml', '--format=json']); + const args = getParams(indexEntryPoint, ['tree', 'openapi.yaml', '--files', '--format=json']); const result = getCommandOutput(args, { testPath: fixturePath }); await expect(cleanupOutput(result)).toMatchFileSnapshot( join(folderPath, 'tree-files-json', 'snapshot.txt') @@ -30,6 +30,7 @@ describe('tree', () => { const args = getParams(indexEntryPoint, [ 'tree', 'openapi.yaml', + '--files', '--affected-by', 'components/schemas/Address.yaml', ]); @@ -43,6 +44,7 @@ describe('tree', () => { const args = getParams(indexEntryPoint, [ 'tree', 'openapi.yaml', + '--files', '--affected-by', 'components/schemas/Unknown.yaml', ]); From d6e2caad465fed6ca6d8c5e72a511dbc23ac90c4 Mon Sep 17 00:00:00 2001 From: kanoru Date: Fri, 12 Jun 2026 20:57:11 +0300 Subject: [PATCH 20/79] test: cover tree structure mode end to end --- tests/e2e/tree/tree-single-file/openapi.yaml | 83 ++++++++ .../tree-structure-affected-bare/snapshot.txt | 18 ++ .../tree-structure-affected-file/snapshot.txt | 8 + .../snapshot.txt | 18 ++ .../snapshot.txt | 3 + .../e2e/tree/tree-structure-json/snapshot.txt | 180 ++++++++++++++++++ .../tree-structure-multi-file/snapshot.txt | 10 + .../tree/tree-structure-stylish/snapshot.txt | 17 ++ tests/e2e/tree/tree.test.ts | 72 +++++++ 9 files changed, 409 insertions(+) create mode 100644 tests/e2e/tree/tree-single-file/openapi.yaml create mode 100644 tests/e2e/tree/tree-structure-affected-bare/snapshot.txt create mode 100644 tests/e2e/tree/tree-structure-affected-file/snapshot.txt create mode 100644 tests/e2e/tree/tree-structure-affected-pointer/snapshot.txt create mode 100644 tests/e2e/tree/tree-structure-affected-unknown/snapshot.txt create mode 100644 tests/e2e/tree/tree-structure-json/snapshot.txt create mode 100644 tests/e2e/tree/tree-structure-multi-file/snapshot.txt create mode 100644 tests/e2e/tree/tree-structure-stylish/snapshot.txt diff --git a/tests/e2e/tree/tree-single-file/openapi.yaml b/tests/e2e/tree/tree-single-file/openapi.yaml new file mode 100644 index 0000000000..c8a235bd6e --- /dev/null +++ b/tests/e2e/tree/tree-single-file/openapi.yaml @@ -0,0 +1,83 @@ +openapi: 3.0.0 +info: + title: Tree fixture + version: 1.0.0 +paths: + /pets: + get: + summary: List pets + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + post: + summary: Create pet + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PetInput' + responses: + '201': + description: Created + /pets/{petId}: + parameters: + - $ref: '#/components/parameters/PetId' + get: + summary: Get pet + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + /users: + get: + summary: List users + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/User' +components: + parameters: + PetId: + name: petId + in: path + required: true + schema: + type: string + schemas: + Address: + type: object + properties: + city: + type: string + Orphan: + type: object + properties: + unused: + type: boolean + Pet: + type: object + properties: + address: + $ref: '#/components/schemas/Address' + name: + type: string + PetInput: + type: object + properties: + pet: + $ref: '#/components/schemas/Pet' + User: + type: object + properties: + address: + $ref: '#/components/schemas/Address' diff --git a/tests/e2e/tree/tree-structure-affected-bare/snapshot.txt b/tests/e2e/tree/tree-structure-affected-bare/snapshot.txt new file mode 100644 index 0000000000..587cad89ef --- /dev/null +++ b/tests/e2e/tree/tree-structure-affected-bare/snapshot.txt @@ -0,0 +1,18 @@ +openapi.yaml +├── /pets +│ ├── GET /pets +│ │ └── schemas/Pet +│ │ └── schemas/Address ← changed +│ └── POST /pets +│ └── schemas/PetInput +│ └── schemas/Pet ↺ +├── /pets/{petId} +│ └── GET /pets/{petId} +│ └── schemas/Pet ↺ +└── /users + └── GET /users + └── schemas/User + └── schemas/Address ↺ ← changed + +4 of 4 operations affected · affected paths: /pets, /pets/{petId}, /users + diff --git a/tests/e2e/tree/tree-structure-affected-file/snapshot.txt b/tests/e2e/tree/tree-structure-affected-file/snapshot.txt new file mode 100644 index 0000000000..eac6655055 --- /dev/null +++ b/tests/e2e/tree/tree-structure-affected-file/snapshot.txt @@ -0,0 +1,8 @@ +openapi.yaml +└── /users + └── paths/users.yaml + └── components/schemas/User.yaml + └── components/schemas/Address.yaml ← changed + +5 of 8 nodes affected + diff --git a/tests/e2e/tree/tree-structure-affected-pointer/snapshot.txt b/tests/e2e/tree/tree-structure-affected-pointer/snapshot.txt new file mode 100644 index 0000000000..587cad89ef --- /dev/null +++ b/tests/e2e/tree/tree-structure-affected-pointer/snapshot.txt @@ -0,0 +1,18 @@ +openapi.yaml +├── /pets +│ ├── GET /pets +│ │ └── schemas/Pet +│ │ └── schemas/Address ← changed +│ └── POST /pets +│ └── schemas/PetInput +│ └── schemas/Pet ↺ +├── /pets/{petId} +│ └── GET /pets/{petId} +│ └── schemas/Pet ↺ +└── /users + └── GET /users + └── schemas/User + └── schemas/Address ↺ ← changed + +4 of 4 operations affected · affected paths: /pets, /pets/{petId}, /users + diff --git a/tests/e2e/tree/tree-structure-affected-unknown/snapshot.txt b/tests/e2e/tree/tree-structure-affected-unknown/snapshot.txt new file mode 100644 index 0000000000..746f562533 --- /dev/null +++ b/tests/e2e/tree/tree-structure-affected-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-json/snapshot.txt b/tests/e2e/tree/tree-structure-json/snapshot.txt new file mode 100644 index 0000000000..78ab43c5b4 --- /dev/null +++ b/tests/e2e/tree/tree-structure-json/snapshot.txt @@ -0,0 +1,180 @@ +{ + "roots": [ + "openapi.yaml" + ], + "nodes": [ + { + "id": "/pets", + "resolved": true, + "kind": "path", + "file": "openapi.yaml" + }, + { + "id": "/pets/{petId}", + "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 /pets/{petId}", + "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", + "resolved": true, + "kind": "root", + "file": "openapi.yaml", + "root": true + }, + { + "id": "parameters/PetId", + "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" + }, + { + "id": "schemas/PetInput", + "resolved": true, + "kind": "component", + "file": "openapi.yaml" + }, + { + "id": "schemas/User", + "resolved": true, + "kind": "component", + "file": "openapi.yaml" + } + ], + "edges": [ + { + "from": "/pets", + "to": "GET /pets", + "refs": [] + }, + { + "from": "/pets", + "to": "POST /pets", + "refs": [] + }, + { + "from": "/pets/{petId}", + "to": "GET /pets/{petId}", + "refs": [] + }, + { + "from": "/pets/{petId}", + "to": "parameters/PetId", + "refs": [ + "#/components/parameters/PetId" + ] + }, + { + "from": "/users", + "to": "GET /users", + "refs": [] + }, + { + "from": "GET /pets", + "to": "schemas/Pet", + "refs": [ + "#/components/schemas/Pet" + ] + }, + { + "from": "GET /pets/{petId}", + "to": "schemas/Pet", + "refs": [ + "#/components/schemas/Pet" + ] + }, + { + "from": "GET /users", + "to": "schemas/User", + "refs": [ + "#/components/schemas/User" + ] + }, + { + "from": "POST /pets", + "to": "schemas/PetInput", + "refs": [ + "#/components/schemas/PetInput" + ] + }, + { + "from": "openapi.yaml", + "to": "/pets", + "refs": [] + }, + { + "from": "openapi.yaml", + "to": "/pets/{petId}", + "refs": [] + }, + { + "from": "openapi.yaml", + "to": "/users", + "refs": [] + }, + { + "from": "schemas/Pet", + "to": "schemas/Address", + "refs": [ + "#/components/schemas/Address" + ] + }, + { + "from": "schemas/PetInput", + "to": "schemas/Pet", + "refs": [ + "#/components/schemas/Pet" + ] + }, + { + "from": "schemas/User", + "to": "schemas/Address", + "refs": [ + "#/components/schemas/Address" + ] + } + ] +} + diff --git a/tests/e2e/tree/tree-structure-multi-file/snapshot.txt b/tests/e2e/tree/tree-structure-multi-file/snapshot.txt new file mode 100644 index 0000000000..f87086433b --- /dev/null +++ b/tests/e2e/tree/tree-structure-multi-file/snapshot.txt @@ -0,0 +1,10 @@ +openapi.yaml +├── /pets +│ └── paths/pets.yaml +│ └── components/schemas/Pet.yaml +└── /users + └── paths/users.yaml + └── components/schemas/User.yaml + ├── components/schemas/Address.yaml + └── components/schemas/Pet.yaml ↺ + 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..517e593821 --- /dev/null +++ b/tests/e2e/tree/tree-structure-stylish/snapshot.txt @@ -0,0 +1,17 @@ +openapi.yaml +├── /pets +│ ├── GET /pets +│ │ └── schemas/Pet +│ │ └── schemas/Address +│ └── POST /pets +│ └── schemas/PetInput +│ └── schemas/Pet ↺ +├── /pets/{petId} +│ ├── GET /pets/{petId} +│ │ └── schemas/Pet ↺ +│ └── parameters/PetId +└── /users + └── GET /users + └── schemas/User + └── schemas/Address ↺ + diff --git a/tests/e2e/tree/tree.test.ts b/tests/e2e/tree/tree.test.ts index 4b05acbd72..ff1cb4e22f 100644 --- a/tests/e2e/tree/tree.test.ts +++ b/tests/e2e/tree/tree.test.ts @@ -9,6 +9,7 @@ const indexEntryPoint = join(process.cwd(), 'packages/cli/lib/index.js'); describe('tree', () => { const folderPath = __dirname; const fixturePath = join(folderPath, 'tree-multi-file'); + const singleFilePath = join(folderPath, 'tree-single-file'); test('tree should print a stylish tree', async () => { const args = getParams(indexEntryPoint, ['tree', 'openapi.yaml', '--files']); @@ -53,4 +54,75 @@ describe('tree', () => { join(folderPath, 'tree-files-affected-by-unknown', 'snapshot.txt') ); }); + + test('tree should print the document structure', async () => { + const args = getParams(indexEntryPoint, ['tree', 'openapi.yaml']); + const result = getCommandOutput(args, { testPath: singleFilePath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot( + join(folderPath, 'tree-structure-stylish', 'snapshot.txt') + ); + }); + + test('tree should print the document structure as JSON', async () => { + const args = getParams(indexEntryPoint, ['tree', 'openapi.yaml', '--format=json']); + const result = getCommandOutput(args, { testPath: singleFilePath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot( + join(folderPath, 'tree-structure-json', 'snapshot.txt') + ); + }); + + test('tree should show what a component pointer affects', async () => { + const args = getParams(indexEntryPoint, [ + 'tree', + 'openapi.yaml', + '--affected-by', + '#/components/schemas/Address', + ]); + const result = getCommandOutput(args, { testPath: singleFilePath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot( + join(folderPath, 'tree-structure-affected-pointer', 'snapshot.txt') + ); + }); + + test('tree should accept a bare component name', async () => { + const args = getParams(indexEntryPoint, ['tree', 'openapi.yaml', '--affected-by', 'Address']); + const result = getCommandOutput(args, { testPath: singleFilePath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot( + join(folderPath, 'tree-structure-affected-bare', 'snapshot.txt') + ); + }); + + test('tree should warn for an unknown affected-by input', async () => { + const args = getParams(indexEntryPoint, [ + 'tree', + 'openapi.yaml', + '--affected-by', + 'schemas/Unknown', + ]); + const result = getCommandOutput(args, { testPath: singleFilePath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot( + join(folderPath, 'tree-structure-affected-unknown', 'snapshot.txt') + ); + }); + + test('tree should blend cross-file structure in default mode', async () => { + const args = getParams(indexEntryPoint, ['tree', 'openapi.yaml']); + const result = getCommandOutput(args, { testPath: fixturePath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot( + join(folderPath, 'tree-structure-multi-file', 'snapshot.txt') + ); + }); + + test('tree should show what a changed file affects in default mode', async () => { + const args = getParams(indexEntryPoint, [ + 'tree', + 'openapi.yaml', + '--affected-by', + 'components/schemas/Address.yaml', + ]); + const result = getCommandOutput(args, { testPath: fixturePath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot( + join(folderPath, 'tree-structure-affected-file', 'snapshot.txt') + ); + }); }); From 8c7733760a8cee0e566c94d61e46c758919ed651 Mon Sep 17 00:00:00 2001 From: kanoru Date: Fri, 12 Jun 2026 21:04:49 +0300 Subject: [PATCH 21/79] docs: document the tree command and update the changeset --- .changeset/graph-command.md | 2 +- docs/@v2/commands/graph.md | 86 ---------------------- docs/@v2/commands/index.md | 2 +- docs/@v2/commands/tree.md | 143 ++++++++++++++++++++++++++++++++++++ docs/@v2/v2.sidebars.yaml | 4 +- 5 files changed, 147 insertions(+), 90 deletions(-) delete mode 100644 docs/@v2/commands/graph.md create mode 100644 docs/@v2/commands/tree.md diff --git a/.changeset/graph-command.md b/.changeset/graph-command.md index 837c338772..c64de4b80f 100644 --- a/.changeset/graph-command.md +++ b/.changeset/graph-command.md @@ -2,4 +2,4 @@ '@redocly/cli': minor --- -Added the `graph` command that prints the file-level `$ref` dependency graph of API descriptions as a tree (`stylish`), `json`, or `mermaid` output. The `--affected-by` option filters the graph to the files impacted by changes to the given files. +Added the `tree` command that displays the structure of an API description — paths, operations, and their component dependency chains — as `stylish` (tree), `json`, or `mermaid` output. The `--affected-by` option filters the tree to what is impacted by a change to a component, path, or file, and `--files` switches to the file-level `$ref` graph. diff --git a/docs/@v2/commands/graph.md b/docs/@v2/commands/graph.md deleted file mode 100644 index 253608c43f..0000000000 --- a/docs/@v2/commands/graph.md +++ /dev/null @@ -1,86 +0,0 @@ -# `graph` - -## Introduction - -The `graph` command prints the file-level dependency graph of an API description: which files reference which other files through `$ref`. It works with multi-file OpenAPI, AsyncAPI, and Arazzo descriptions. - -Use it to: - -- get a quick `tree`-style overview of a multi-file API description; -- find out which files are affected by a change to a shared file (`--affected-by`) — for example, in CI or automated code review; -- feed exact file relationships to tooling as JSON or render them as a Mermaid diagram. - -## Usage - -```bash -redocly graph -redocly graph -redocly graph [--format=] [--affected-by=] [--config=] -``` - -If you don't pass any API to the command, it processes all APIs defined in your Redocly configuration file and prints them as a single graph with shared files deduplicated — one tree per API root in the default view. - -## Options - -| Option | Type | Description | -| ------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| apis | [string] | Paths to API description files. Defaults to all APIs from the Redocly configuration file. | -| --affected-by | [string] | Show only the part of the graph affected by changes to the given files: the files themselves plus everything that references them. Repeat the option to pass several files: `--affected-by a.yaml --affected-by b.yaml`. | -| --config | string | Specify the path to the [Redocly configuration file](../configuration/index.md). | -| --format | string | Output format: `stylish` (default, tree view), `json`, or `mermaid`. | -| --help | boolean | Show help. | -| --lint-config | string | Specify the severity level for the configuration file.
**Possible values:** `warn`, `error`, `off`. Default value is `warn`. | -| --version | boolean | Show version number. | - -## Examples - -### Print the dependency tree - -```bash -redocly graph openapi.yaml -``` - -``` -openapi.yaml -├── paths/pets.yaml -│ └── components/schemas/Pet.yaml -└── paths/users.yaml - └── components/schemas/User.yaml - ├── components/schemas/Address.yaml - └── components/schemas/Pet.yaml ↺ -``` - -The `↺` marker means the file was already expanded earlier in the tree, so its references are not repeated. Files that cannot be resolved are marked with `✗ not found`, and references to URLs are marked with `(external)`. - -### Find files affected by a change - -Pass a changed file to `--affected-by` to see only the impacted part of the graph — useful in CI and automated review to decide what needs attention without reading every file. Repeat the option to pass several changed files at once. - -```bash -redocly graph openapi.yaml --affected-by components/schemas/Address.yaml -``` - -``` -openapi.yaml -└── paths/users.yaml - └── components/schemas/User.yaml - └── components/schemas/Address.yaml ← changed - -4 of 6 files affected · affected roots: openapi.yaml -``` - -If a file passed to `--affected-by` is not referenced by any processed API, the command prints a warning to stderr and exits with code `0` — "nothing depends on this file" is a valid answer. - -### Machine-readable output - -```bash -redocly graph openapi.yaml --format=json -``` - -Prints the graph as JSON with `roots`, `nodes` (including `resolved` and `external` flags), and `edges` (including the exact `$ref` strings). Only the JSON is written to stdout, so the output is safe to pipe. - -```bash -redocly graph openapi.yaml --format=mermaid -``` - -Prints a [Mermaid](https://mermaid.js.org/) `flowchart` definition. GitHub renders Mermaid code blocks in Markdown automatically, so you can paste the output into a pull request comment or documentation page to get a diagram. diff --git a/docs/@v2/commands/index.md b/docs/@v2/commands/index.md index 4cec5d9e74..e2f9fb6534 100644 --- a/docs/@v2/commands/index.md +++ b/docs/@v2/commands/index.md @@ -14,11 +14,11 @@ Documentation commands: API management commands: - [`bundle`](bundle.md) Bundle API description. -- [`graph`](graph.md) Show the `$ref` dependency graph of API description files. - [`join`](join.md) Join API descriptions [experimental feature]. - [`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..f3f3c30c10 --- /dev/null +++ b/docs/@v2/commands/tree.md @@ -0,0 +1,143 @@ +# `tree` + +## Introduction + +The `tree` command prints the structure of an API description: its paths, operations, and the component dependency chains between them through `$ref`. It 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 `$ref`'d components rather than a paths and operations tree. + +Use it to: + +- get quick orientation in any API, whether single-file or multi-file; +- run impact analysis with `--affected-by` — which paths and operations are affected by a change to a component or file, useful in CI and automated code review; +- produce machine-readable JSON or a Mermaid diagram with `--format`; +- view the file-level `$ref` graph with `--files`. + +## Usage + +```bash +redocly tree +redocly tree +redocly tree [--format=] [--affected-by=] [--config=] +redocly tree --files [apis...] +``` + +With no API argument, the command takes the API from the Redocly configuration file. The default structure view shows 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. | +| --affected-by | [string] | Show only the part of the tree affected by changes to the given components, paths, or files. Repeat the option to pass several values: `--affected-by Pet --affected-by /users`. | +| --config | string | Specify the path to the [Redocly configuration file](../configuration/index.md). | +| --files | boolean | Show the file-level `$ref` graph instead of the document structure. | +| --format | string | Output format: `stylish` (default, tree view), `json`, or `mermaid`. | +| --help | boolean | Show help. | +| --lint-config | string | Specify the severity level for the configuration file.
**Possible values:** `warn`, `error`, `off`. Default value is `warn`. | +| --version | boolean | Show version number. | + +## Examples + +### Print the structure of an API description + +```bash +redocly tree openapi.yaml +``` + +``` +openapi.yaml +├── /pets +│ ├── GET /pets +│ │ └── schemas/Pet +│ │ └── schemas/Address +│ └── POST /pets +│ └── schemas/PetInput +│ └── schemas/Pet ↺ +├── /pets/{petId} +│ ├── GET /pets/{petId} +│ │ └── schemas/Pet ↺ +│ └── parameters/PetId +└── /users + └── GET /users + └── schemas/User + └── schemas/Address ↺ +``` + +Markers legend: + +- `↺` — the node was already expanded earlier in the tree; its dependencies are not repeated. This is also how recursive schemas render. +- `✗ not found` — an unresolvable `$ref`. +- `(external)` — a reference to a URL. + +For multi-file APIs, components living in other files appear as file nodes (for example, `paths/pets.yaml`). Operations defined inside a `$ref`'d path-item file are represented by that file node, not expanded individually. + +### Find what a change affects + +Pass a component pointer, name, or file path to `--affected-by` to see only the impacted part of the tree — useful in CI and automated review. + +```bash +redocly tree openapi.yaml --affected-by '#/components/schemas/Address' +``` + +``` +openapi.yaml +├── /pets +│ ├── GET /pets +│ │ └── schemas/Pet +│ │ └── schemas/Address ← changed +│ └── POST /pets +│ └── schemas/PetInput +│ └── schemas/Pet ↺ +├── /pets/{petId} +│ └── GET /pets/{petId} +│ └── schemas/Pet ↺ +└── /users + └── GET /users + └── schemas/User + └── schemas/Address ↺ ← changed + +4 of 4 operations affected · affected paths: /pets, /pets/{petId}, /users +``` + +`--affected-by` accepts several input forms: + +- Full JSON pointer: `#/components/schemas/Address` +- Shorthand pointer: `schemas/Address` +- Bare component name: `Address` — ambiguous bare names match all candidates and print a note to stderr (impact analysis over-reports rather than under-reports) +- A file path (for multi-file specs): `schemas/address.yaml` +- The root file itself: the whole tree is affected + +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. When the tree has no operation nodes at all (an AsyncAPI or Arazzo description, or a multi-file OpenAPI description whose path items live in `$ref`'d files), the summary falls back to counting nodes, for example `5 of 8 nodes affected`. + +Unknown inputs print a warning to stderr and exit with code `0`. + +### Machine-readable output + +```bash +redocly tree openapi.yaml --format=json +``` + +Prints the structure as JSON with `roots`, `nodes` (including `kind`, `file`, `resolved`, and `external` fields), and `edges` (including the exact `$ref` strings). Only the JSON is written to stdout, so the output is safe to pipe. + +```bash +redocly tree openapi.yaml --format=mermaid +``` + +Prints a [Mermaid](https://mermaid.js.org/) `flowchart` definition. GitHub renders Mermaid code blocks in Markdown automatically, so you can paste the output into a pull request comment or documentation page to get a diagram. + +### File-level graph + +```bash +redocly tree openapi.yaml --files +``` + +``` +openapi.yaml +├── paths/pets.yaml +│ └── components/schemas/Pet.yaml +└── paths/users.yaml + └── components/schemas/User.yaml + ├── components/schemas/Address.yaml + └── components/schemas/Pet.yaml ↺ +``` + +This is the multi-file `$ref` view: it shows which files reference which other files. The `--files` flag supports multiple APIs in a single run. diff --git a/docs/@v2/v2.sidebars.yaml b/docs/@v2/v2.sidebars.yaml index 515c7eb706..6f8a5859b5 100644 --- a/docs/@v2/v2.sidebars.yaml +++ b/docs/@v2/v2.sidebars.yaml @@ -18,8 +18,6 @@ page: commands/eject.md - label: generate-arazzo page: commands/generate-arazzo.md - - label: graph - page: commands/graph.md - label: join page: commands/join.md - label: lint @@ -46,6 +44,8 @@ page: commands/stats.md - label: translate page: commands/translate.md + - label: tree + page: commands/tree.md - group: Guides page: guides/index.md items: From 5730de6b562de7b595c8f23ff6e25a5fc61622ae Mon Sep 17 00:00:00 2001 From: kanoru Date: Sat, 13 Jun 2026 00:25:36 +0300 Subject: [PATCH 22/79] fix: hoist entity test imports to avoid per-test transform timeout Dynamic imports inside the test body made vitest transform the whole untransformed dependency subtree within the test, exceeding the 5000ms per-test budget when istanbul coverage is enabled. Static top-level imports move that cost to the file-load phase, which has no timeout. --- packages/core/src/__tests__/entity.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 From 5eec56f2525dc48747ed6b3b803fcdd0e0271c8a Mon Sep 17 00:00:00 2001 From: kanoru Date: Mon, 15 Jun 2026 12:31:06 +0300 Subject: [PATCH 23/79] docs: unbreak documentation tests for the tree command Add the `treeview` language to the example code fences in tree.md (matching eject.md / translate.md house style) to satisfy markdownlint MD040. Stop tracking the internal agentic planning/spec docs under docs/superpowers/: they were accidentally committed into the published documentation and caused all vale and linkcheck failures plus most markdownlint errors. Nothing references them; they remain available locally but are no longer published. --- docs/@v2/commands/tree.md | 6 +- .../plans/2026-06-11-graph-command.md | 1331 ----------------- .../plans/2026-06-12-tree-command-rework.md | 126 -- .../specs/2026-06-11-graph-command-design.md | 215 --- .../2026-06-12-tree-command-rework-design.md | 138 -- 5 files changed, 3 insertions(+), 1813 deletions(-) delete mode 100644 docs/superpowers/plans/2026-06-11-graph-command.md delete mode 100644 docs/superpowers/plans/2026-06-12-tree-command-rework.md delete mode 100644 docs/superpowers/specs/2026-06-11-graph-command-design.md delete mode 100644 docs/superpowers/specs/2026-06-12-tree-command-rework-design.md diff --git a/docs/@v2/commands/tree.md b/docs/@v2/commands/tree.md index f3f3c30c10..5eb6013940 100644 --- a/docs/@v2/commands/tree.md +++ b/docs/@v2/commands/tree.md @@ -43,7 +43,7 @@ With no API argument, the command takes the API from the Redocly configuration f redocly tree openapi.yaml ``` -``` +```treeview openapi.yaml ├── /pets │ ├── GET /pets @@ -78,7 +78,7 @@ Pass a component pointer, name, or file path to `--affected-by` to see only the redocly tree openapi.yaml --affected-by '#/components/schemas/Address' ``` -``` +```treeview openapi.yaml ├── /pets │ ├── GET /pets @@ -130,7 +130,7 @@ Prints a [Mermaid](https://mermaid.js.org/) `flowchart` definition. GitHub rende redocly tree openapi.yaml --files ``` -``` +```treeview openapi.yaml ├── paths/pets.yaml │ └── components/schemas/Pet.yaml diff --git a/docs/superpowers/plans/2026-06-11-graph-command.md b/docs/superpowers/plans/2026-06-11-graph-command.md deleted file mode 100644 index d4bbc1f788..0000000000 --- a/docs/superpowers/plans/2026-06-11-graph-command.md +++ /dev/null @@ -1,1331 +0,0 @@ -# `redocly graph` Command Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a `redocly graph` command that prints the file-level `$ref` dependency graph of API descriptions as an ASCII tree (`stylish`), `json`, or `mermaid`, with an `--affected-by` filter that shows only the subgraph impacted by changes to given files. - -**Architecture:** CLI-only (no core changes), mirroring the `stats` command: `BaseResolver` + `resolveDocument()` from `@redocly/openapi-core` produce a `ResolvedRefMap`; a pure `buildGraph()` converts ref maps into a `DependencyGraph` model; a pure `filterAffected()` computes the reverse closure; three pure renderers return strings printed via `logger.output()` (stdout stays clean — `logger.info/warn` go to stderr). - -**Tech Stack:** TypeScript ESM (`.js` import suffixes), yargs, vitest (unit: `packages/cli/src/**/*.test.ts`; e2e: `tests/e2e/**`), Changesets. - -**Spec:** `docs/superpowers/specs/2026-06-11-graph-command-design.md` - -**Key codebase facts (verified):** - -- `ResolvedRefMap = Map`; key = `makeRefId(sourceAbsoluteRef, ref.$ref)` = `` `${sourceAbsoluteRef}::${$ref}` `` (`packages/core/src/utils/make-ref-id.ts`). -- `ResolvedRef` is a union: `{ resolved: true; node; document: Document; nodePointer; isRemote }` or `{ resolved: false; isRemote; document?: Document; error?; ... }`. The type itself is NOT exported from core — only `ResolvedRefMap` is; iterate the map to get values typed structurally. -- `isRemote === true` ⇔ the `$ref` target lives in a DIFFERENT file than the source (`resolve.ts:389-395`). Not http-specific. These are exactly the file→file edges. -- Successful target file = `resolvedRef.document.source.absoluteRef`. Failed file load = `document: undefined` + `error`; recover the attempted path via `resolver.resolveExternalRef(sourceAbsoluteRef, uriPartOf$ref)` (public method, `resolve.ts:101`). -- Root loading: `await externalRefResolver.resolveDocument(null, apiPath, true)` returns `Document | ResolveError | YamlParseError` (both errors extend `Error`). -- Root type derivation (same as `lint.ts`/`stats`): `detectSpec(parsed)` → `normalizeTypes(config.extendTypes(getTypes(specVersion), specVersion), config)` → pass `types.Root` to `resolveDocument({ rootDocument, rootType, externalRefResolver })`. -- Public core exports used: `BaseResolver`, `resolveDocument`, `detectSpec`, `getTypes`, `normalizeTypes`, `Source`, `ResolveError`, `logger`, `isAbsoluteUrl`, `slash`, types `Document`, `ResolvedRefMap`. -- `logger.output()` → stdout; `logger.info/warn/error` → stderr. JSON/mermaid purity relies on using ONLY `logger.output` for graph content. -- `CommandArgv` (`packages/cli/src/types.ts:30-45`) is a closed union — `GraphArgv` must be added. -- `getFallbackApisOrExit(argsApis: string[] | undefined, config)` → `Promise` (`{ path, alias?, output? }`); with no args falls back to all APIs from `redocly.yaml`. -- Unit tests: vitest with globals (no `describe/it/expect` imports), files at `packages/cli/src/commands//__tests__/*.test.ts`. `@redocly/openapi-core` resolves to compiled `lib/` → run `npm run compile` before unit tests. -- E2E: `tests/e2e/graph/graph.test.ts` + fixture dirs, runs `node packages/cli/lib/index.js` via helpers `getParams`/`getCommandOutput`/`cleanupOutput`, snapshots via `toMatchFileSnapshot(join(testPath, 'snapshot.txt'))`. Update with `npm run e2e -- -u`. -- Resolution is async/parallel ⇒ map insertion order is nondeterministic. `buildGraph` MUST sort nodes/edges/refs for stable snapshots. Roots keep CLI/config order; stylish tree children are sorted. - -## File Structure - -``` -packages/cli/src/commands/graph/ -├── index.ts # GraphArgv + handleGraph (orchestration only) -├── types.ts # DependencyGraph, GraphNode, GraphEdge, GraphFormat -├── build-graph.ts # pure: ref maps → DependencyGraph -├── filter-affected.ts # pure: graph + changed node ids → induced subgraph -├── print/ -│ ├── stylish.ts # renderStylish(): ASCII trees + markers + summary -│ ├── json.ts # renderJson() -│ └── mermaid.ts # renderMermaid() -└── __tests__/ - ├── build-graph.test.ts - ├── filter-affected.test.ts - └── print.test.ts - -packages/cli/src/types.ts # add GraphArgv to CommandArgv union -packages/cli/src/index.ts # yargs registration - -tests/e2e/graph/ -├── graph.test.ts -└── graph-multi-file/ # fixture + snapshot dirs (see Task 5) - -docs/@v2/commands/graph.md # command docs -docs/@v2/v2.sidebars.yaml # sidebar entry -docs/@v2/commands/index.md # commands list entry -.changeset/graph-command.md # minor release note -``` - ---- - -### Task 0: Baseline compile - -- [ ] **Step 0.1: Compile workspaces so `@redocly/openapi-core` resolves to fresh `lib/`** - -Run: `npm run compile` -Expected: exits 0. (Re-run after any `packages/core` changes; not needed between pure-CLI edits because vitest transpiles CLI `src/` on the fly, but e2e ALWAYS needs a fresh compile of `packages/cli`.) - ---- - -### Task 1: Graph model types + `buildGraph()` - -**Files:** - -- Create: `packages/cli/src/commands/graph/types.ts` -- Create: `packages/cli/src/commands/graph/build-graph.ts` -- Create: `packages/cli/src/commands/graph/__tests__/build-graph.test.ts` - -- [ ] **Step 1.1: Create the model types** - -`packages/cli/src/commands/graph/types.ts`: - -```typescript -export type GraphFormat = 'stylish' | 'json' | 'mermaid'; - -export type GraphNode = { - /** Path relative to cwd; http(s) refs keep the full URL. */ - id: string; - /** Entry-point API file. */ - root?: boolean; - /** Node is an http(s) URL, not a local file. */ - external?: boolean; - /** False: the file is referenced but could not be loaded. */ - resolved: boolean; -}; - -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[]; -}; -``` - -- [ ] **Step 1.2: Write the failing tests** - -`packages/cli/src/commands/graph/__tests__/build-graph.test.ts`: - -```typescript -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'; - -/** Creates a minimal core Document for a given absolute path or URL. */ -function makeDocument(absoluteRef: string): Document { - return { source: new Source(absoluteRef, ''), parsed: {} }; -} - -/** Creates a successfully resolved cross-file ResolvedRefMap entry value. */ -function resolvedEntry(targetAbsoluteRef: string, isRemote = true) { - return { - resolved: true as const, - isRemote, - node: {}, - nodePointer: '#/', - document: makeDocument(targetAbsoluteRef), - }; -} - -/** Resolves a $ref uri against the source file directory, like BaseResolver.resolveExternalRef. */ -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 }], { - cwd: 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 }], { - cwd: 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 }, - ], - { cwd: 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 }], { - cwd: 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 }], { - cwd: 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 }], { - cwd: CWD, - resolveRef, - }); - - expect(graph.edges).toEqual([ - { from: 'a.yaml', to: 'b.yaml', refs: ['b.yaml'] }, - { from: 'b.yaml', to: 'a.yaml', refs: ['a.yaml'] }, - ]); - }); -}); -``` - -- [ ] **Step 1.3: Run the tests to verify they fail** - -Run: `npm run unit -- packages/cli/src/commands/graph/__tests__/build-graph.test.ts` -Expected: FAIL — cannot find module `../build-graph.js`. - -- [ ] **Step 1.4: Implement `buildGraph`** - -`packages/cli/src/commands/graph/build-graph.ts`: - -```typescript -import { isAbsoluteUrl, slash, type Document, type ResolvedRefMap } from '@redocly/openapi-core'; -import * as path from 'node:path'; - -import type { DependencyGraph, GraphEdge, GraphNode } from './types.js'; - -/** Converts an absolute file path or URL into a stable node id (cwd-relative posix path; URLs as-is). */ -function toNodeId(absoluteRef: string, cwd: string): string { - return isAbsoluteUrl(absoluteRef) ? absoluteRef : slash(path.relative(cwd, absoluteRef)); -} - -/** - * Builds the file-level dependency graph from the resolver's ref maps of one or more roots. - * Only cross-file refs (isRemote) become edges; nodes/edges/refs are sorted for stable output. - */ -export function buildGraph( - resolutions: Array<{ rootDocument: Document; refMap: ResolvedRefMap }>, - options: { cwd: string; resolveRef: (base: string, uri: string) => string } -): DependencyGraph { - const { cwd, resolveRef } = options; - const nodes = new Map(); - const edges = new Map(); - - /** Merges-or-creates a node, OR-ing its resolved/root/external flags. */ - 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, cwd), 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, cwd); - const to = toNodeId(targetAbsolute, cwd); - 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); - } - } - - // Codepoint comparison (not localeCompare): deterministic across Node ICU builds → stable snapshots. - const byString = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0); - - return { - roots: resolutions.map(({ rootDocument }) => toNodeId(rootDocument.source.absoluteRef, cwd)), - nodes: [...nodes.values()].sort((a, b) => byString(a.id, b.id)), - edges: [...edges.values()] - .map((edge) => ({ ...edge, refs: [...edge.refs].sort() })) - .sort((a, b) => byString(a.from, b.from) || byString(a.to, b.to)), - }; -} -``` - -Note on node shape: `root`/`external` are set only when true (optional props), so `toEqual` fixtures in Step 1.2 list them only where expected. - -- [ ] **Step 1.5: Run the tests to verify they pass** - -Run: `npm run unit -- packages/cli/src/commands/graph/__tests__/build-graph.test.ts` -Expected: 6 passed. - -Also run: `npm run typecheck` -Expected: exit 0 (vitest does not typecheck — catch type errors now, not in Task 4). - -- [ ] **Step 1.6: Commit** - -```bash -git add packages/cli/src/commands/graph -git commit -m "feat: add dependency graph builder for graph command" -``` - ---- - -### Task 2: `filterAffected()` - -**Files:** - -- Create: `packages/cli/src/commands/graph/filter-affected.ts` -- Create: `packages/cli/src/commands/graph/__tests__/filter-affected.test.ts` - -- [ ] **Step 2.1: Write the failing tests** - -`packages/cli/src/commands/graph/__tests__/filter-affected.test.ts`: - -```typescript -import { filterAffected } 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: [] }); - }); -}); -``` - -- [ ] **Step 2.2: Run the tests to verify they fail** - -Run: `npm run unit -- packages/cli/src/commands/graph/__tests__/filter-affected.test.ts` -Expected: FAIL — cannot find module `../filter-affected.js`. - -- [ ] **Step 2.3: Implement `filterAffected`** - -`packages/cli/src/commands/graph/filter-affected.ts`: - -```typescript -import type { DependencyGraph } from './types.js'; - -/** - * Returns the induced subgraph affected by changes to the given files: - * the changed nodes plus every transitive dependent (reverse closure up to the roots). - * `changedIds` must already be node ids of the graph (cwd-relative paths). - */ -export function filterAffected(graph: DependencyGraph, changedIds: string[]): DependencyGraph { - const dependentsByTarget = new Map(); - for (const edge of graph.edges) { - const dependents = dependentsByTarget.get(edge.to) ?? []; - dependents.push(edge.from); - dependentsByTarget.set(edge.to, dependents); - } - - const affected = new Set(changedIds); - const queue = [...affected]; - while (queue.length > 0) { - const current = queue.shift()!; - for (const dependent of dependentsByTarget.get(current) ?? []) { - if (!affected.has(dependent)) { - affected.add(dependent); - queue.push(dependent); - } - } - } - - 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)), - }; -} -``` - -- [ ] **Step 2.4: Run the tests to verify they pass** - -Run: `npm run unit -- packages/cli/src/commands/graph/__tests__/filter-affected.test.ts` -Expected: 3 passed. - -Also run: `npm run typecheck` -Expected: exit 0. - -- [ ] **Step 2.5: Commit** - -```bash -git add packages/cli/src/commands/graph/filter-affected.ts packages/cli/src/commands/graph/__tests__/filter-affected.test.ts -git commit -m "feat: add affected-files filter for graph command" -``` - ---- - -### Task 3: Renderers (`stylish`, `json`, `mermaid`) - -**Files:** - -- Create: `packages/cli/src/commands/graph/print/stylish.ts` -- Create: `packages/cli/src/commands/graph/print/json.ts` -- Create: `packages/cli/src/commands/graph/print/mermaid.ts` -- Create: `packages/cli/src/commands/graph/__tests__/print.test.ts` - -Renderers are pure (`graph → string`); the handler prints via `logger.output()`. No `console.log` anywhere (e2e is snapshot-based). - -- [ ] **Step 3.1: Write the failing tests** - -`packages/cli/src/commands/graph/__tests__/print.test.ts`: - -```typescript -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 repeat, broken-ref, and external markers', () => { - expect(renderStylish(graph)).toMatchInlineSnapshot(); - }); - - it('marks changed files and 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, { changed: ['components/Pet.yaml'], totalNodeCount: 7 }) - ).toMatchInlineSnapshot(); - }); - - it('reports when nothing is affected', () => { - expect( - renderStylish({ roots: [], nodes: [], edges: [] }, { changed: [], totalNodeCount: 7 }) - ).toMatchInlineSnapshot(`"No files affected."`); - }); -}); - -describe('renderJson', () => { - it('serializes the graph model as-is', () => { - const parsed = JSON.parse(renderJson(graph)); - expect(parsed.roots).toEqual(['openapi.yaml']); - expect(parsed.nodes).toHaveLength(7); - expect(parsed.edges).toHaveLength(7); - }); -}); - -describe('renderMermaid', () => { - it('renders a flowchart with stable ids and a root class', () => { - expect(renderMermaid(graph)).toMatchInlineSnapshot(); - }); -}); -``` - -(Empty `toMatchInlineSnapshot()` calls are filled automatically on the first passing run — see Step 3.4.) - -- [ ] **Step 3.2: Run the tests to verify they fail** - -Run: `npm run unit -- packages/cli/src/commands/graph/__tests__/print.test.ts` -Expected: FAIL — cannot find module `../print/json.js`. - -- [ ] **Step 3.3: Implement the three renderers** - -`packages/cli/src/commands/graph/print/json.ts`: - -```typescript -import type { DependencyGraph } from '../types.js'; - -/** Serializes the dependency graph as pretty-printed JSON. */ -export function renderJson(graph: DependencyGraph): string { - return JSON.stringify(graph, null, 2); -} -``` - -`packages/cli/src/commands/graph/print/mermaid.ts`: - -```typescript -import type { DependencyGraph } from '../types.js'; - -/** Renders the dependency graph as a Mermaid flowchart definition. */ -export function renderMermaid(graph: DependencyGraph): string { - const mermaidIds = new Map(graph.nodes.map((node, index) => [node.id, `n${index}`])); - const escapeLabel = (label: string) => label.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'); -} -``` - -`packages/cli/src/commands/graph/print/stylish.ts`: - -```typescript -import type { DependencyGraph } from '../types.js'; - -export type StylishOptions = { - /** Node ids queried via --affected-by that exist in the graph. */ - changed?: string[]; - /** Node count of the unfiltered graph; enables the affected summary line. */ - totalNodeCount?: number; -}; - -/** - * Renders one ASCII tree per root. A node already expanded in the current tree - * is printed with `↺` and not expanded again (handles cycles and fan-in). - */ -export function renderStylish(graph: DependencyGraph, options: StylishOptions = {}): string { - if (graph.nodes.length === 0) { - return '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(); - } - - const nodesById = new Map(graph.nodes.map((node) => [node.id, node])); - const changed = new Set(options.changed ?? []); - const lines: string[] = []; - - const label = (id: string, isRepeat: boolean): string => { - const node = nodesById.get(id); - let text = id; - if (node?.external) text += ' (external)'; - if (node && !node.resolved) text += ' ✗ not found'; - if (isRepeat) text += ' ↺'; - if (changed.has(id)) text += ' ← changed'; - return text; - }; - - const renderSubtree = (id: string, prefix: string, printed: Set) => { - const children = childrenByNode.get(id) ?? []; - children.forEach((child, index) => { - const isLast = index === children.length - 1; - const isRepeat = printed.has(child); - lines.push(`${prefix}${isLast ? '└── ' : '├── '}${label(child, isRepeat)}`); - if (!isRepeat) { - printed.add(child); - renderSubtree(child, `${prefix}${isLast ? ' ' : '│ '}`, printed); - } - }); - }; - - graph.roots.forEach((root, index) => { - if (index > 0) lines.push(''); - lines.push(label(root, false)); - renderSubtree(root, '', new Set([root])); - }); - - if (options.totalNodeCount !== undefined) { - lines.push(''); - lines.push( - `${graph.nodes.length} of ${options.totalNodeCount} files affected · affected roots: ${ - graph.roots.join(', ') || 'none' - }` - ); - } - - return lines.join('\n'); -} -``` - -- [ ] **Step 3.4: Run the tests, let vitest fill the inline snapshots, then review them** - -Run: `npm run unit -- packages/cli/src/commands/graph/__tests__/print.test.ts -u` -Expected: 5 passed; empty `toMatchInlineSnapshot()` calls now contain the rendered output. - -Manually verify the filled snapshots look exactly like this (tree shape, markers, summary): - -``` -openapi.yaml -├── paths/pets.yaml -│ └── components/Pet.yaml -└── paths/users.yaml - └── components/User.yaml - ├── components/Pet.yaml ↺ - ├── components/missing.yaml ✗ not found - └── https://example.com/shared.yaml (external) -``` - -and for affected mode (`components/Pet.yaml` queried): - -``` -openapi.yaml -├── paths/pets.yaml -│ └── components/Pet.yaml ← changed -└── paths/users.yaml - └── components/User.yaml - └── components/Pet.yaml ↺ ← changed - -5 of 7 files affected · affected roots: openapi.yaml -``` - -and mermaid (node order follows graph.nodes order): - -``` -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 -``` - -If the output differs from the spec's intent (wrong markers, missing summary), fix the renderer, not the snapshot. - -- [ ] **Step 3.5: Run all graph unit tests together** - -Run: `npm run unit -- packages/cli/src/commands/graph` -Expected: build-graph (6) + filter-affected (3) + print (5) all pass. - -Also run: `npm run typecheck` -Expected: exit 0. - -- [ ] **Step 3.6: Commit** - -```bash -git add packages/cli/src/commands/graph -git commit -m "feat: add graph command output renderers" -``` - ---- - -### Task 4: Handler + CLI registration - -**Files:** - -- Create: `packages/cli/src/commands/graph/index.ts` -- Modify: `packages/cli/src/types.ts` (CommandArgv union, imports at top) -- Modify: `packages/cli/src/index.ts` (import + `.command()` block) - -- [ ] **Step 4.1: Implement the handler** - -`packages/cli/src/commands/graph/index.ts`: - -```typescript -import { - BaseResolver, - detectSpec, - getTypes, - logger, - normalizeTypes, - resolveDocument, - type Document, - type ResolvedRefMap, -} from '@redocly/openapi-core'; -import * as path from 'node:path'; - -import type { 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 } from './filter-affected.js'; -import { renderJson } from './print/json.js'; -import { renderMermaid } from './print/mermaid.js'; -import { renderStylish, type StylishOptions } from './print/stylish.js'; -import type { GraphFormat } from './types.js'; - -export type GraphArgv = { - apis?: string[]; - format: GraphFormat; - 'affected-by'?: string[]; -} & VerifyConfigOptions; - -/** Resolves the given API descriptions and prints their file-level $ref dependency graph. */ -export async function handleGraph({ argv, config, collectSpecData }: CommandArgs) { - const apis = await getFallbackApisOrExit(argv.apis, config); - const externalRefResolver = new BaseResolver(config.resolve); - const cwd = process.cwd(); - - const resolutions: Array<{ rootDocument: Document; refMap: ResolvedRefMap }> = []; - for (const { path: apiPath } of apis) { - const rootDocument = await externalRefResolver.resolveDocument(null, apiPath, true); - if (rootDocument instanceof Error) { - return exitWithError(`Failed to load ${apiPath}: ${rootDocument.message}`); - } - collectSpecData?.(rootDocument.parsed); - const specVersion = detectSpec(rootDocument.parsed); - const types = normalizeTypes(config.extendTypes(getTypes(specVersion), specVersion), config); - const refMap = await resolveDocument({ - rootDocument: rootDocument as Document, - rootType: types.Root, - externalRefResolver, - }); - resolutions.push({ rootDocument: rootDocument as Document, refMap }); - } - - const graph = buildGraph(resolutions, { - cwd, - resolveRef: (base, uri) => externalRefResolver.resolveExternalRef(base, uri), - }); - - let printedGraph = graph; - let stylishOptions: StylishOptions = {}; - if (argv['affected-by']) { - const changedIds = argv['affected-by'].map((file) => - path.relative(cwd, path.resolve(cwd, file)) - ); - const knownIds = new Set(graph.nodes.map((node) => node.id)); - 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 = { changed: knownChanged, totalNodeCount: graph.nodes.length }; - } - - switch (argv.format) { - case 'json': - logger.output(renderJson(printedGraph) + '\n'); - break; - case 'mermaid': - logger.output(renderMermaid(printedGraph) + '\n'); - break; - default: - logger.output(renderStylish(printedGraph, stylishOptions) + '\n'); - } -} -``` - -- [ ] **Step 4.2: Add `GraphArgv` to the `CommandArgv` union** - -In `packages/cli/src/types.ts`, add the import after the `GenerateArazzoCommandArgv` import (line 7): - -```typescript -import type { GraphArgv } from './commands/graph/index.js'; -``` - -and extend the union (after `| StatsArgv`): - -```typescript -export type CommandArgv = StatsArgv | GraphArgv | SplitArgv; -``` - -(rest of the union unchanged). - -- [ ] **Step 4.3: Register the command in yargs** - -In `packages/cli/src/index.ts`: - -Add the import after `import { handleGenerateArazzo, ... } from './commands/generate-arazzo.js';`: - -```typescript -import { handleGraph } from './commands/graph/index.js'; -import type { GraphFormat } from './commands/graph/types.js'; -``` - -Insert this `.command()` block immediately after the existing `stats` command block (after its closing `)` around line 76): - -```typescript - .command( - 'graph [apis...]', - 'Show the $ref dependency graph of API description files.', - (yargs) => - yargs - .env('REDOCLY_CLI_GRAPH') - .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'] as ReadonlyArray, - default: 'stylish' as GraphFormat, - }, - 'affected-by': { - description: - 'Show only the part of the graph affected by changes to the given files.', - array: true, - type: 'string', - requiresArg: true, - }, - }), - (argv) => { - commandWrapper(handleGraph)(argv); - } - ) -``` - -- [ ] **Step 4.4: Typecheck and compile** - -Run: `npm run typecheck && npm run compile` -Expected: both exit 0. If `rootDocument instanceof Error` narrowing complains (`ResolveError`/`YamlParseError` are `Error` subclasses), keep the `as Document` casts as written above — they mirror `lint.ts`. - -- [ ] **Step 4.5: Smoke-run the wired command** - -(Single-file spec: the graph is just the root node — this only verifies registration, resolution, and clean output. Multi-file behavior is covered by Task 1 unit tests and Task 5 e2e.) - -Run: `npm run cli -- graph tests/e2e/join/multiple-tags-in-same-files/foo.yaml 2>/dev/null` -Expected: stdout is exactly one tree line `tests/e2e/join/multiple-tags-in-same-files/foo.yaml` (no stack trace). - -Run: `npm run cli -- graph tests/e2e/join/multiple-tags-in-same-files/foo.yaml --format=json 2>/dev/null` -Expected: valid JSON with `roots`, `nodes`, `edges` keys and nothing else on stdout. - -- [ ] **Step 4.6: Run the full unit suite** - -Run: `npm run unit` -Expected: all suites pass (graph tests included, nothing else broken). - -- [ ] **Step 4.7: Commit** - -```bash -git add packages/cli/src/commands/graph packages/cli/src/types.ts packages/cli/src/index.ts -git commit -m "feat: register graph command in CLI" -``` - ---- - -### Task 5: E2E tests with a multi-file fixture - -**Files:** - -- Create: `tests/e2e/graph/graph.test.ts` -- Create: `tests/e2e/graph/graph-multi-file/openapi.yaml` -- Create: `tests/e2e/graph/graph-multi-file/paths/pets.yaml` -- Create: `tests/e2e/graph/graph-multi-file/paths/users.yaml` -- Create: `tests/e2e/graph/graph-multi-file/components/schemas/Pet.yaml` -- Create: `tests/e2e/graph/graph-multi-file/components/schemas/User.yaml` -- Create: `tests/e2e/graph/graph-multi-file/components/schemas/Address.yaml` -- Generated: `snapshot.txt` in `graph-stylish/`, `graph-json/`, `graph-affected-by/` (see Step 5.3 — the three test dirs share one fixture via relative path) - -Fixture exercises: nesting (root → paths → schemas), fan-in (`Pet.yaml` referenced from `pets.yaml` and `User.yaml` → `↺` marker), affected-branch pruning (`Address.yaml` only affects the users branch). - -- [ ] **Step 5.1: Create the fixture files** - -`tests/e2e/graph/graph-multi-file/openapi.yaml`: - -```yaml -openapi: 3.0.0 -info: - title: Graph fixture - version: 1.0.0 -paths: - /pets: - $ref: paths/pets.yaml - /users: - $ref: paths/users.yaml -``` - -`tests/e2e/graph/graph-multi-file/paths/pets.yaml`: - -```yaml -get: - summary: List pets - responses: - '200': - description: OK - content: - application/json: - schema: - $ref: ../components/schemas/Pet.yaml -``` - -`tests/e2e/graph/graph-multi-file/paths/users.yaml`: - -```yaml -get: - summary: List users - responses: - '200': - description: OK - content: - application/json: - schema: - $ref: ../components/schemas/User.yaml -``` - -`tests/e2e/graph/graph-multi-file/components/schemas/Pet.yaml`: - -```yaml -type: object -properties: - name: - type: string -``` - -`tests/e2e/graph/graph-multi-file/components/schemas/User.yaml`: - -```yaml -type: object -properties: - address: - $ref: Address.yaml - pet: - $ref: Pet.yaml -``` - -`tests/e2e/graph/graph-multi-file/components/schemas/Address.yaml`: - -```yaml -type: object -properties: - city: - type: string -``` - -- [ ] **Step 5.2: Write the e2e test** - -`tests/e2e/graph/graph.test.ts` (imports mirror `tests/e2e/stats/stats.test.ts` exactly — ESM, so `__dirname` is derived via `fileURLToPath`; `describe/test/expect` are vitest globals, no import): - -```typescript -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('graph', () => { - const folderPath = __dirname; - const fixturePath = join(folderPath, 'graph-multi-file'); - - test('graph should print a stylish tree', async () => { - const args = getParams(indexEntryPoint, ['graph', 'openapi.yaml']); - const result = getCommandOutput(args, { testPath: fixturePath }); - await expect(cleanupOutput(result)).toMatchFileSnapshot( - join(folderPath, 'graph-stylish', 'snapshot.txt') - ); - }); - - test('graph should print pure JSON', async () => { - const args = getParams(indexEntryPoint, ['graph', 'openapi.yaml', '--format=json']); - const result = getCommandOutput(args, { testPath: fixturePath }); - await expect(cleanupOutput(result)).toMatchFileSnapshot( - join(folderPath, 'graph-json', 'snapshot.txt') - ); - }); - - test('graph should print only the affected subgraph', async () => { - const args = getParams(indexEntryPoint, [ - 'graph', - 'openapi.yaml', - '--affected-by', - 'components/schemas/Address.yaml', - ]); - const result = getCommandOutput(args, { testPath: fixturePath }); - await expect(cleanupOutput(result)).toMatchFileSnapshot( - join(folderPath, 'graph-affected-by', 'snapshot.txt') - ); - }); -}); -``` - -- [ ] **Step 5.3: Compile and generate snapshots** - -Run: `npm run compile && npm run e2e -- tests/e2e/graph/graph.test.ts -u` -Expected: 3 passed, three `snapshot.txt` files created. - -- [ ] **Step 5.4: Review the generated snapshots against the spec** - -`graph-stylish/snapshot.txt` must contain exactly this tree (children sorted; `Pet.yaml` expanded once, repeated with `↺`): - -``` -openapi.yaml -├── paths/pets.yaml -│ └── components/schemas/Pet.yaml -└── paths/users.yaml - └── components/schemas/User.yaml - ├── components/schemas/Address.yaml - └── components/schemas/Pet.yaml ↺ -``` - -`graph-json/snapshot.txt` must be valid JSON only (6 nodes, 6 edges, `"roots": ["openapi.yaml"]`, edge objects carry `refs` arrays). - -`graph-affected-by/snapshot.txt` must show only the users branch plus the summary: - -``` -openapi.yaml -└── paths/users.yaml - └── components/schemas/User.yaml - └── components/schemas/Address.yaml ← changed - -4 of 6 files affected · affected roots: openapi.yaml -``` - -If a snapshot deviates (e.g. unsorted children, missing marker), fix the source, re-run with `-u`, and re-review. - -- [ ] **Step 5.5: Run the whole e2e suite** - -Run: `npm run e2e` -Expected: all pass (no other suites affected). - -- [ ] **Step 5.6: Commit** - -```bash -git add tests/e2e/graph -git commit -m "test: add graph command e2e tests" -``` - ---- - -### Task 6: Docs, sidebar, commands index, changeset - -**Files:** - -- Create: `docs/@v2/commands/graph.md` -- Modify: `docs/@v2/v2.sidebars.yaml` (Commands group, alphabetical: between `generate-arazzo` and `join`) -- Modify: `docs/@v2/commands/index.md` (API management commands list, between `bundle` and `join`) -- Create: `.changeset/graph-command.md` - -- [ ] **Step 6.1: Write the command docs page** - -`docs/@v2/commands/graph.md` (structure mirrors `stats.md`: title, Introduction, Usage, Options table, Examples): - -````markdown -# `graph` - -## Introduction - -The `graph` command prints the file-level dependency graph of an API description: which files reference which other files through `$ref`. It works with multi-file OpenAPI, AsyncAPI, and Arazzo descriptions. - -Use it to: - -- get a quick `tree`-style overview of a multi-file API description; -- find out which files are affected by a change to a shared file (`--affected-by`) — for example, in CI or automated code review; -- feed exact file relationships to tooling as JSON or render them as a Mermaid diagram. - -## Usage - -```bash -redocly graph -redocly graph -redocly graph [--format=] [--affected-by=] [--config=] -``` -```` - -If you don't pass any API to the command, it processes all APIs defined in your Redocly configuration file and prints one merged graph. - -## Options - -| Option | Type | Description | -| ------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| apis | [string] | Paths to API description files. Defaults to all APIs from the Redocly configuration file. | -| --affected-by | [string] | Show only the part of the graph affected by changes to the given files: the files themselves plus everything that references them. Repeat the option to pass several files: `--affected-by a.yaml --affected-by b.yaml`. | -| --config | string | Specify the path to the [Redocly configuration file](../configuration/index.md). | -| --format | string | Output format: `stylish` (default, tree view), `json`, or `mermaid`. | -| --help | boolean | Show help. | -| --lint-config | string | Specify the severity level for the configuration file.
**Possible values:** `warn`, `error`, `off`. Default value is `warn`. | -| --version | boolean | Show version number. | - -## Examples - -### Print the dependency tree - -```bash -redocly graph openapi.yaml -``` - -``` -openapi.yaml -├── paths/pets.yaml -│ └── components/schemas/Pet.yaml -└── paths/users.yaml - └── components/schemas/User.yaml - ├── components/schemas/Address.yaml - └── components/schemas/Pet.yaml ↺ -``` - -The `↺` marker means the file was already expanded earlier in the tree, so its references are not repeated. Files that cannot be resolved are marked with `✗ not found`, and references to URLs are marked with `(external)`. - -### Find files affected by a change - -Pass one or more changed files to `--affected-by` to see only the impacted part of the graph — useful in CI and automated review to decide what needs attention without reading every file: - -```bash -redocly graph openapi.yaml --affected-by components/schemas/Address.yaml -``` - -``` -openapi.yaml -└── paths/users.yaml - └── components/schemas/User.yaml - └── components/schemas/Address.yaml ← changed - -4 of 6 files affected · affected roots: openapi.yaml -``` - -If a file in `--affected-by` is not referenced by any processed API, the command prints a warning to stderr and exits with code `0` — "nothing depends on this file" is a valid answer. - -### Machine-readable output - -```bash -redocly graph openapi.yaml --format=json -``` - -Prints the graph as JSON with `roots`, `nodes` (including `resolved` and `external` flags), and `edges` (including the exact `$ref` strings). Only the JSON is written to stdout, so the output is safe to pipe. - -```bash -redocly graph openapi.yaml --format=mermaid -``` - -Prints a [Mermaid](https://mermaid.js.org/) `flowchart` definition. GitHub renders Mermaid code blocks in Markdown automatically, so you can paste the output into a pull request comment or documentation page to get a diagram. - -```` - -- [ ] **Step 6.2: Add the sidebar entry** - -In `docs/@v2/v2.sidebars.yaml`, inside the `Commands` group items, insert between `generate-arazzo` and `join`: - -```yaml - - label: graph - page: commands/graph.md -```` - -- [ ] **Step 6.3: Add the commands index entry** - -In `docs/@v2/commands/index.md`, in the `API management commands:` list, insert between the `bundle` and `join` lines: - -```markdown -- [`graph`](graph.md) Show the `$ref` dependency graph of API description files. -``` - -- [ ] **Step 6.4: Create the changeset** - -`.changeset/graph-command.md`: - -```markdown ---- -'@redocly/cli': minor ---- - -Added the `graph` command that prints the file-level `$ref` dependency graph of API descriptions as a tree (`stylish`), `json`, or `mermaid` output. The `--affected-by` option filters the graph to the files impacted by changes to the given files. -``` - -- [ ] **Step 6.5: Full verification** - -Run: `npm test` -Expected: compile, typecheck, unit, and e2e all pass. - -- [ ] **Step 6.6: Commit** - -```bash -git add docs/@v2/commands/graph.md docs/@v2/v2.sidebars.yaml docs/@v2/commands/index.md .changeset/graph-command.md -git commit -m "docs: document graph command and add changeset" -``` - ---- - -## Rollback - -Every task is an isolated commit on `feat/graph-command`; revert any of them with `git revert `. The feature adds one new command and touches shared files only additively (`types.ts` union member, `index.ts` command block, docs lists), so reverting the branch removes the feature completely. - -## Out of Scope (per spec) - -- Core package changes, DOT/Graphviz output, component-level nodes, validation behavior (broken refs stay non-fatal). diff --git a/docs/superpowers/plans/2026-06-12-tree-command-rework.md b/docs/superpowers/plans/2026-06-12-tree-command-rework.md deleted file mode 100644 index f2bda73f30..0000000000 --- a/docs/superpowers/plans/2026-06-12-tree-command-rework.md +++ /dev/null @@ -1,126 +0,0 @@ -# `redocly tree` Rework Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Rework `redocly graph` into `redocly tree`: default mode shows the internal structure of one API description (root → paths → operations → component chains); the existing file-level graph moves behind `--files` unchanged. - -**Spec:** `docs/superpowers/specs/2026-06-12-tree-command-rework-design.md` (the contract — read it first). - -**Architecture:** CLI-only. New pure modules `node-id.ts` (pointer→node mapping), `build-structure.ts` (walkDocument-based builder using the stats pattern + a `ref` visitor for every `$ref` site), `match-affected-by.ts` (input matcher). Reused untouched: `filter-affected.ts`, `print/json.ts`, `print/mermaid.ts`, `build-graph.ts` (files mode). Adjusted: `types.ts` (additive `kind`/`file`), `print/stylish.ts` (caller-provided `summary`/`emptyMessage`), handler (mode dispatch). - -**Verified mechanics (trust these):** - -- `walkDocument` fires `ref` enter visitors at EVERY ref site (`packages/core/src/walk.ts` ~182-209): visitor `(node, ctx, resolved)` where `ctx.location`/`ctx.rawLocation` = ref-site Location (`source.absoluteRef` + `pointer`), `resolved = { node, location: resolvedLocation, error }` with target Location (`source.absoluteRef` + `nodePointer`). Cycles terminate via the walker's seen-node dedup. -- Type-enter visitors receive `rawLocation` = the ref-site location for `$ref`'d nodes — pointer-prefix checks on `PathItem`/`Operation` hooks are reliable and immune to callbacks/webhooks misattribution. -- `normalizeVisitors` ignores visitor keys absent from the spec's type map → `PathItem`/`Operation` hooks are inert for AsyncAPI/Arazzo. -- PathItem methods across specs: `get put post delete options head patch trace query x-query`. -- Core public barrel: `unescapePointerFragment`, `escapePointerFragment`, `isAbsoluteUrl`, `slash`, `Source`, `normalizeVisitors`, `walkDocument`, `normalizeTypes`, `getTypes`, `detectSpec`, `resolveDocument`, `BaseResolver`, `logger`, types `Document`, `ResolvedRefMap`, `NormalizedNodeType`, `WalkContext`. NOT exported: `parsePointer`/`parseRef`/`joinPointer` → `node-id.ts` carries a tiny local `parsePointerSegments`. -- Unit-test harness for walking without fs: see `packages/cli/src/commands/score/__tests__/collect-metrics-helper.ts` (`Source` + parsed object + `normalizeTypes(getTypes(v), {})` + `resolveDocument` + `WalkContext`). -- Files referencing the command outside its folder: `packages/cli/src/index.ts` (yargs block), `packages/cli/src/types.ts` (union), docs/changeset/e2e. `lint.ts` guard keys on format values only — unchanged. -- Sidebar: `tree` sorts after `translate` (currently last in the Commands group). -- Known pre-existing failures (NOT ours): build-docs e2e bundle-size drift; respect-core `entity.test.ts` timeout flake; occasional `local-json-server` flake. - -## Node model & id scheme - -See spec. Summary: `GraphNode += kind?: 'root'|'path'|'operation'|'component'|'file'; file?: string` (default mode only). Ids: `openapi.yaml` / `/pets` / `GET /pets` / `schemas/Pet` (root-file component, no `#/`) / `definitions/Pet` (OAS2) / `webhooks/newPet` (fallback, first two segments) / `schemas/pet.yaml` (whole foreign file) / `common.yaml#/components/schemas/Pet` (component in foreign file). Nested target pointers normalize to the top-level component. Self-edges kept. Post-build prune to root-reachable. Default mode = exactly one API (else `exitWithError` suggesting `--files`). - -## `node-id.ts` contract (T3) - -```ts -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', -]); - -/** '#/paths/~1pets/get' -> ['paths', '/pets', 'get'] */ -export function parsePointerSegments(pointer: string): string[]; - -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[]; -}; - -/** Maps a pointer within the ROOT document to its owning tree node. */ -export function mapRootPointer(pointer: string, rootId: string): MappedNode; -// paths/{p} -> { id: p, kind: 'path', ancestry: [] } -// paths/{p}/{method}.. -> { id: `${METHOD} ${p}`, kind: 'operation', ancestry: [p] } -// paths/{p}/.. -> { id: p, kind: 'path', ancestry: [] } (path-level params etc.) -// components/{t}/{n}.. -> { id: `${t}/${n}`, kind: 'component' } (no ancestry) -// OAS2 {section}/{n}.. -> { id: `${section}/${n}`, kind: 'component' } -// '' -> { id: rootId, kind: 'root' } -// anything else -> { id: first two segments (or one), kind: 'component', ancestry: [] } - -/** Maps a location in a NON-root file: component inside it or the whole file. */ -export function mapForeignLocation(fileId: string, pointer: string): MappedNode & { file: string }; -// components-section pointer (depth 3) or OAS2 section (depth 2) -> { id: `${fileId}#/`, kind: 'component' } -// otherwise -> { id: fileId, kind: 'file' } -``` - -## `build-structure.ts` contract (T4) - -```ts -export function buildStructure(options: { - document: Document; - types: Record; - resolvedRefMap: ResolvedRefMap; - ctx: WalkContext; - cwd: string; - resolveRef: (base: string, uri: string) => string; // BaseResolver.resolveExternalRef in prod -}): DependencyGraph; -``` - -- Visitor: `PathItem.enter` / `Operation.enter` act only when site is root file AND pointer has exactly 2 / 3 segments starting with `paths` (3rd ∈ OPERATION_METHODS) → materialize node + spine edges (refs `[]`). -- `ref.enter(refNode, ctx, resolved)`: owner = map(ctx.location), target = `resolved.location` ? map(resolved.location) : unresolved-target derivation (raw `$ref` split on `#`; empty uri → root-pointer mapping; else `resolveRef(siteFile, uri)` → file/foreign mapping; node `resolved:false`). Edge owner→target collects distinct `$ref` strings. `isAbsoluteUrl` ids → `external: true`. -- `materialize(mapped)` upserts node with kind/file and wires `root → ...ancestry → node` spine edges when `ancestry !== undefined`. -- Post-build: BFS-prune to root-reachable; codepoint-sort nodes/edges/refs (same comparator as `build-graph.ts`); `roots: [rootId]`. - -## `match-affected-by.ts` contract (T6) - -```ts -export function matchAffectedBy( - graph: DependencyGraph, - inputs: string[], - options: { cwd: string; rootId: string } -): { changedIds: string[]; markerIds: string[]; notes: string[]; warnings: string[] }; -``` - -Rules per input (first match wins): exact id → pointer (`#...` via mapRootPointer) → file path (`slash(path.relative(cwd, path.resolve(cwd, input)))`; equals rootId → ALL node ids changed, marker = root only, note) → bare component name (last segment match over `kind:'component'`; ambiguous → all + note). No match → warning. Handler logs notes/warnings via `logger.warn` (stderr), exit 0. - -## Stylish options (T5) - -`StylishOptions = { changed?: string[]; summary?: string; emptyMessage?: string }` — renderer appends `summary` after a blank line when set; empty graph returns `emptyMessage ?? 'No files affected.'`. Handler composes: files mode summary byte-identical to today; default mode `N of M operations affected · affected paths: ` (fallback `N of M nodes affected` when the full graph has zero `kind:'operation'` nodes); default empty message `No nodes affected.` - -## Tasks (TDD, one commit each; verification: `npm run compile` before unit/e2e) - -- **T1** Spec+plan docs (this file + spec) → `docs: add tree command rework spec and plan` -- **T2** Mechanical rename: `git mv packages/cli/src/commands/graph packages/cli/src/commands/tree`; `git mv tests/e2e/graph tests/e2e/tree`; symbols `handleGraph→handleTree`, `GraphArgv→TreeArgv`, `GraphFormat→TreeFormat`; yargs `'tree [apis...]'` + desc `Display the structure of an API description as a tree.` + `.env('REDOCLY_CLI_TREE')`; union import/member in `packages/cli/src/types.ts`; e2e test runs `'tree'`, snapshot dirs `graph-*` → `tree-files-*` (содержимое unchanged), fixture dir stays `graph-multi-file` → rename to `tree-multi-file` (update test paths). Verify: typecheck + unit + e2e (tests/e2e/tree). Commit `refactor: rename graph command to tree`. -- **T3** `node-id.ts` + `__tests__/node-id.test.ts` (~10 cases: escaping `~1/~0`; root/path/operation/path-level/component/OAS2/fallback/x-query; foreign component canonical id; foreign whole-file). Commit `feat: add pointer-to-node mapping for the tree structure view`. -- **T4** `types.ts` additive fields; `build-structure.ts` + `__tests__/build-structure.test.ts` (~12 cases listed in spec Testing section; harness per score pattern with injected `resolveRef`). Commit `feat: add internal-structure builder for the tree command`. -- **T5** stylish options refactor + adapt 2 print tests (summary now caller-provided string). Commit `refactor: make stylish summary and empty message caller-provided`. -- **T6** `match-affected-by.ts` + ~7 tests. Commit `feat: match affected-by inputs against tree nodes`. -- **T7** Handler rework (`--files` dispatch keeps today's path verbatim incl. summary text; default mode: single-API guard → buildStructure → matcher → filterAffected → summary → render) + yargs `files` boolean option + `--affected-by` description update. Verify: files-mode e2e snapshots UNCHANGED; manual smoke. Commit `feat: make document structure the default tree view behind --files fallback`. -- **T8** E2E: new `tests/e2e/tree/tree-single-file/openapi.yaml` (paths `/pets` GET+POST, `/pets/{petId}` GET, `/users` GET; `components.schemas`: `Pet→Address`, `PetInput→Pet`, `User→Address` (fan-in `↺`), `parameters/PetId` referenced at path level, unused `Orphan` (proves pruning)); tests: default stylish, json, `--affected-by '#/components/schemas/Address'`, `--affected-by Address`, `--affected-by schemas/Unknown` (warning), multi-file default mode, multi-file default `--affected-by components/schemas/Address.yaml` (file input → impacted operations); 4 files-mode tests kept. Commit `test: cover tree structure mode end to end`. -- **T9** `git mv docs/@v2/commands/graph.md docs/@v2/commands/tree.md` + rewrite per spec; sidebar move after `translate`; commands index line `- [\`tree\`](tree.md) Display the structure of an API description as a tree.`; rewrite `.changeset/graph-command.md`. Check `grep -rn "commands/graph" docs/`clean. Commit`docs: document the tree command and update the changeset`. -- **T10** `npm test` (known pre-existing failures excepted) + `grep -rni "redocly graph\|REDOCLY_CLI_GRAPH\|handleGraph\|GraphArgv" packages docs tests` clean + final whole-feature review. - -## Risks - -- YAML anchor-shared operation objects enumerate once (walker dedup) — accepted. -- `$ref`'d path-item walker ordering — early T4 test; ref-visitor spine creation is the fallback. -- Files-mode snapshot content diffs = regression signal (only dir names change). diff --git a/docs/superpowers/specs/2026-06-11-graph-command-design.md b/docs/superpowers/specs/2026-06-11-graph-command-design.md deleted file mode 100644 index 7472e134ed..0000000000 --- a/docs/superpowers/specs/2026-06-11-graph-command-design.md +++ /dev/null @@ -1,215 +0,0 @@ -# `redocly graph` Command — Design - -**Date:** 2026-06-11 -**Branch:** `feat/graph-command` -**Status:** Approved - -## Motivation - -Multi-file OpenAPI projects spread their structure across dozens of files connected by `$ref`. Today there is no way to see that structure without reading the files. Two audiences need it: - -1. **AI tooling (primary driver).** AI Review must know which files are impacted by a change without guessing or reading every file in the repo. A machine-readable dependency graph plus a built-in "what is affected by a change to file X" query answers this in one command call and saves tokens. -2. **Humans.** A `tree`-style view of an API project for quick orientation, and a Mermaid diagram for docs and PR comments (GitHub renders Mermaid natively). - -The data already exists: `resolveDocument()` in `packages/core/src/resolve.ts` produces a `ResolvedRefMap` whose entries identify, for every `$ref`, the source file, the `$ref` string, the target file, and whether the reference crosses file boundaries (`isRemote`). The command surfaces what core already computes on every bundle/lint run. - -## Goals - -- New CLI command `redocly graph` that prints the file-level `$ref` dependency graph of one or more API descriptions. -- Output formats: `stylish` (ASCII tree, default), `json` (machine-readable), `mermaid` (renderable diagram). -- Impact query: `--affected-by ` prints only the subgraph affected by changes to the given files. -- Works for every spec type core can resolve (OpenAPI 2/3.x, AsyncAPI, Arazzo) with no spec-specific logic. - -## Non-goals - -- No changes to `packages/core` — the command consumes existing public core APIs (`BaseResolver`, `resolveDocument`, spec detection/type normalization), following the precedent of the `stats` command. -- No component-level (pointer-level) graph nodes; nodes are files. Edge metadata does include the distinct `$ref` strings, which is enough detail for impact analysis. -- No DOT/Graphviz output in MVP. -- No validation: broken `$ref`s are displayed, not reported as errors — that is `lint`'s job. - -## CLI Surface - -```bash -redocly graph [apis...] # no args: all APIs from redocly.yaml (lint convention) -redocly graph openapi.yaml # explicit root(s) -redocly graph --format # default: stylish -redocly graph --affected-by [--affected-by ] # impact filter; repeat the flag per file -redocly graph --config # standard config flag -``` - -- Registered in `packages/cli/src/index.ts` via yargs, executed through `commandWrapper(handleGraph)` like every other command. -- Multiple roots produce one **merged** graph (shared nodes/edges deduplicated, every root flagged). This is required for trustworthy impact analysis: a shared schema may affect 2 of 5 configured APIs, and the answer must say which. -- Exit codes follow repo convention: `0` success (including "file affects nothing"), `1` execution error (root missing/unparseable), `2` config error. - -## Data Model - -The single contract consumed by all three printers: - -```ts -type DependencyGraph = { - roots: string[]; // root file ids - nodes: GraphNode[]; - edges: GraphEdge[]; // deduplicated file→file edges -}; - -type GraphNode = { - id: string; // path relative to cwd; http(s) refs keep the URL as id - root?: boolean; // entry-point API file - external?: boolean; // http(s) reference - resolved: boolean; // false: referenced but missing/unparseable -}; - -type GraphEdge = { - from: string; - to: string; - refs: string[]; // distinct $ref strings used from `from` to `to` -}; -``` - -Notes: - -- Node ids are stable, cwd-relative paths so output is reproducible in CI and diffable. -- `refs` per edge comes directly from `ResolvedRefMap` entries and tells AI consumers _which_ references create the dependency, not just that one exists. -- Cycles between files are legal and representable (edges form a general directed graph, not a tree). -- Failed resolutions become nodes with `resolved: false` so the graph honestly shows holes without failing the command. - -## Execution Flow - -Mirrors `stats`, minus bundling (the bundle output is not needed — only the resolution pass): - -``` -handleGraph({ argv, config }) - → getFallbackApisOrExit(argv.apis, config) - → one shared BaseResolver(config.resolve) for the whole invocation - → for each root: - resolver.resolveDocument(rootPath) // parse root document - detect spec + normalized types // same helpers stats uses - resolveDocument({ rootDocument, rootType, externalRefResolver }) - → buildGraph(resolvedRefMaps, roots) // pure function → DependencyGraph - → if --affected-by: filterAffected(graph, files) - → printGraph[format](graph) // stdout -``` - -- One shared `BaseResolver` means files shared between roots are read once (resolver caches by absolute path). -- `buildGraph` iterates `ResolvedRefMap` entries: source file comes from the entry key (`makeRefId(sourceAbsoluteRef, $ref)`), target file from the resolved document's `source.absoluteRef`, cross-file edges identified via `isRemote`. Exact field access is verified against `resolve.ts` during implementation. -- Telemetry parity with other commands: `collectSpecData` is called with each parsed root, and `commandWrapper` handles the rest. - -## `--affected-by` Semantics - -Reverse BFS over edges starting from the given files: collect every file that references them, transitively, up to the roots. The result is the induced subgraph (changed files + all transitive dependents + edges among them), rendered in whichever `--format` is active. - -- Input paths are resolved against cwd to absolute form and matched to node ids; output stays cwd-relative. -- Multiple files: the affected sets are unioned. The flag is passed once per file (`--affected-by a.yaml --affected-by b.yaml`) — the CLI's global `greedy-arrays: false` parser setting means space-separated values after one flag would be read as extra API positionals. -- `stylish` prunes the tree to affected branches, marks the queried files with a `← changed` suffix, and appends a summary line, e.g. `2 of 6 files affected · affected roots: openapi.yaml`. -- A queried file that is not part of the graph produces a **stderr** warning (`schemas/Unused.yaml is not referenced by any of the processed APIs.`) and exit code `0` — for AI Review "nothing depends on this" is a legitimate answer, not an error. If no queried file is in the graph, the output is an empty graph in the chosen format (`stylish` prints `No files affected.`). -- stdout stays pure for `json` and `mermaid` (no banners or progress text) so output can be piped. - -## Output Formats - -### stylish (default) - -One tree per root, root filename as the header line: - -``` -openapi.yaml -├── paths/pets.yaml -│ └── components/schemas/Pet.yaml -└── paths/users.yaml - └── components/schemas/User.yaml - ├── components/schemas/Pet.yaml ↺ - └── components/schemas/missing.yaml ✗ not found -``` - -- `↺` — node already expanded earlier in this tree; children are not repeated. This single rule handles both cycles and fan-in (a schema referenced 50 times prints its subtree once), keeping output linear in the number of edges. -- `✗ not found` — unresolved reference (`resolved: false`). -- `(external)` suffix — http(s) URL nodes. - -### json - -The `DependencyGraph` model serialized as-is (2-space indent): - -```json -{ - "roots": ["openapi.yaml"], - "nodes": [ - { "id": "openapi.yaml", "root": true, "resolved": true }, - { "id": "paths/users.yaml", "resolved": true }, - { "id": "components/schemas/User.yaml", "resolved": true } - ], - "edges": [ - { "from": "openapi.yaml", "to": "paths/users.yaml", "refs": ["paths/users.yaml"] }, - { - "from": "paths/users.yaml", - "to": "components/schemas/User.yaml", - "refs": ["../components/schemas/User.yaml"] - } - ] -} -``` - -### mermaid - -`flowchart LR` with stable sequential node ids and roots highlighted: - -``` -flowchart LR - n0["openapi.yaml"]:::root - n1["paths/users.yaml"] - n2["components/schemas/User.yaml"] - n0 --> n1 - n1 --> n2 - classDef root font-weight:bold -``` - -Labels are double-quoted (Mermaid's mechanism for special characters such as brackets); literal `"` inside a label is escaped as `#quot;`. - -## Error Handling - -| Situation | Behavior | -| -------------------------------------- | ----------------------------------------------------------------------- | -| Root file missing | `getFallbackApisOrExit` reports and exits (existing behavior), exit `1` | -| Root file unparseable | Clear error via `commandWrapper`, exit `1` | -| Broken `$ref` inside the graph | Node with `resolved: false`, command succeeds with exit `0` | -| `--affected-by` file outside the graph | stderr warning, exit `0` | -| Config problems | Standard config error path, exit `2` | - -## File Layout - -``` -packages/cli/src/commands/graph/ -├── index.ts # handleGraph: resolve roots → build → filter → print -├── build-graph.ts # pure: ResolvedRefMap[] + roots → DependencyGraph -├── filter-affected.ts# pure: DependencyGraph + files → induced subgraph -└── print/ - ├── stylish.ts - ├── json.ts - └── mermaid.ts -``` - -Every function carries a concise purpose docstring (repo code-quality standard). No wrapper layers beyond this — handler calls pure functions directly. - -## Testing - -- **Unit** (`packages/cli/src/commands/graph/__tests__/`): - - `build-graph`: edges from a refMap fixture; cycle between two files; external URL node; unresolved ref node. - - `filter-affected`: chain where root `$ref`s B and B `$ref`s C; querying C yields `{root, B, C}`; untouched sibling branch excluded; queried file outside graph → empty result. - - Printers: inline snapshots of all three formats over one small fixture graph. -- **E2E**: one multi-file fixture (root + two path files + one shared schema), snapshots for default tree, `--format=json`, and `--affected-by`, following the existing e2e suite structure. -- Coverage stays above the repo's 71% threshold; no `console.log` added to production paths outside the printers (e2e is snapshot-based). - -## Documentation & Release - -- New page `docs/@v2/commands/graph.md` modeled on `stats.md`: description, usage, options table, examples — including the AI Review scenario (`--affected-by` + `--format=json`). -- Sidebar entry in `docs/@v2/v2.sidebars.yaml`. -- Changeset: `minor` for `@redocly/cli` (new feature; `@redocly/openapi-core` untouched). - -## Decisions Log - -| Decision | Choice | Rationale | -| ------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- | -| Audience | Both human + machine via `--format` | One data model, cheap formatters; follows `stats` precedent | -| Impact query in MVP | Yes, `--affected-by` | It is the stated motivation (AI Review); cheap reverse BFS over already-built edges; output filtering saves tokens | -| Formats | `stylish` + `json` + `mermaid` | Mermaid covers the "graphical" ask and renders natively on GitHub; DOT deferred (YAGNI) | -| Architecture | CLI-only, no core changes | Identical to `stats` pattern; smallest review surface; pure `buildGraph` can move to core later if language-server needs it | -| Multiple roots | Merged graph, lint-style `[apis...]` | Impact analysis must span all configured APIs to be trustworthy | -| Broken refs | Shown, not fatal | Graph reports structure; validation belongs to `lint` | diff --git a/docs/superpowers/specs/2026-06-12-tree-command-rework-design.md b/docs/superpowers/specs/2026-06-12-tree-command-rework-design.md deleted file mode 100644 index c1deee5702..0000000000 --- a/docs/superpowers/specs/2026-06-12-tree-command-rework-design.md +++ /dev/null @@ -1,138 +0,0 @@ -# `redocly tree` Command Rework — Design - -**Date:** 2026-06-12 -**Branch:** `feat/graph-command` (rework on top of the existing implementation; the PR stays open) -**Supersedes:** the file-level-only design in `2026-06-11-graph-command-design.md` (kept for history) -**Status:** Approved - -## Motivation (PR feedback) - -The shipped `graph` command shows only the file-level `$ref` graph, which is useful only for split specs. Review feedback: - -1. The command must be named **`tree`**. -2. The primary case is a spec in **one file**. The command must show the structure of the **OpenAPI document itself** — paths, operations, and their component dependency chains — and `--affected-by` must answer "which paths/operations are impacted" even for a single-file spec. -3. The file-level view is the less useful mode and is demoted behind a flag. - -## Decisions (confirmed with the user) - -- **One command `tree`.** Default mode = internal document structure. `--files` flag = the existing file-level graph, unchanged. -- Stylish depth in default mode: root file → `/pets` → `GET`/`POST` → transitive component chains, with the existing `↺` repeat marker. -- `--affected-by` accepts a component pointer (`#/components/schemas/Pet`), a shorthand (`schemas/Pet`, bare `Pet`), or a file path. Output is the affected subgraph; the summary reports affected operations and paths. - -## Goals - -- `redocly tree [api]` prints the internal structure tree of one API description (any spec type core resolves). -- `--files` preserves today's multi-API file-level graph byte-for-byte (snapshots are the regression guard). -- All three formats (`stylish` default, `json`, `mermaid`) work in both modes; `json`/`mermaid` stdout stays pure. -- `--affected-by` works in both modes; in default mode it reports impacted operations/paths. - -## Non-goals - -- No `packages/core` changes. -- No exploding of operations defined inside a `$ref`'d path-item _file_ — the file node represents them (documented limitation). -- Orphan (unreachable from root) components are pruned from the default-mode graph — unused-component detection stays `lint`'s job. -- No changes to `--files` mode semantics. - -## Node model - -`DependencyGraph`/`GraphEdge` stay as-is. `GraphNode` gains optional fields set only by the structure builder (files mode emits objects identical to today): - -```ts -export type NodeKind = 'root' | 'path' | 'operation' | 'component' | 'file'; - -type GraphNode = { - id: string; - root?: boolean; - external?: boolean; - resolved: boolean; - kind?: NodeKind; // default mode only - file?: string; // cwd-relative source file of the node; default mode only -}; -``` - -Id scheme (id = display label; renderers print ids directly): - -| Node | id | kind | -| --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | ----------- | -| Root API document | `openapi.yaml` (cwd-relative, as in files mode) | `root` | -| Path | `/pets` (unescaped pointer fragment) | `path` | -| Operation | `GET /pets` (uppercased method + space + path) | `operation` | -| Component in root file (OAS3/AsyncAPI/Arazzo) | `schemas/Pet` (`components/` wrapper dropped, no `#/` prefix) | `component` | -| Component in root file (OAS2 sections) | `definitions/Pet`, `parameters/limitParam`, `responses/NotFound`, `securityDefinitions/api_key` | `component` | -| Generic root-level fallback (`webhooks/*`, `channels/*`, `workflows/*`, `servers/0`, …) | first two pointer segments (or one) | `component` | -| Whole external file | `schemas/pet.yaml` (cwd-relative; URLs as-is + `external`) | `file` | -| Component inside another file | `common.yaml#/components/schemas/Pet` (copy-pasteable as `$ref`) | `component` | - -Disambiguation is structural: path ids start with `/`, operation ids contain a space, file ids contain an extension or `#`. - -- Nested target pointers normalize to their top-level component (`#/components/schemas/Pet/properties/x` → `schemas/Pet`). -- Self-edges (recursive schemas) are kept → stylish renders `schemas/Pet ↺` as its own child. -- After building, the graph is pruned to nodes reachable from the root so all three formats agree. -- **Default mode processes exactly one API.** Multiple APIs (args or config fallback) → clear error suggesting a single API or `--files`. Rationale: path/component ids from different documents would collide and merge wrongly. - -## Structure builder - -New `build-structure.ts` + pure `node-id.ts`, using `walkDocument` exactly like `stats` (resolveDocument → normalizeVisitors → walkDocument): - -- `PathItem` / `Operation` enter hooks create the root → path → operation spine. They act only when `rawLocation` is in the root file AND the pointer is exactly `/paths/{p}` (2 segments) or `/paths/{p}/{method}` (3 segments, method ∈ get/put/post/delete/options/head/patch/trace/query/x-query). This stateless pointer check is immune to callback/webhook false positives. For AsyncAPI/Arazzo these visitor keys don't exist in the type map and are silently ignored — the command stays spec-agnostic. -- A `ref` enter hook fires at every `$ref` site. Owner = mapped site location; target = mapped resolved-target location. Edge owner→target collects distinct `$ref` strings (dedup as in files mode). Spine edges carry `refs: []`. -- Ownership mapping (`node-id.ts`): - - `#/paths/~1pets/get/...` → `GET /pets`; `#/paths/~1pets/parameters/0` → `/pets`; `#/components/schemas/User/properties/address` → `schemas/User`; callback sites map to the outer operation; other root-level sites → generic fallback node with a root spine edge. - - Site/target in a non-root file → file node, or `file#/components/...`-style component node when the pointer addresses a component section inside that file. -- Unresolved refs: target id derived from the raw `$ref` (same-file pointer → `mapRootPointer`; uri part → injected `resolveRef(siteFile, uri)`), node upserted `resolved: false` (`✗ not found` marker reused). `isAbsoluteUrl` targets → `external: true`. -- Deterministic output: same codepoint sorting as the files-mode builder. - -## `--affected-by` matching (default mode) - -Pure `match-affected-by.ts`; per input, first rule that matches wins: - -1. **Exact node id** (`schemas/Pet`, `/pets`, `GET /pets`, file ids, URLs). -2. **Pointer form** (starts with `#`): mapped via `mapRootPointer` (`#/components/schemas/Pet`; bonus: `#/paths/~1pets/get`). -3. **File path**: normalized cwd-relative; matches all nodes with `node.file === rel`. Passing the root file itself → the whole tree is affected: `changedIds` = all nodes, the `← changed` marker goes on the root only, stderr note ` is the root document — the whole tree is affected.` -4. **Bare component name** (`Pet`): all `kind: 'component'` nodes whose last `/`-segment equals it. Ambiguous → match ALL + stderr note listing the matches (impact analysis must over-report, not under-report). - -Unknown input → stderr warning (` does not match any path, operation, or component of .`), exit 0. Files mode keeps today's matching and warning text verbatim. - -## Renderer adjustments - -Only `print/stylish.ts` changes. `StylishOptions` becomes `{ changed?: string[]; summary?: string; emptyMessage?: string }` — the handler composes the summary: - -- Files mode: byte-identical summary to today (`N of M files affected · affected roots: ...`). -- Default mode: `N of M operations affected · affected paths: /pets, /users` (counted via `kind`), falling back to `N of M nodes affected` when the document has no operations (AsyncAPI/Arazzo). Empty result: `No nodes affected.` (files mode keeps `No files affected.`). - -`json.ts` / `mermaid.ts` unchanged (`kind`/`file` appear additively in json; mermaid labels are quoted so spaces in `GET /pets` are safe). - -## CLI surface - -```bash -redocly tree [api] # default: internal structure (exactly one API) -redocly tree --files [apis...] # file-level $ref graph (multi-API supported, today's behavior) -redocly tree --format -redocly tree --affected-by [--affected-by ] # repeat per input -redocly tree --config -``` - -- Command name `tree`, description `Display the structure of an API description as a tree.`, env prefix `REDOCLY_CLI_TREE`. -- `TreeArgv` replaces `GraphArgv` in the `CommandArgv` union; `TreeFormat` replaces `GraphFormat` (same values — the `lint.ts` mermaid guard is untouched). -- Exit codes unchanged: 0 success (incl. "affects nothing"), 1 execution error (incl. multi-API in default mode), 2 config error. - -## Error handling - -| Situation | Behavior | -| ------------------------------------- | ----------------------------------------------------------- | -| Multiple APIs in default mode | `exitWithError`: pass a single API or use `--files`, exit 1 | -| Root missing/unparseable | unchanged (clear error, exit 1) | -| Broken `$ref` | `resolved: false` node, exit 0 | -| `--affected-by` input matches nothing | stderr warning, exit 0 | -| Root file passed to `--affected-by` | full tree + root marked, stderr note, exit 0 | - -## Testing - -- Unit: `node-id.ts` (~10 mapping cases incl. `~1`/`~0` escaping, OAS2 sections, foreign files), `build-structure.ts` (~12 cases via the score test-harness pattern: spine enumeration, op→component edges, transitive chains, nested-pointer normalization, path-level params, self-edges, callback attribution, webhook fallback, unresolved, external URL, pruning, OAS2), `match-affected-by.ts` (~7 cases), adapted stylish tests. Existing `build-graph` (6) and `filter-affected` (5) tests survive unchanged. -- E2E: new primary single-file fixture (paths + component chains + fan-in + path-level param + pruned orphan): default stylish, json, pointer input, bare-name input, unknown input; multi-file fixture in default mode (cross-file blend) and with a file input (headline AI-review case); the 4 existing files-mode tests kept with **unchanged snapshot content** (regression guard). - -## Documentation & release - -- `docs/@v2/commands/graph.md` → `tree.md`, rewritten: structure mode first, `--files` section, all `--affected-by` input forms, markers legend, non-OpenAPI note, ref'd-path-item-file limitation. -- Sidebar entry moves to after `translate` (alphabetical); commands index line updated. -- `.changeset/graph-command.md` rewritten in place (still `'@redocly/cli': minor` — the command was never released). From da60c958ebb3ff523576d2cb58a28e1c7edff7cb Mon Sep 17 00:00:00 2001 From: kanoru Date: Mon, 15 Jun 2026 15:34:19 +0300 Subject: [PATCH 24/79] refactor(tree): share toNodeId/byString/OPERATION_METHODS via node-id The absolute-ref to node-id rule, the codepoint sort comparator, and the operation-method set each had duplicate copies across build-graph.ts and build-structure.ts. Move them to node-id.ts as the single source so the two graph builders cannot drift. Also fix build-graph's edge-refs sort to use the codepoint comparator (was the default .sort()), matching the determinism the module documents, and drop a redundant narrating comment in build-structure. No behavior change: 58 tree unit tests and 11 e2e snapshots pass unchanged. --- packages/cli/src/commands/tree/build-graph.ts | 14 ++------ .../cli/src/commands/tree/build-structure.ts | 36 +++++-------------- packages/cli/src/commands/tree/node-id.ts | 18 ++++++++-- 3 files changed, 27 insertions(+), 41 deletions(-) diff --git a/packages/cli/src/commands/tree/build-graph.ts b/packages/cli/src/commands/tree/build-graph.ts index 8469117895..23021a53a2 100644 --- a/packages/cli/src/commands/tree/build-graph.ts +++ b/packages/cli/src/commands/tree/build-graph.ts @@ -1,13 +1,8 @@ -import { isAbsoluteUrl, slash, type Document, type ResolvedRefMap } from '@redocly/openapi-core'; -import * as path from 'node:path'; +import { isAbsoluteUrl, type Document, type ResolvedRefMap } from '@redocly/openapi-core'; +import { byString, toNodeId } from './node-id.js'; import type { DependencyGraph, GraphEdge, GraphNode } from './types.js'; -/** Converts an absolute file path or URL into a stable node id (cwd-relative posix path; URLs as-is). */ -function toNodeId(absoluteRef: string, cwd: string): string { - return isAbsoluteUrl(absoluteRef) ? absoluteRef : slash(path.relative(cwd, absoluteRef)); -} - /** * Builds the file-level dependency graph from the resolver's ref maps of one or more roots. * Only cross-file refs (isRemote) become edges; nodes/edges/refs are sorted for stable output. @@ -56,14 +51,11 @@ export function buildGraph( } } - // Codepoint comparison (not localeCompare): deterministic across Node ICU builds → stable snapshots. - const byString = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0); - return { roots: resolutions.map(({ rootDocument }) => toNodeId(rootDocument.source.absoluteRef, cwd)), nodes: [...nodes.values()].sort((a, b) => byString(a.id, b.id)), edges: [...edges.values()] - .map((edge) => ({ ...edge, refs: [...edge.refs].sort() })) + .map((edge) => ({ ...edge, refs: [...edge.refs].sort(byString) })) .sort((a, b) => byString(a.from, b.from) || byString(a.to, b.to)), }; } diff --git a/packages/cli/src/commands/tree/build-structure.ts b/packages/cli/src/commands/tree/build-structure.ts index 5b22cff6eb..deb34789ca 100644 --- a/packages/cli/src/commands/tree/build-structure.ts +++ b/packages/cli/src/commands/tree/build-structure.ts @@ -1,7 +1,6 @@ import { isAbsoluteUrl, normalizeVisitors, - slash, walkDocument, type Document, type Location, @@ -10,29 +9,18 @@ import { type ResolvedRefMap, type WalkContext, } from '@redocly/openapi-core'; -import * as path from 'node:path'; import { + byString, mapForeignLocation, mapRootPointer, + OPERATION_METHODS, parsePointerSegments, + toNodeId, type MappedNode, } from './node-id.js'; import type { DependencyGraph, GraphEdge, GraphNode } from './types.js'; -const OPERATION_METHODS = new Set([ - 'get', - 'put', - 'post', - 'delete', - 'options', - 'head', - 'patch', - 'trace', - 'query', - 'x-query', -]); - /** * Builds the internal structure graph of one API description: root -> paths -> operations and the * component dependency chains reached through every `$ref`. The result is pruned to nodes reachable @@ -49,7 +37,7 @@ export function buildStructure(options: { const { document, types, resolvedRefMap, ctx, cwd, resolveRef } = options; const rootAbs = document.source.absoluteRef; - const rootId = isAbsoluteUrl(rootAbs) ? rootAbs : slash(path.relative(cwd, rootAbs)); + const rootId = toNodeId(rootAbs, cwd); const nodes = new Map(); const edges = new Map(); @@ -78,10 +66,6 @@ export function buildStructure(options: { edges.set(edgeKey, edge); }; - /** Converts a non-root file's absolute ref into its node id (URLs as-is, else cwd-relative). */ - const toFileId = (absoluteRef: string): string => - isAbsoluteUrl(absoluteRef) ? absoluteRef : slash(path.relative(cwd, absoluteRef)); - /** * Materializes the node for a resolved Location and, when the mapping carries an ancestry, * wires the structural spine `root -> ancestry[0] -> ... -> node` (spine edges carry no refs). @@ -91,7 +75,7 @@ export function buildStructure(options: { const inRootFile = location.source.absoluteRef === rootAbs; const mapped: MappedNode & { file: string } = inRootFile ? { ...mapRootPointer(location.pointer, rootId), file: rootId } - : mapForeignLocation(toFileId(location.source.absoluteRef), location.pointer); + : mapForeignLocation(toNodeId(location.source.absoluteRef, cwd), location.pointer); upsertNode(mapped, true); wireSpine(mapped); @@ -127,9 +111,9 @@ export function buildStructure(options: { mapped = siteFile === rootAbs ? { ...mapRootPointer(pointer, rootId), file: rootId } - : mapForeignLocation(toFileId(siteFile), pointer); + : mapForeignLocation(toNodeId(siteFile, cwd), pointer); } else { - const fileId = toFileId(resolveRef(siteFile, uri)); + const fileId = toNodeId(resolveRef(siteFile, uri), cwd); mapped = fragment !== undefined ? mapForeignLocation(fileId, '#' + fragment) @@ -177,7 +161,6 @@ export function buildStructure(options: { }, }; - // Root node: always present, marks the entry point of the structure. upsertNode({ id: rootId, kind: 'root', file: rootId }, true); nodes.get(rootId)!.root = true; @@ -192,7 +175,7 @@ export function buildStructure(options: { /** * Drops nodes unreachable from the root via directed BFS over the edges, then codepoint-sorts the - * nodes (id), edges (from, then to), and each edge's refs — the same comparator as build-graph.ts. + * nodes (id), edges (from, then to), and each edge's refs with the shared `byString` comparator. */ function prune( rootId: string, @@ -218,9 +201,6 @@ function prune( } } - // Codepoint comparison (not localeCompare): deterministic across Node ICU builds → stable output. - const byString = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0); - return { roots: [rootId], nodes: [...nodes.values()] diff --git a/packages/cli/src/commands/tree/node-id.ts b/packages/cli/src/commands/tree/node-id.ts index d43f2959b6..ee7a478b7c 100644 --- a/packages/cli/src/commands/tree/node-id.ts +++ b/packages/cli/src/commands/tree/node-id.ts @@ -1,8 +1,22 @@ -import { escapePointerFragment, unescapePointerFragment } from '@redocly/openapi-core'; +import { + escapePointerFragment, + isAbsoluteUrl, + slash, + unescapePointerFragment, +} from '@redocly/openapi-core'; +import * as path from 'node:path'; import type { NodeKind } from './types.js'; -const OPERATION_METHODS = new Set([ +/** Codepoint comparison (not localeCompare): deterministic across Node ICU builds → stable output. */ +export const byString = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0); + +/** Converts an absolute file path or URL into a stable node id (cwd-relative posix path; URLs as-is). */ +export function toNodeId(absoluteRef: string, cwd: string): string { + return isAbsoluteUrl(absoluteRef) ? absoluteRef : slash(path.relative(cwd, absoluteRef)); +} + +export const OPERATION_METHODS = new Set([ 'get', 'put', 'post', From c9fe0d7fafb7d5b9fd87a64097f0d00eed90f5ed Mon Sep 17 00:00:00 2001 From: kanoru Date: Mon, 15 Jun 2026 16:29:40 +0300 Subject: [PATCH 25/79] fix: improvments --- .../src/commands/tree/__tests__/print.test.ts | 21 +++++++++++++ .../cli/src/commands/tree/print/mermaid.ts | 4 ++- .../tree/tree-structure-mermaid/snapshot.txt | 31 +++++++++++++++++++ tests/e2e/tree/tree.test.ts | 8 +++++ 4 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/tree/tree-structure-mermaid/snapshot.txt diff --git a/packages/cli/src/commands/tree/__tests__/print.test.ts b/packages/cli/src/commands/tree/__tests__/print.test.ts index 24328e14af..5fb458d279 100644 --- a/packages/cli/src/commands/tree/__tests__/print.test.ts +++ b/packages/cli/src/commands/tree/__tests__/print.test.ts @@ -139,4 +139,25 @@ describe('renderMermaid', () => { 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"]'); + }); }); diff --git a/packages/cli/src/commands/tree/print/mermaid.ts b/packages/cli/src/commands/tree/print/mermaid.ts index dad8833cd2..5760245215 100644 --- a/packages/cli/src/commands/tree/print/mermaid.ts +++ b/packages/cli/src/commands/tree/print/mermaid.ts @@ -3,7 +3,9 @@ import type { DependencyGraph } from '../types.js'; /** Renders the dependency graph as a Mermaid flowchart definition. */ export function renderMermaid(graph: DependencyGraph): string { const mermaidIds = new Map(graph.nodes.map((node, index) => [node.id, `n${index}`])); - const escapeLabel = (label: string) => label.replace(/"/g, '#quot;'); + // 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) { 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..c27fed9588 --- /dev/null +++ b/tests/e2e/tree/tree-structure-mermaid/snapshot.txt @@ -0,0 +1,31 @@ +flowchart LR + n0["/pets"] + n1["/pets/{petId}"] + n2["/users"] + n3["GET /pets"] + n4["GET /pets/{petId}"] + n5["GET /users"] + n6["POST /pets"] + n7["openapi.yaml"]:::root + n8["parameters/PetId"] + n9["schemas/Address"] + n10["schemas/Pet"] + n11["schemas/PetInput"] + n12["schemas/User"] + n0 --> n3 + n0 --> n6 + n1 --> n4 + n1 --> n8 + n2 --> n5 + n3 --> n10 + n4 --> n10 + n5 --> n12 + n6 --> n11 + n7 --> n0 + n7 --> n1 + n7 --> n2 + n10 --> n9 + n11 --> n10 + n12 --> n9 + classDef root font-weight:bold + diff --git a/tests/e2e/tree/tree.test.ts b/tests/e2e/tree/tree.test.ts index ff1cb4e22f..1bef345219 100644 --- a/tests/e2e/tree/tree.test.ts +++ b/tests/e2e/tree/tree.test.ts @@ -71,6 +71,14 @@ describe('tree', () => { ); }); + test('tree should print the document structure as a mermaid diagram', async () => { + const args = getParams(indexEntryPoint, ['tree', 'openapi.yaml', '--format=mermaid']); + const result = getCommandOutput(args, { testPath: singleFilePath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot( + join(folderPath, 'tree-structure-mermaid', 'snapshot.txt') + ); + }); + test('tree should show what a component pointer affects', async () => { const args = getParams(indexEntryPoint, [ 'tree', From 49904e3a35b25add27d4bf476a6d1e9ff0dd7a87 Mon Sep 17 00:00:00 2001 From: kanoru Date: Wed, 17 Jun 2026 11:25:11 +0300 Subject: [PATCH 26/79] refactor: tidy tree renderer sort and trim a restating comment --- packages/cli/src/commands/tree/build-structure.ts | 4 ++-- packages/cli/src/commands/tree/print/stylish.ts | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/tree/build-structure.ts b/packages/cli/src/commands/tree/build-structure.ts index deb34789ca..ad8945ee52 100644 --- a/packages/cli/src/commands/tree/build-structure.ts +++ b/packages/cli/src/commands/tree/build-structure.ts @@ -124,8 +124,8 @@ export function buildStructure(options: { return mapped.id; }; - // PathItem/Operation build the spine; the ref hook wires every dependency edge. Keys absent from - // a non-OpenAPI type map (AsyncAPI/Arazzo) are silently ignored by normalizeVisitors. + // Keys absent from a non-OpenAPI type map (AsyncAPI/Arazzo) are silently ignored by + // normalizeVisitors, so this OAS3-shaped visitor is safe to run against any detected spec. const visitor: Oas3Visitor = { PathItem: { enter(_node, vctx) { diff --git a/packages/cli/src/commands/tree/print/stylish.ts b/packages/cli/src/commands/tree/print/stylish.ts index 20e0b6a317..f87d8c314a 100644 --- a/packages/cli/src/commands/tree/print/stylish.ts +++ b/packages/cli/src/commands/tree/print/stylish.ts @@ -1,3 +1,4 @@ +import { byString } from '../node-id.js'; import type { DependencyGraph } from '../types.js'; export type StylishOptions = { @@ -25,7 +26,7 @@ export function renderStylish(graph: DependencyGraph, options: StylishOptions = childrenByNode.set(edge.from, children); } for (const children of childrenByNode.values()) { - children.sort(); + children.sort(byString); } const nodesById = new Map(graph.nodes.map((node) => [node.id, node])); From a25a1668c4538254d2bc90c26240f2044e3d76e4 Mon Sep 17 00:00:00 2001 From: kanoru Date: Wed, 17 Jun 2026 11:25:26 +0300 Subject: [PATCH 27/79] test: drop redundant tree tests (runtime round-trip, path.resolve variants, duplicate e2e snapshot) --- .../tree/__tests__/match-affected-by.test.ts | 22 +------------------ .../src/commands/tree/__tests__/print.test.ts | 10 --------- .../tree-structure-affected-bare/snapshot.txt | 18 --------------- tests/e2e/tree/tree.test.ts | 8 ------- 4 files changed, 1 insertion(+), 57 deletions(-) delete mode 100644 tests/e2e/tree/tree-structure-affected-bare/snapshot.txt 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 index c8c54d02b4..fd1e4edd70 100644 --- a/packages/cli/src/commands/tree/__tests__/match-affected-by.test.ts +++ b/packages/cli/src/commands/tree/__tests__/match-affected-by.test.ts @@ -65,7 +65,7 @@ describe('matchAffectedBy', () => { }); }); - it('case 4a: file path resolves to a non-root file', () => { + 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'], markerIds: ['common.yaml#/components/schemas/Pet'], @@ -74,26 +74,6 @@ describe('matchAffectedBy', () => { }); }); - it('case 4b: file path with ./ prefix normalizes to same result', () => { - expect(matchAffectedBy(graph, ['./common.yaml'], { cwd: CWD, rootId: ROOT_ID })).toEqual({ - changedIds: ['common.yaml#/components/schemas/Pet'], - markerIds: ['common.yaml#/components/schemas/Pet'], - notes: [], - warnings: [], - }); - }); - - it('case 4c: absolute file path normalizes to same result', () => { - expect(matchAffectedBy(graph, ['/project/common.yaml'], { cwd: CWD, rootId: ROOT_ID })).toEqual( - { - changedIds: ['common.yaml#/components/schemas/Pet'], - markerIds: ['common.yaml#/components/schemas/Pet'], - notes: [], - warnings: [], - } - ); - }); - it('case 5: root file — changedIds gets all ids, markerIds only rootId, note emitted', () => { const result = matchAffectedBy(graph, ['openapi.yaml'], { cwd: CWD, rootId: ROOT_ID }); diff --git a/packages/cli/src/commands/tree/__tests__/print.test.ts b/packages/cli/src/commands/tree/__tests__/print.test.ts index 5fb458d279..e30774d895 100644 --- a/packages/cli/src/commands/tree/__tests__/print.test.ts +++ b/packages/cli/src/commands/tree/__tests__/print.test.ts @@ -1,4 +1,3 @@ -import { renderJson } from '../print/json.js'; import { renderMermaid } from '../print/mermaid.js'; import { renderStylish } from '../print/stylish.js'; import type { DependencyGraph } from '../types.js'; @@ -109,15 +108,6 @@ describe('renderStylish', () => { }); }); -describe('renderJson', () => { - it('serializes the graph model as-is', () => { - const parsed = JSON.parse(renderJson(graph)); - expect(parsed.roots).toEqual(['openapi.yaml']); - expect(parsed.nodes).toHaveLength(7); - expect(parsed.edges).toHaveLength(7); - }); -}); - describe('renderMermaid', () => { it('renders a flowchart with stable ids and a root class', () => { expect(renderMermaid(graph)).toMatchInlineSnapshot(` diff --git a/tests/e2e/tree/tree-structure-affected-bare/snapshot.txt b/tests/e2e/tree/tree-structure-affected-bare/snapshot.txt deleted file mode 100644 index 587cad89ef..0000000000 --- a/tests/e2e/tree/tree-structure-affected-bare/snapshot.txt +++ /dev/null @@ -1,18 +0,0 @@ -openapi.yaml -├── /pets -│ ├── GET /pets -│ │ └── schemas/Pet -│ │ └── schemas/Address ← changed -│ └── POST /pets -│ └── schemas/PetInput -│ └── schemas/Pet ↺ -├── /pets/{petId} -│ └── GET /pets/{petId} -│ └── schemas/Pet ↺ -└── /users - └── GET /users - └── schemas/User - └── schemas/Address ↺ ← changed - -4 of 4 operations affected · affected paths: /pets, /pets/{petId}, /users - diff --git a/tests/e2e/tree/tree.test.ts b/tests/e2e/tree/tree.test.ts index 1bef345219..f5cd9cd578 100644 --- a/tests/e2e/tree/tree.test.ts +++ b/tests/e2e/tree/tree.test.ts @@ -92,14 +92,6 @@ describe('tree', () => { ); }); - test('tree should accept a bare component name', async () => { - const args = getParams(indexEntryPoint, ['tree', 'openapi.yaml', '--affected-by', 'Address']); - const result = getCommandOutput(args, { testPath: singleFilePath }); - await expect(cleanupOutput(result)).toMatchFileSnapshot( - join(folderPath, 'tree-structure-affected-bare', 'snapshot.txt') - ); - }); - test('tree should warn for an unknown affected-by input', async () => { const args = getParams(indexEntryPoint, [ 'tree', From d63acb4aa460ca551c81484c3692324a1bc37679 Mon Sep 17 00:00:00 2001 From: kanoru Date: Wed, 17 Jun 2026 11:25:38 +0300 Subject: [PATCH 28/79] docs: clarify --affected-by per mode and JSON field scope in tree docs --- docs/@v2/commands/tree.md | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/@v2/commands/tree.md b/docs/@v2/commands/tree.md index 5eb6013940..126e7fb3e4 100644 --- a/docs/@v2/commands/tree.md +++ b/docs/@v2/commands/tree.md @@ -24,16 +24,16 @@ With no API argument, the command takes the API from the Redocly configuration f ## 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. | -| --affected-by | [string] | Show only the part of the tree affected by changes to the given components, paths, or files. Repeat the option to pass several values: `--affected-by Pet --affected-by /users`. | -| --config | string | Specify the path to the [Redocly configuration file](../configuration/index.md). | -| --files | boolean | Show the file-level `$ref` graph instead of the document structure. | -| --format | string | Output format: `stylish` (default, tree view), `json`, or `mermaid`. | -| --help | boolean | Show help. | -| --lint-config | string | Specify the severity level for the configuration file.
**Possible values:** `warn`, `error`, `off`. Default value is `warn`. | -| --version | boolean | Show version number. | +| 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. | +| --affected-by | [string] | Show only the part of the tree affected by the given changes. The default view accepts a JSON pointer, shorthand pointer, bare component name, or file path; `--files` mode accepts file paths only. Repeat the option to pass several values: `--affected-by Pet --affected-by /users`. | +| --config | string | Specify the path to the [Redocly configuration file](../configuration/index.md). | +| --files | boolean | Show the file-level `$ref` graph instead of the document structure. | +| --format | string | Output format: `stylish` (default, tree view), `json`, or `mermaid`. | +| --help | boolean | Show help. | +| --lint-config | string | Specify the severity level for the configuration file.
**Possible values:** `warn`, `error`, `off`. Default value is `warn`. | +| --version | boolean | Show version number. | ## Examples @@ -72,7 +72,7 @@ For multi-file APIs, components living in other files appear as file nodes (for ### Find what a change affects -Pass a component pointer, name, or file path to `--affected-by` to see only the impacted part of the tree — useful in CI and automated review. +Pass one or more components, paths, or files to `--affected-by` to see only the impacted part of the tree: ```bash redocly tree openapi.yaml --affected-by '#/components/schemas/Address' @@ -101,7 +101,7 @@ openapi.yaml `--affected-by` accepts several input forms: - Full JSON pointer: `#/components/schemas/Address` -- Shorthand pointer: `schemas/Address` +- Shorthand pointer (the node id, without `#/components/`): `schemas/Address` - Bare component name: `Address` — ambiguous bare names match all candidates and print a note to stderr (impact analysis over-reports rather than under-reports) - A file path (for multi-file specs): `schemas/address.yaml` - The root file itself: the whole tree is affected @@ -116,7 +116,7 @@ Unknown inputs print a warning to stderr and exit with code `0`. redocly tree openapi.yaml --format=json ``` -Prints the structure as JSON with `roots`, `nodes` (including `kind`, `file`, `resolved`, and `external` fields), and `edges` (including the exact `$ref` strings). Only the JSON is written to stdout, so the output is safe to pipe. +Prints the graph as JSON with `roots`, `nodes` (`resolved` and `external` on every node; `kind` and `file` in the default view only), and `edges` (with the exact `$ref` strings). Only the JSON is written to stdout, so the output is safe to pipe. ```bash redocly tree openapi.yaml --format=mermaid @@ -140,4 +140,4 @@ openapi.yaml └── components/schemas/Pet.yaml ↺ ``` -This is the multi-file `$ref` view: it shows which files reference which other files. The `--files` flag supports multiple APIs in a single run. +Unlike the default view, `--files` accepts multiple APIs in a single run (their graphs merge). In this mode `--affected-by` takes file paths, and the summary reports affected files and roots. From d9eb1f4fd6d644f621494310b6812db8e786d781 Mon Sep 17 00:00:00 2001 From: kanoru Date: Wed, 17 Jun 2026 11:50:54 +0300 Subject: [PATCH 29/79] test: cover multi-API rejection in default view and multi-API --files merge --- tests/e2e/tree/tree-files-multi-api/snapshot.txt | 14 ++++++++++++++ tests/e2e/tree/tree-multi-api-error/snapshot.txt | 3 +++ tests/e2e/tree/tree-multi-file/admin.yaml | 7 +++++++ tests/e2e/tree/tree.test.ts | 16 ++++++++++++++++ 4 files changed, 40 insertions(+) create mode 100644 tests/e2e/tree/tree-files-multi-api/snapshot.txt create mode 100644 tests/e2e/tree/tree-multi-api-error/snapshot.txt create mode 100644 tests/e2e/tree/tree-multi-file/admin.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..abcd1c48fd --- /dev/null +++ b/tests/e2e/tree/tree-files-multi-api/snapshot.txt @@ -0,0 +1,14 @@ +openapi.yaml +├── paths/pets.yaml +│ └── components/schemas/Pet.yaml +└── paths/users.yaml + └── components/schemas/User.yaml + ├── components/schemas/Address.yaml + └── components/schemas/Pet.yaml ↺ + +admin.yaml +└── paths/users.yaml + └── components/schemas/User.yaml + ├── components/schemas/Address.yaml + └── components/schemas/Pet.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-multi-file/admin.yaml b/tests/e2e/tree/tree-multi-file/admin.yaml new file mode 100644 index 0000000000..9e814bd150 --- /dev/null +++ b/tests/e2e/tree/tree-multi-file/admin.yaml @@ -0,0 +1,7 @@ +openapi: 3.0.3 +info: + title: Admin API + version: 1.0.0 +paths: + /admin/users: + $ref: paths/users.yaml diff --git a/tests/e2e/tree/tree.test.ts b/tests/e2e/tree/tree.test.ts index f5cd9cd578..b5ca5f76f7 100644 --- a/tests/e2e/tree/tree.test.ts +++ b/tests/e2e/tree/tree.test.ts @@ -125,4 +125,20 @@ describe('tree', () => { join(folderPath, 'tree-structure-affected-file', 'snapshot.txt') ); }); + + test('tree should reject multiple APIs in the default view', async () => { + const args = getParams(indexEntryPoint, ['tree', 'openapi.yaml', 'admin.yaml']); + const result = getCommandOutput(args, { testPath: fixturePath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot( + join(folderPath, 'tree-multi-api-error', 'snapshot.txt') + ); + }); + + test('tree --files should merge multiple APIs into one graph', async () => { + const args = getParams(indexEntryPoint, ['tree', 'openapi.yaml', 'admin.yaml', '--files']); + const result = getCommandOutput(args, { testPath: fixturePath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot( + join(folderPath, 'tree-files-multi-api', 'snapshot.txt') + ); + }); }); From 2bfb3391cad401563ef1eb681477b0413228dbdc Mon Sep 17 00:00:00 2001 From: kanoru Date: Wed, 17 Jun 2026 16:59:35 +0300 Subject: [PATCH 30/79] =?UTF-8?q?fix:=20tidy=20tree=20command=20=E2=80=94?= =?UTF-8?q?=20share=20mode=20context,=20dedupe=20location=20mapping,=20tri?= =?UTF-8?q?m=20comments=20to=20project=20style?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/@v2/commands/tree.md | 4 +- packages/cli/src/commands/tree/build-graph.ts | 5 -- .../cli/src/commands/tree/build-structure.ts | 48 ++++--------------- .../cli/src/commands/tree/filter-affected.ts | 6 +-- packages/cli/src/commands/tree/index.ts | 33 +++++-------- .../src/commands/tree/match-affected-by.ts | 16 +------ packages/cli/src/commands/tree/node-id.ts | 4 -- packages/cli/src/commands/tree/print/json.ts | 1 - .../cli/src/commands/tree/print/mermaid.ts | 1 - .../cli/src/commands/tree/print/stylish.ts | 5 -- packages/cli/src/commands/tree/types.ts | 3 -- 11 files changed, 26 insertions(+), 100 deletions(-) diff --git a/docs/@v2/commands/tree.md b/docs/@v2/commands/tree.md index 126e7fb3e4..e4479e5e0e 100644 --- a/docs/@v2/commands/tree.md +++ b/docs/@v2/commands/tree.md @@ -122,7 +122,7 @@ Prints the graph as JSON with `roots`, `nodes` (`resolved` and `external` on eve redocly tree openapi.yaml --format=mermaid ``` -Prints a [Mermaid](https://mermaid.js.org/) `flowchart` definition. GitHub renders Mermaid code blocks in Markdown automatically, so you can paste the output into a pull request comment or documentation page to get a diagram. +Prints a [Mermaid](https://mermaid.js.org/) `flowchart` definition. ### File-level graph @@ -140,4 +140,4 @@ openapi.yaml └── components/schemas/Pet.yaml ↺ ``` -Unlike the default view, `--files` accepts multiple APIs in a single run (their graphs merge). In this mode `--affected-by` takes file paths, and the summary reports affected files and roots. +`--files` shows only which files reference which other files — not the paths, operations, and components inside them. (The default view already traverses those, following `$ref`s across files.) It also accepts multiple APIs in one run, merging their graphs; in this mode `--affected-by` takes file paths and the summary counts affected files and roots. diff --git a/packages/cli/src/commands/tree/build-graph.ts b/packages/cli/src/commands/tree/build-graph.ts index 23021a53a2..73633e228f 100644 --- a/packages/cli/src/commands/tree/build-graph.ts +++ b/packages/cli/src/commands/tree/build-graph.ts @@ -3,10 +3,6 @@ import { isAbsoluteUrl, type Document, type ResolvedRefMap } from '@redocly/open import { byString, toNodeId } from './node-id.js'; import type { DependencyGraph, GraphEdge, GraphNode } from './types.js'; -/** - * Builds the file-level dependency graph from the resolver's ref maps of one or more roots. - * Only cross-file refs (isRemote) become edges; nodes/edges/refs are sorted for stable output. - */ export function buildGraph( resolutions: Array<{ rootDocument: Document; refMap: ResolvedRefMap }>, options: { cwd: string; resolveRef: (base: string, uri: string) => string } @@ -15,7 +11,6 @@ export function buildGraph( const nodes = new Map(); const edges = new Map(); - /** Merges-or-creates a node, OR-ing its resolved/root/external flags. */ const upsertNode = (id: string, resolved: boolean, root?: boolean) => { const node = nodes.get(id) ?? { id, resolved: false }; if (resolved) node.resolved = true; diff --git a/packages/cli/src/commands/tree/build-structure.ts b/packages/cli/src/commands/tree/build-structure.ts index ad8945ee52..f44e53d01b 100644 --- a/packages/cli/src/commands/tree/build-structure.ts +++ b/packages/cli/src/commands/tree/build-structure.ts @@ -21,11 +21,6 @@ import { } from './node-id.js'; import type { DependencyGraph, GraphEdge, GraphNode } from './types.js'; -/** - * Builds the internal structure graph of one API description: root -> paths -> operations and the - * component dependency chains reached through every `$ref`. The result is pruned to nodes reachable - * from the root and sorted by codepoint so all three renderers agree byte-for-byte. - */ export function buildStructure(options: { document: Document; types: Record; @@ -42,11 +37,6 @@ export function buildStructure(options: { const nodes = new Map(); const edges = new Map(); - /** - * Adds or updates a node. `resolved` is OR-ed; `kind`/`file` take the latest mapping — - * distinct mappings of one id are expected to agree (a component literally named like a - * sibling file path is the known, accepted exception: last writer in document order wins). - */ const upsertNode = (mapped: MappedNode & { file: string }, resolved: boolean) => { const node = nodes.get(mapped.id) ?? { id: mapped.id, resolved: false }; if (resolved) node.resolved = true; @@ -56,7 +46,6 @@ export function buildStructure(options: { nodes.set(mapped.id, node); }; - /** Adds (or extends) a directed edge, deduping by `from -> to` and collecting distinct refs. */ const addEdge = (from: string, to: string, refString?: string) => { const edgeKey = `${from} -> ${to}`; const edge = edges.get(edgeKey) ?? { from, to, refs: [] }; @@ -66,23 +55,18 @@ export function buildStructure(options: { edges.set(edgeKey, edge); }; - /** - * Materializes the node for a resolved Location and, when the mapping carries an ancestry, - * wires the structural spine `root -> ancestry[0] -> ... -> node` (spine edges carry no refs). - * Returns the node id so callers can attach `$ref` edges to it. - */ - const nodeFor = (location: Location): string => { - const inRootFile = location.source.absoluteRef === rootAbs; - const mapped: MappedNode & { file: string } = inRootFile - ? { ...mapRootPointer(location.pointer, rootId), file: rootId } - : mapForeignLocation(toNodeId(location.source.absoluteRef, cwd), location.pointer); + const mapByFile = (absoluteRef: string, pointer: string): MappedNode & { file: string } => + absoluteRef === rootAbs + ? { ...mapRootPointer(pointer, rootId), file: rootId } + : mapForeignLocation(toNodeId(absoluteRef, cwd), pointer); + const nodeFor = (location: Location): string => { + const mapped = mapByFile(location.source.absoluteRef, location.pointer); upsertNode(mapped, true); wireSpine(mapped); return mapped.id; }; - /** Wires root -> ...ancestry -> node spine edges when the mapping requests a structural link. */ const wireSpine = (mapped: MappedNode) => { if (mapped.ancestry === undefined) return; let previous = rootId; @@ -94,11 +78,8 @@ export function buildStructure(options: { addEdge(previous, mapped.id); }; - /** - * Derives the target id for an unresolved `$ref` from its raw string: a same-file fragment maps - * through the root/foreign pointer mappers; a uri part resolves against the ref site's file. - * The node is upserted as unresolved (and external for URLs). - */ + // Unresolved `$ref`: derive the target id from the raw string — a same-file fragment, or a uri + // resolved against the ref site's file. const unresolvedTargetId = (siteLocation: Location, refString: string): string => { const hashIndex = refString.indexOf('#'); const uri = hashIndex === -1 ? refString : refString.slice(0, hashIndex); @@ -107,11 +88,7 @@ export function buildStructure(options: { let mapped: MappedNode & { file: string }; if (uri === '') { - const pointer = '#' + (fragment ?? '/'); - mapped = - siteFile === rootAbs - ? { ...mapRootPointer(pointer, rootId), file: rootId } - : mapForeignLocation(toNodeId(siteFile, cwd), pointer); + mapped = mapByFile(siteFile, '#' + (fragment ?? '/')); } else { const fileId = toNodeId(resolveRef(siteFile, uri), cwd); mapped = @@ -124,8 +101,6 @@ export function buildStructure(options: { return mapped.id; }; - // Keys absent from a non-OpenAPI type map (AsyncAPI/Arazzo) are silently ignored by - // normalizeVisitors, so this OAS3-shaped visitor is safe to run against any detected spec. const visitor: Oas3Visitor = { PathItem: { enter(_node, vctx) { @@ -173,10 +148,7 @@ export function buildStructure(options: { return prune(rootId, nodes, edges); } -/** - * Drops nodes unreachable from the root via directed BFS over the edges, then codepoint-sorts the - * nodes (id), edges (from, then to), and each edge's refs with the shared `byString` comparator. - */ +/** Drops nodes unreachable from the root, then codepoint-sorts nodes/edges/refs for stable output. */ function prune( rootId: string, nodes: Map, diff --git a/packages/cli/src/commands/tree/filter-affected.ts b/packages/cli/src/commands/tree/filter-affected.ts index a3014da5e6..e8db8bf64a 100644 --- a/packages/cli/src/commands/tree/filter-affected.ts +++ b/packages/cli/src/commands/tree/filter-affected.ts @@ -1,10 +1,6 @@ import type { DependencyGraph } from './types.js'; -/** - * Returns the induced subgraph affected by changes to the given files: - * the changed nodes plus every transitive dependent (reverse closure up to the roots). - * `changedIds` must already be node ids of the graph (cwd-relative paths). - */ +/** Returns `changedIds` plus every node that transitively depends on them (reverse closure up to the roots). */ export function filterAffected(graph: DependencyGraph, changedIds: string[]): DependencyGraph { const dependentsByTarget = new Map(); for (const edge of graph.edges) { diff --git a/packages/cli/src/commands/tree/index.ts b/packages/cli/src/commands/tree/index.ts index c24763a2d1..6afcd5baf9 100644 --- a/packages/cli/src/commands/tree/index.ts +++ b/packages/cli/src/commands/tree/index.ts @@ -15,7 +15,7 @@ import { } from '@redocly/openapi-core'; import * as path from 'node:path'; -import type { VerifyConfigOptions } from '../../types.js'; +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'; @@ -35,7 +35,14 @@ export type TreeArgv = { files?: boolean; } & VerifyConfigOptions; -/** Resolves the given API descriptions and prints their dependency tree. */ +type TreeModeContext = { + argv: TreeArgv; + config: CommandArgs['config']; + collectSpecData: CommandArgs['collectSpecData']; + externalRefResolver: BaseResolver; + cwd: string; +}; + export async function handleTree({ argv, config, collectSpecData }: CommandArgs) { const apis = await getFallbackApisOrExit(argv.apis, config); const externalRefResolver = new BaseResolver(config.resolve); @@ -61,7 +68,6 @@ export async function handleTree({ argv, config, collectSpecData }: CommandArgs< }); } -/** Loads and resolves one API description: parses the root, detects the spec, and resolves all refs. */ async function resolveApi({ apiPath, config, @@ -93,7 +99,6 @@ async function resolveApi({ return { rootDocument, specVersion, types, refMap }; } -/** Resolves all given APIs and prints their file-level $ref dependency graph. */ async function handleFilesMode({ apis, argv, @@ -101,14 +106,7 @@ async function handleFilesMode({ collectSpecData, externalRefResolver, cwd, -}: { - apis: Array<{ path: string }>; - argv: TreeArgv; - config: CommandArgs['config']; - collectSpecData: CommandArgs['collectSpecData']; - externalRefResolver: BaseResolver; - cwd: string; -}): Promise { +}: TreeModeContext & { apis: Entrypoint[] }): Promise { const resolutions: Array<{ rootDocument: Document; refMap: ResolvedRefMap }> = []; for (const { path: apiPath } of apis) { const { rootDocument, refMap } = await resolveApi({ @@ -150,7 +148,6 @@ async function handleFilesMode({ renderOutput(printedGraph, argv.format, stylishOptions); } -/** Resolves a single API and prints its internal document structure tree. */ async function handleStructureMode({ api, argv, @@ -158,14 +155,7 @@ async function handleStructureMode({ collectSpecData, externalRefResolver, cwd, -}: { - api: { path: string }; - argv: TreeArgv; - config: CommandArgs['config']; - collectSpecData: CommandArgs['collectSpecData']; - externalRefResolver: BaseResolver; - cwd: string; -}): Promise { +}: TreeModeContext & { api: Entrypoint }): Promise { const { rootDocument, specVersion, @@ -233,7 +223,6 @@ async function handleStructureMode({ renderOutput(printedGraph, argv.format, stylishOptions); } -/** Emits the graph in the requested format to logger.output. */ function renderOutput( graph: DependencyGraph, format: TreeFormat, diff --git a/packages/cli/src/commands/tree/match-affected-by.ts b/packages/cli/src/commands/tree/match-affected-by.ts index f9ee5e4b95..1e70fec410 100644 --- a/packages/cli/src/commands/tree/match-affected-by.ts +++ b/packages/cli/src/commands/tree/match-affected-by.ts @@ -5,17 +5,12 @@ import { mapRootPointer } from './node-id.js'; import type { DependencyGraph } from './types.js'; export type AffectedByMatch = { - /** Node ids to seed the reverse-closure filter with. */ changedIds: string[]; - /** Node ids that get the `← changed` marker in stylish output. */ markerIds: string[]; - /** Informational stderr notes (root-file expansion, ambiguous matches). */ notes: string[]; - /** Stderr warnings for inputs that matched nothing. */ warnings: string[]; }; -/** Matches raw --affected-by inputs (node id, pointer, file path, or bare component name) against structure-graph nodes. */ export function matchAffectedBy( graph: DependencyGraph, inputs: string[], @@ -29,7 +24,6 @@ export function matchAffectedBy( const notes: string[] = []; const warnings: string[] = []; - /** Appends ids to changedSet and markerSet, preserving first-seen order. */ function addIds(ids: string[]): void { for (const id of ids) { changedSet.add(id); @@ -38,17 +32,15 @@ export function matchAffectedBy( } for (const input of inputs) { - // Pre-compute the cwd-relative path for every input; used in Rules 1 and 3. const rel = slash(path.relative(cwd, path.resolve(cwd, input))); - // Rule 1: exact node id — but skip when the input also resolves to the root file, - // so that passing the root filename triggers Rule 3's whole-tree expansion instead. + // Exact id wins — unless the input is the root file itself, which must fall through to the + // whole-tree branch below rather than matching only the root node. if (nodeIds.has(input) && rel !== rootId) { addIds([input]); continue; } - // Rule 2: pointer form if (input.startsWith('#')) { const mapped = mapRootPointer(input, rootId); if (nodeIds.has(mapped.id)) { @@ -57,9 +49,7 @@ export function matchAffectedBy( } } - // Rule 3: file path if (rel === rootId) { - // Special: entire tree is affected for (const node of graph.nodes) { changedSet.add(node.id); } @@ -73,7 +63,6 @@ export function matchAffectedBy( continue; } - // Rule 4: bare component name (no '/', no '#') if (!input.includes('/') && !input.includes('#')) { const componentMatches = graph.nodes .filter((n) => n.kind === 'component') @@ -90,7 +79,6 @@ export function matchAffectedBy( } } - // No rule matched warnings.push(`${input} does not match any path, operation, or component of ${rootId}.`); } diff --git a/packages/cli/src/commands/tree/node-id.ts b/packages/cli/src/commands/tree/node-id.ts index ee7a478b7c..6866f81b79 100644 --- a/packages/cli/src/commands/tree/node-id.ts +++ b/packages/cli/src/commands/tree/node-id.ts @@ -11,7 +11,6 @@ import type { NodeKind } from './types.js'; /** Codepoint comparison (not localeCompare): deterministic across Node ICU builds → stable output. */ export const byString = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0); -/** Converts an absolute file path or URL into a stable node id (cwd-relative posix path; URLs as-is). */ export function toNodeId(absoluteRef: string, cwd: string): string { return isAbsoluteUrl(absoluteRef) ? absoluteRef : slash(path.relative(cwd, absoluteRef)); } @@ -43,7 +42,6 @@ export type MappedNode = { ancestry?: string[]; }; -/** Splits a JSON pointer like '#/paths/~1pets/get' into unescaped segments: ['paths', '/pets', 'get']. */ export function parsePointerSegments(pointer: string): string[] { return pointer .replace(/^#?\/?/, '') @@ -52,7 +50,6 @@ export function parsePointerSegments(pointer: string): string[] { .map(unescapePointerFragment); } -/** Maps a pointer within the root document to the tree node that owns it. */ export function mapRootPointer(pointer: string, rootId: string): MappedNode { const segments = parsePointerSegments(pointer); if (segments.length === 0) { @@ -78,7 +75,6 @@ export function mapRootPointer(pointer: string, rootId: string): MappedNode { }; } -/** Maps a location in a non-root file to a component inside it or to the whole file. */ export function mapForeignLocation(fileId: string, pointer: string): MappedNode & { file: string } { const segments = parsePointerSegments(pointer); const componentDepth = diff --git a/packages/cli/src/commands/tree/print/json.ts b/packages/cli/src/commands/tree/print/json.ts index e7a0a73c26..31bd8e42a7 100644 --- a/packages/cli/src/commands/tree/print/json.ts +++ b/packages/cli/src/commands/tree/print/json.ts @@ -1,6 +1,5 @@ import type { DependencyGraph } from '../types.js'; -/** Serializes the dependency graph as pretty-printed JSON. */ export function renderJson(graph: DependencyGraph): string { return JSON.stringify(graph, null, 2); } diff --git a/packages/cli/src/commands/tree/print/mermaid.ts b/packages/cli/src/commands/tree/print/mermaid.ts index 5760245215..ad2bafb2b8 100644 --- a/packages/cli/src/commands/tree/print/mermaid.ts +++ b/packages/cli/src/commands/tree/print/mermaid.ts @@ -1,6 +1,5 @@ import type { DependencyGraph } from '../types.js'; -/** Renders the dependency graph as a Mermaid flowchart definition. */ 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 `#` diff --git a/packages/cli/src/commands/tree/print/stylish.ts b/packages/cli/src/commands/tree/print/stylish.ts index f87d8c314a..768c78e339 100644 --- a/packages/cli/src/commands/tree/print/stylish.ts +++ b/packages/cli/src/commands/tree/print/stylish.ts @@ -2,11 +2,8 @@ import { byString } from '../node-id.js'; import type { DependencyGraph } from '../types.js'; export type StylishOptions = { - /** Node ids queried via --affected-by that exist in the graph. */ changed?: string[]; - /** Pre-composed summary line; appended after a blank line when set. */ summary?: string; - /** Message returned for an empty graph. */ emptyMessage?: string; }; @@ -33,7 +30,6 @@ export function renderStylish(graph: DependencyGraph, options: StylishOptions = const changed = new Set(options.changed ?? []); const lines: string[] = []; - /** Formats one node line: id plus external/broken/repeat/changed markers. */ const label = (id: string, isRepeat: boolean): string => { const node = nodesById.get(id); let text = id; @@ -44,7 +40,6 @@ export function renderStylish(graph: DependencyGraph, options: StylishOptions = return text; }; - /** Recursively prints the children of a node with tree connectors. */ const renderSubtree = (id: string, prefix: string, printed: Set) => { const children = childrenByNode.get(id) ?? []; children.forEach((child, index) => { diff --git a/packages/cli/src/commands/tree/types.ts b/packages/cli/src/commands/tree/types.ts index cb97dd93c6..53fb78303a 100644 --- a/packages/cli/src/commands/tree/types.ts +++ b/packages/cli/src/commands/tree/types.ts @@ -3,11 +3,8 @@ export type TreeFormat = 'stylish' | 'json' | 'mermaid'; export type NodeKind = 'root' | 'path' | 'operation' | 'component' | 'file'; export type GraphNode = { - /** Path relative to cwd; http(s) refs keep the full URL. */ id: string; - /** Entry-point API file. */ root?: boolean; - /** Node is an http(s) URL, not a local file. */ external?: boolean; /** False: the file is referenced but could not be loaded. */ resolved: boolean; From 619ada11a315734d1f3dda959e0e48163fbc38ee Mon Sep 17 00:00:00 2001 From: kanoru Date: Wed, 17 Jun 2026 18:30:52 +0300 Subject: [PATCH 31/79] fix: remove comments --- .../tree/__tests__/build-graph.test.ts | 3 --- .../tree/__tests__/build-structure.test.ts | 27 ++----------------- .../tree/__tests__/match-affected-by.test.ts | 4 --- 3 files changed, 2 insertions(+), 32 deletions(-) diff --git a/packages/cli/src/commands/tree/__tests__/build-graph.test.ts b/packages/cli/src/commands/tree/__tests__/build-graph.test.ts index 39caac5337..13caf36d1e 100644 --- a/packages/cli/src/commands/tree/__tests__/build-graph.test.ts +++ b/packages/cli/src/commands/tree/__tests__/build-graph.test.ts @@ -5,12 +5,10 @@ import { buildGraph } from '../build-graph.js'; const CWD = '/project'; -/** Creates a minimal core Document for a given absolute path or URL. */ function makeDocument(absoluteRef: string): Document { return { source: new Source(absoluteRef, ''), parsed: {} }; } -/** Creates a successfully resolved cross-file ResolvedRefMap entry value. */ function resolvedEntry(targetAbsoluteRef: string, isRemote = true) { return { resolved: true as const, @@ -21,7 +19,6 @@ function resolvedEntry(targetAbsoluteRef: string, isRemote = true) { }; } -/** Resolves a $ref uri against the source file directory, like BaseResolver.resolveExternalRef. */ const resolveRef = (base: string, uri: string) => path.resolve(path.dirname(base), uri); describe('buildGraph', () => { diff --git a/packages/cli/src/commands/tree/__tests__/build-structure.test.ts b/packages/cli/src/commands/tree/__tests__/build-structure.test.ts index 87f55bbf5b..65583f84d8 100644 --- a/packages/cli/src/commands/tree/__tests__/build-structure.test.ts +++ b/packages/cli/src/commands/tree/__tests__/build-structure.test.ts @@ -6,7 +6,6 @@ import { resolveDocument, Source, type Document, - type ResolvedRefMap, type WalkContext, } from '@redocly/openapi-core'; import * as path from 'node:path'; @@ -17,27 +16,18 @@ import type { DependencyGraph } from '../types.js'; const CWD = '/project'; const ROOT_ABS = '/project/openapi.yaml'; -/** The value type stored in a ResolvedRefMap (not exported from the barrel, so derived here). */ -type ResolvedRef = ResolvedRefMap extends Map ? V : never; - -/** Resolves and builds the structure graph for a parsed root document, like the real command does. */ async function structureOf( parsed: Record, - options?: { - mutateRefMap?: (refMap: ResolvedRefMap) => void; - externalRefResolver?: BaseResolver; - } + externalRefResolver: BaseResolver = new BaseResolver() ): Promise { const document = { source: new Source(ROOT_ABS, ''), parsed } as Document; const specVersion = detectSpec(parsed); const types = normalizeTypes(getTypes(specVersion), {}); - const externalRefResolver = options?.externalRefResolver ?? new BaseResolver(); const resolvedRefMap = await resolveDocument({ rootDocument: document, rootType: types.Root, externalRefResolver, }); - options?.mutateRefMap?.(resolvedRefMap); const ctx = { problems: [], specVersion, visitorsData: {} } as unknown as WalkContext; return buildStructure({ document, @@ -49,7 +39,6 @@ async function structureOf( }); } -/** Returns the refs of the edge from `from` to `to`, or undefined when the edge is absent. */ function edgeRefs(graph: DependencyGraph, from: string, to: string): string[] | undefined { return graph.edges.find((edge) => edge.from === from && edge.to === to)?.refs; } @@ -348,19 +337,7 @@ describe('buildStructure', () => { }, }, }, - { - externalRefResolver: new OfflineResolver(), - mutateRefMap: (refMap) => { - // Make the resolution deterministic regardless of how the offline resolver populated it. - refMap.set(ROOT_ABS + '::' + URL_REF, { - resolved: true, - isRemote: true, - node: {}, - nodePointer: '#/components/schemas/S', - document: { source: new Source('https://example.com/shared.yaml', ''), parsed: {} }, - } as ResolvedRef); - }, - } + new OfflineResolver() ); expect(graph.nodes).toContainEqual({ 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 index fd1e4edd70..9af8b9c9e5 100644 --- a/packages/cli/src/commands/tree/__tests__/match-affected-by.test.ts +++ b/packages/cli/src/commands/tree/__tests__/match-affected-by.test.ts @@ -35,8 +35,6 @@ describe('matchAffectedBy', () => { }); it('case 2: exact id wins over bare-name logic — no ambiguity note', () => { - // 'schemas/Pet' is an exact id match; even though 'Pet' (bare) would match multiple, - // the exact match short-circuits and no ambiguity note is emitted. expect(matchAffectedBy(graph, ['schemas/Pet'], { cwd: CWD, rootId: ROOT_ID })).toEqual({ changedIds: ['schemas/Pet'], markerIds: ['schemas/Pet'], @@ -89,7 +87,6 @@ describe('matchAffectedBy', () => { it('case 6a: bare component name matching multiple — includes all + ambiguity note', () => { const result = matchAffectedBy(graph, ['Pet'], { cwd: CWD, rootId: ROOT_ID }); - // All three Pet components in the graph expect(result.changedIds).toEqual([ 'common.yaml#/components/schemas/Pet', 'parameters/Pet', @@ -141,7 +138,6 @@ describe('matchAffectedBy', () => { 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 }); - // Pet (bare) matches 3 ids; schemas/Pet is already among them → deduped expect(result.changedIds).toEqual([ 'common.yaml#/components/schemas/Pet', 'parameters/Pet', From a9845ec3d9d1ca2f76abec11314f5aed79a62586 Mon Sep 17 00:00:00 2001 From: kanoru Date: Mon, 22 Jun 2026 12:26:53 +0300 Subject: [PATCH 32/79] fix: refactoring --- .changeset/graph-command.md | 2 +- packages/cli/src/commands/tree/build-graph.ts | 8 +- .../cli/src/commands/tree/build-structure.ts | 75 +++++++------------ .../cli/src/commands/tree/filter-affected.ts | 37 +++++---- packages/cli/src/commands/tree/node-id.ts | 3 +- .../cli/src/commands/tree/print/stylish.ts | 10 +-- 6 files changed, 61 insertions(+), 74 deletions(-) diff --git a/.changeset/graph-command.md b/.changeset/graph-command.md index c64de4b80f..39465609e2 100644 --- a/.changeset/graph-command.md +++ b/.changeset/graph-command.md @@ -2,4 +2,4 @@ '@redocly/cli': minor --- -Added the `tree` command that displays the structure of an API description — paths, operations, and their component dependency chains — as `stylish` (tree), `json`, or `mermaid` output. The `--affected-by` option filters the tree to what is impacted by a change to a component, path, or file, and `--files` switches to the file-level `$ref` graph. +Added the `tree` command that displays the structure of an API description — its paths, operations, and component dependency chains. diff --git a/packages/cli/src/commands/tree/build-graph.ts b/packages/cli/src/commands/tree/build-graph.ts index 73633e228f..1043f7834a 100644 --- a/packages/cli/src/commands/tree/build-graph.ts +++ b/packages/cli/src/commands/tree/build-graph.ts @@ -1,6 +1,6 @@ import { isAbsoluteUrl, type Document, type ResolvedRefMap } from '@redocly/openapi-core'; -import { byString, toNodeId } from './node-id.js'; +import { compareStrings, toNodeId } from './node-id.js'; import type { DependencyGraph, GraphEdge, GraphNode } from './types.js'; export function buildGraph( @@ -48,9 +48,9 @@ export function buildGraph( return { roots: resolutions.map(({ rootDocument }) => toNodeId(rootDocument.source.absoluteRef, cwd)), - nodes: [...nodes.values()].sort((a, b) => byString(a.id, b.id)), + nodes: [...nodes.values()].sort((a, b) => compareStrings(a.id, b.id)), edges: [...edges.values()] - .map((edge) => ({ ...edge, refs: [...edge.refs].sort(byString) })) - .sort((a, b) => byString(a.from, b.from) || byString(a.to, b.to)), + .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/build-structure.ts b/packages/cli/src/commands/tree/build-structure.ts index f44e53d01b..d92c6436d2 100644 --- a/packages/cli/src/commands/tree/build-structure.ts +++ b/packages/cli/src/commands/tree/build-structure.ts @@ -10,8 +10,9 @@ import { type WalkContext, } from '@redocly/openapi-core'; +import { collectConnectedIds } from './filter-affected.js'; import { - byString, + compareStrings, mapForeignLocation, mapRootPointer, OPERATION_METHODS, @@ -37,7 +38,7 @@ export function buildStructure(options: { const nodes = new Map(); const edges = new Map(); - const upsertNode = (mapped: MappedNode & { file: string }, resolved: boolean) => { + 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; @@ -55,31 +56,29 @@ export function buildStructure(options: { edges.set(edgeKey, edge); }; - const mapByFile = (absoluteRef: string, pointer: string): MappedNode & { file: string } => + const mapToNode = (absoluteRef: string, pointer: string): MappedNode & { file: string } => absoluteRef === rootAbs ? { ...mapRootPointer(pointer, rootId), file: rootId } : mapForeignLocation(toNodeId(absoluteRef, cwd), pointer); const nodeFor = (location: Location): string => { - const mapped = mapByFile(location.source.absoluteRef, location.pointer); - upsertNode(mapped, true); - wireSpine(mapped); + const mapped = mapToNode(location.source.absoluteRef, location.pointer); + addOrUpdateNode(mapped, true); + linkToRoot(mapped); return mapped.id; }; - const wireSpine = (mapped: MappedNode) => { + const linkToRoot = (mapped: MappedNode) => { if (mapped.ancestry === undefined) return; let previous = rootId; for (const ancestorId of mapped.ancestry) { - upsertNode({ id: ancestorId, kind: 'path', file: rootId }, true); + addOrUpdateNode({ id: ancestorId, kind: 'path', file: rootId }, true); addEdge(previous, ancestorId); previous = ancestorId; } addEdge(previous, mapped.id); }; - // Unresolved `$ref`: derive the target id from the raw string — a same-file fragment, or a uri - // resolved against the ref site's file. const unresolvedTargetId = (siteLocation: Location, refString: string): string => { const hashIndex = refString.indexOf('#'); const uri = hashIndex === -1 ? refString : refString.slice(0, hashIndex); @@ -88,7 +87,7 @@ export function buildStructure(options: { let mapped: MappedNode & { file: string }; if (uri === '') { - mapped = mapByFile(siteFile, '#' + (fragment ?? '/')); + mapped = mapToNode(siteFile, '#' + (fragment ?? '/')); } else { const fileId = toNodeId(resolveRef(siteFile, uri), cwd); mapped = @@ -97,7 +96,7 @@ export function buildStructure(options: { : { id: fileId, kind: 'file', file: fileId }; } - upsertNode(mapped, false); + addOrUpdateNode(mapped, false); return mapped.id; }; @@ -136,7 +135,7 @@ export function buildStructure(options: { }, }; - upsertNode({ id: rootId, kind: 'root', file: rootId }, true); + addOrUpdateNode({ id: rootId, kind: 'root', file: rootId }, true); nodes.get(rootId)!.root = true; const normalizedVisitors = normalizeVisitors( @@ -145,42 +144,24 @@ export function buildStructure(options: { ); walkDocument({ document, rootType: types.Root, normalizedVisitors, resolvedRefMap, ctx }); - return prune(rootId, nodes, edges); + return finalizeGraph(rootId, nodes, edges); } -/** Drops nodes unreachable from the root, then codepoint-sorts nodes/edges/refs for stable output. */ -function prune( +function finalizeGraph( rootId: string, - nodes: Map, - edges: Map + nodeMap: Map, + edgeMap: Map ): DependencyGraph { - const adjacency = new Map(); - for (const { from, to } of edges.values()) { - const targets = adjacency.get(from) ?? []; - targets.push(to); - adjacency.set(from, targets); - } - - const reachable = new Set([rootId]); - const queue = [rootId]; - while (queue.length > 0) { - const current = queue.shift()!; - for (const next of adjacency.get(current) ?? []) { - if (!reachable.has(next)) { - reachable.add(next); - queue.push(next); - } - } - } - - return { - roots: [rootId], - nodes: [...nodes.values()] - .filter((node) => reachable.has(node.id)) - .sort((a, b) => byString(a.id, b.id)), - edges: [...edges.values()] - .filter((edge) => reachable.has(edge.from) && reachable.has(edge.to)) - .map((edge) => ({ ...edge, refs: [...edge.refs].sort(byString) })) - .sort((a, b) => byString(a.from, b.from) || byString(a.to, b.to)), - }; + 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 }; } diff --git a/packages/cli/src/commands/tree/filter-affected.ts b/packages/cli/src/commands/tree/filter-affected.ts index e8db8bf64a..8ebb68561a 100644 --- a/packages/cli/src/commands/tree/filter-affected.ts +++ b/packages/cli/src/commands/tree/filter-affected.ts @@ -1,26 +1,35 @@ -import type { DependencyGraph } from './types.js'; +import type { DependencyGraph, GraphEdge } from './types.js'; -/** Returns `changedIds` plus every node that transitively depends on them (reverse closure up to the roots). */ -export function filterAffected(graph: DependencyGraph, changedIds: string[]): DependencyGraph { - const dependentsByTarget = new Map(); - for (const edge of graph.edges) { - const dependents = dependentsByTarget.get(edge.to) ?? []; - dependents.push(edge.from); - dependentsByTarget.set(edge.to, dependents); +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 affected = new Set(changedIds); - const queue = [...affected]; + const seen = new Set(seeds); + const queue = [...seen]; while (queue.length > 0) { const current = queue.shift()!; - for (const dependent of dependentsByTarget.get(current) ?? []) { - if (!affected.has(dependent)) { - affected.add(dependent); - queue.push(dependent); + for (const next of adjacency.get(current) ?? []) { + if (!seen.has(next)) { + seen.add(next); + queue.push(next); } } } + return seen; +} +export function filterAffected(graph: DependencyGraph, changedIds: string[]): DependencyGraph { + const affected = collectConnectedIds(changedIds, graph.edges, { reverse: true }); return { roots: graph.roots.filter((root) => affected.has(root)), nodes: graph.nodes.filter((node) => affected.has(node.id)), diff --git a/packages/cli/src/commands/tree/node-id.ts b/packages/cli/src/commands/tree/node-id.ts index 6866f81b79..8f87914e9f 100644 --- a/packages/cli/src/commands/tree/node-id.ts +++ b/packages/cli/src/commands/tree/node-id.ts @@ -8,8 +8,7 @@ import * as path from 'node:path'; import type { NodeKind } from './types.js'; -/** Codepoint comparison (not localeCompare): deterministic across Node ICU builds → stable output. */ -export const byString = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0); +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)); diff --git a/packages/cli/src/commands/tree/print/stylish.ts b/packages/cli/src/commands/tree/print/stylish.ts index 768c78e339..9353591528 100644 --- a/packages/cli/src/commands/tree/print/stylish.ts +++ b/packages/cli/src/commands/tree/print/stylish.ts @@ -1,4 +1,4 @@ -import { byString } from '../node-id.js'; +import { compareStrings } from '../node-id.js'; import type { DependencyGraph } from '../types.js'; export type StylishOptions = { @@ -7,10 +7,6 @@ export type StylishOptions = { emptyMessage?: string; }; -/** - * Renders one ASCII tree per root. A node already expanded in the current tree - * is printed with `↺` and not expanded again (handles cycles and fan-in). - */ export function renderStylish(graph: DependencyGraph, options: StylishOptions = {}): string { if (graph.nodes.length === 0) { return options.emptyMessage ?? 'No files affected.'; @@ -23,7 +19,7 @@ export function renderStylish(graph: DependencyGraph, options: StylishOptions = childrenByNode.set(edge.from, children); } for (const children of childrenByNode.values()) { - children.sort(byString); + children.sort(compareStrings); } const nodesById = new Map(graph.nodes.map((node) => [node.id, node])); @@ -40,6 +36,8 @@ export function renderStylish(graph: DependencyGraph, options: StylishOptions = return text; }; + // A child already expanded in this tree is printed with `↺` and not expanded again — + // this is what makes cycles and fan-in terminate. const renderSubtree = (id: string, prefix: string, printed: Set) => { const children = childrenByNode.get(id) ?? []; children.forEach((child, index) => { From 2748189d61f880574220aec868398bd1c791d3d4 Mon Sep 17 00:00:00 2001 From: kanoru Date: Mon, 22 Jun 2026 17:56:34 +0300 Subject: [PATCH 33/79] fix: resolve bug with splitted files --- .../tree/__tests__/build-structure.test.ts | 45 +++++++++++++++-- .../tree/__tests__/match-affected-by.test.ts | 11 +++++ .../commands/tree/__tests__/node-id.test.ts | 32 +++++++++++++ .../cli/src/commands/tree/build-structure.ts | 48 +++++++++++++++++-- packages/cli/src/commands/tree/index.ts | 43 ++++++----------- .../src/commands/tree/match-affected-by.ts | 6 ++- packages/cli/src/commands/tree/node-id.ts | 30 ++++++++++-- 7 files changed, 175 insertions(+), 40 deletions(-) diff --git a/packages/cli/src/commands/tree/__tests__/build-structure.test.ts b/packages/cli/src/commands/tree/__tests__/build-structure.test.ts index 65583f84d8..ae50085d6d 100644 --- a/packages/cli/src/commands/tree/__tests__/build-structure.test.ts +++ b/packages/cli/src/commands/tree/__tests__/build-structure.test.ts @@ -1,5 +1,6 @@ import { BaseResolver, + createConfig, detectSpec, getTypes, normalizeTypes, @@ -10,7 +11,7 @@ import { } from '@redocly/openapi-core'; import * as path from 'node:path'; -import { buildStructure } from '../build-structure.js'; +import { buildStructureGraph, walkStructure } from '../build-structure.js'; import type { DependencyGraph } from '../types.js'; const CWD = '/project'; @@ -29,7 +30,7 @@ async function structureOf( externalRefResolver, }); const ctx = { problems: [], specVersion, visitorsData: {} } as unknown as WalkContext; - return buildStructure({ + return walkStructure({ document, types, resolvedRefMap, @@ -43,7 +44,7 @@ function edgeRefs(graph: DependencyGraph, from: string, to: string): string[] | return graph.edges.find((edge) => edge.from === from && edge.to === to)?.refs; } -describe('buildStructure', () => { +describe('walkStructure', () => { it('builds the root -> path -> operation spine without refs', async () => { const graph = await structureOf({ openapi: '3.0.0', @@ -475,3 +476,41 @@ describe('buildStructure', () => { ]); }); }); + +describe('buildStructureGraph (multi-file parity)', () => { + const multiFile = path.join(process.cwd(), 'tests/e2e/tree/tree-multi-file/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); + return buildStructureGraph({ + rootDocument, + specVersion, + types, + config, + externalRefResolver, + cwd: path.dirname(apiPath), + }); + } + + it('bundles referenced path-items and components into a single-file-equivalent tree', async () => { + const graph = await structureGraphOf(multiFile); + const nodes = graph.nodes.map((node) => ({ id: node.id, kind: node.kind })); + expect(nodes).toContainEqual({ id: 'GET /pets', kind: 'operation' }); + expect(nodes).toContainEqual({ id: 'GET /users', kind: 'operation' }); + expect(nodes).toContainEqual({ id: 'schemas/Pet', kind: 'component' }); + expect(nodes).toContainEqual({ id: 'schemas/User', kind: 'component' }); + expect(nodes).toContainEqual({ id: 'schemas/Address', kind: 'component' }); + expect(graph.nodes.some((node) => node.kind === 'file')).toBe(false); + + expect(graph.edges).toContainEqual({ + from: 'schemas/User', + to: 'schemas/Address', + refs: ['#/components/schemas/Address'], + }); + }); +}); 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 index 9af8b9c9e5..f303476a89 100644 --- a/packages/cli/src/commands/tree/__tests__/match-affected-by.test.ts +++ b/packages/cli/src/commands/tree/__tests__/match-affected-by.test.ts @@ -171,4 +171,15 @@ describe('matchAffectedBy', () => { 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: [], + markerIds: [], + 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__/node-id.test.ts b/packages/cli/src/commands/tree/__tests__/node-id.test.ts index d8c2083e46..b74b343ffc 100644 --- a/packages/cli/src/commands/tree/__tests__/node-id.test.ts +++ b/packages/cli/src/commands/tree/__tests__/node-id.test.ts @@ -136,4 +136,36 @@ describe('mapForeignLocation', () => { 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', + }); + }); + + 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/cli/src/commands/tree/build-structure.ts b/packages/cli/src/commands/tree/build-structure.ts index d92c6436d2..cfa3129c5a 100644 --- a/packages/cli/src/commands/tree/build-structure.ts +++ b/packages/cli/src/commands/tree/build-structure.ts @@ -1,12 +1,18 @@ import { + bundleDocument, + getTypes, isAbsoluteUrl, normalizeVisitors, + resolveDocument, walkDocument, + type BaseResolver, + type Config, type Document, type Location, type NormalizedNodeType, type Oas3Visitor, type ResolvedRefMap, + type SpecVersion, type WalkContext, } from '@redocly/openapi-core'; @@ -22,7 +28,42 @@ import { } from './node-id.js'; import type { DependencyGraph, GraphEdge, GraphNode } from './types.js'; -export function buildStructure(options: { +export async function buildStructureGraph(options: { + rootDocument: Document; + specVersion: SpecVersion; + types: Record; + config: Config; + externalRefResolver: BaseResolver; + cwd: string; +}): Promise { + const { rootDocument, specVersion, types, config, externalRefResolver, cwd } = options; + + const { bundle } = await bundleDocument({ + document: rootDocument, + config, + types: getTypes(specVersion), + externalRefResolver, + }); + + const resolvedRefMap = await resolveDocument({ + rootDocument: bundle, + rootType: types.Root, + externalRefResolver, + }); + + const ctx: WalkContext = { problems: [], specVersion, config, visitorsData: {} }; + + return walkStructure({ + document: bundle, + types, + resolvedRefMap, + ctx, + cwd, + resolveRef: (base, uri) => externalRefResolver.resolveExternalRef(base, uri), + }); +} + +export function walkStructure(options: { document: Document; types: Record; resolvedRefMap: ResolvedRefMap; @@ -80,9 +121,7 @@ export function buildStructure(options: { }; const unresolvedTargetId = (siteLocation: Location, refString: string): string => { - const hashIndex = refString.indexOf('#'); - const uri = hashIndex === -1 ? refString : refString.slice(0, hashIndex); - const fragment = hashIndex === -1 ? undefined : refString.slice(hashIndex + 1); + const [uri, fragment] = refString.split('#'); const siteFile = siteLocation.source.absoluteRef; let mapped: MappedNode & { file: string }; @@ -147,6 +186,7 @@ export function buildStructure(options: { return finalizeGraph(rootId, nodes, edges); } +/** Keeps only nodes reachable from the root, sorted for stable output. */ function finalizeGraph( rootId: string, nodeMap: Map, diff --git a/packages/cli/src/commands/tree/index.ts b/packages/cli/src/commands/tree/index.ts index 6afcd5baf9..14b7412192 100644 --- a/packages/cli/src/commands/tree/index.ts +++ b/packages/cli/src/commands/tree/index.ts @@ -11,7 +11,6 @@ import { type NormalizedNodeType, type ResolvedRefMap, type SpecVersion, - type WalkContext, } from '@redocly/openapi-core'; import * as path from 'node:path'; @@ -20,7 +19,7 @@ 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 { buildStructure } from './build-structure.js'; +import { buildStructureGraph } from './build-structure.js'; import { filterAffected } from './filter-affected.js'; import { matchAffectedBy } from './match-affected-by.js'; import { renderJson } from './print/json.js'; @@ -68,7 +67,7 @@ export async function handleTree({ argv, config, collectSpecData }: CommandArgs< }); } -async function resolveApi({ +async function loadApi({ apiPath, config, collectSpecData, @@ -82,7 +81,6 @@ async function resolveApi({ rootDocument: Document; specVersion: SpecVersion; types: Record; - refMap: ResolvedRefMap; }> { const rootDocument = await externalRefResolver.resolveDocument(null, apiPath, true); if (rootDocument instanceof Error) { @@ -91,12 +89,7 @@ async function resolveApi({ collectSpecData?.(rootDocument.parsed); const specVersion = detectSpec(rootDocument.parsed); const types = normalizeTypes(config.extendTypes(getTypes(specVersion), specVersion), config); - const refMap = await resolveDocument({ - rootDocument, - rootType: types.Root, - externalRefResolver, - }); - return { rootDocument, specVersion, types, refMap }; + return { rootDocument, specVersion, types }; } async function handleFilesMode({ @@ -109,12 +102,17 @@ async function handleFilesMode({ }: TreeModeContext & { apis: Entrypoint[] }): Promise { const resolutions: Array<{ rootDocument: Document; refMap: ResolvedRefMap }> = []; for (const { path: apiPath } of apis) { - const { rootDocument, refMap } = await resolveApi({ + const { rootDocument, types } = await loadApi({ apiPath, config, collectSpecData, externalRefResolver, }); + const refMap = await resolveDocument({ + rootDocument, + rootType: types.Root, + externalRefResolver, + }); resolutions.push({ rootDocument, refMap }); } @@ -156,34 +154,23 @@ async function handleStructureMode({ externalRefResolver, cwd, }: TreeModeContext & { api: Entrypoint }): Promise { - const { - rootDocument, - specVersion, - types, - refMap: resolvedRefMap, - } = await resolveApi({ + const { rootDocument, specVersion, types } = await loadApi({ apiPath: api.path, config, collectSpecData, externalRefResolver, }); - const ctx: WalkContext = { - problems: [], + const graph = await buildStructureGraph({ + rootDocument, specVersion, - config, - visitorsData: {}, - }; - - const graph = buildStructure({ - document: rootDocument, types, - resolvedRefMap, - ctx, + config, + externalRefResolver, cwd, - resolveRef: (base, uri) => externalRefResolver.resolveExternalRef(base, uri), }); + // Structure mode resolves exactly one API (handleTree rejects more), so there is a single root. const rootId = graph.roots[0]; let printedGraph = graph; diff --git a/packages/cli/src/commands/tree/match-affected-by.ts b/packages/cli/src/commands/tree/match-affected-by.ts index 1e70fec410..372cdbf98a 100644 --- a/packages/cli/src/commands/tree/match-affected-by.ts +++ b/packages/cli/src/commands/tree/match-affected-by.ts @@ -79,7 +79,11 @@ export function matchAffectedBy( } } - warnings.push(`${input} does not match any path, operation, or component of ${rootId}.`); + 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 { diff --git a/packages/cli/src/commands/tree/node-id.ts b/packages/cli/src/commands/tree/node-id.ts index 8f87914e9f..4299737f77 100644 --- a/packages/cli/src/commands/tree/node-id.ts +++ b/packages/cli/src/commands/tree/node-id.ts @@ -49,6 +49,11 @@ export function parsePointerSegments(pointer: string): string[] { .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) { @@ -74,12 +79,29 @@ export function mapRootPointer(pointer: string, rootId: string): MappedNode { }; } +/** + * 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); - const componentDepth = - segments[0] === 'components' ? 3 : OAS2_COMPONENT_SECTIONS.has(segments[0]) ? 2 : 0; - if (componentDepth > 0 && segments.length >= componentDepth) { - const canonical = segments.slice(0, componentDepth).map(escapePointerFragment).join('/'); + + 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 }; From d45ab5014d690eedc3a8ae54e55812332a3fe60d Mon Sep 17 00:00:00 2001 From: kanoru Date: Mon, 22 Jun 2026 18:39:50 +0300 Subject: [PATCH 34/79] fix: update tests --- .../tree/tree-structure-affected-file/snapshot.txt | 9 ++------- .../e2e/tree/tree-structure-multi-file/snapshot.txt | 12 ++++++------ tests/e2e/tree/tree.test.ts | 2 +- 3 files changed, 9 insertions(+), 14 deletions(-) diff --git a/tests/e2e/tree/tree-structure-affected-file/snapshot.txt b/tests/e2e/tree/tree-structure-affected-file/snapshot.txt index eac6655055..5770411f14 100644 --- a/tests/e2e/tree/tree-structure-affected-file/snapshot.txt +++ b/tests/e2e/tree/tree-structure-affected-file/snapshot.txt @@ -1,8 +1,3 @@ -openapi.yaml -└── /users - └── paths/users.yaml - └── components/schemas/User.yaml - └── components/schemas/Address.yaml ← changed - -5 of 8 nodes affected +No nodes affected. +components/schemas/Address.yaml does not match any path, operation, or component of openapi.yaml. For file-level analysis, use `--files`. diff --git a/tests/e2e/tree/tree-structure-multi-file/snapshot.txt b/tests/e2e/tree/tree-structure-multi-file/snapshot.txt index f87086433b..664e1a8789 100644 --- a/tests/e2e/tree/tree-structure-multi-file/snapshot.txt +++ b/tests/e2e/tree/tree-structure-multi-file/snapshot.txt @@ -1,10 +1,10 @@ openapi.yaml ├── /pets -│ └── paths/pets.yaml -│ └── components/schemas/Pet.yaml +│ └── GET /pets +│ └── schemas/Pet └── /users - └── paths/users.yaml - └── components/schemas/User.yaml - ├── components/schemas/Address.yaml - └── components/schemas/Pet.yaml ↺ + └── GET /users + └── schemas/User + ├── schemas/Address + └── schemas/Pet ↺ diff --git a/tests/e2e/tree/tree.test.ts b/tests/e2e/tree/tree.test.ts index b5ca5f76f7..ff84420adc 100644 --- a/tests/e2e/tree/tree.test.ts +++ b/tests/e2e/tree/tree.test.ts @@ -113,7 +113,7 @@ describe('tree', () => { ); }); - test('tree should show what a changed file affects in default mode', async () => { + test('tree should point a changed file to --files in the default view', async () => { const args = getParams(indexEntryPoint, [ 'tree', 'openapi.yaml', From 9afbdc0662105741960a50f72cee963315bedb76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacek=20=C5=81=C4=99kawa?= <164185257+JLekawa@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:55:20 +0200 Subject: [PATCH 35/79] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jacek Łękawa <164185257+JLekawa@users.noreply.github.com> --- docs/@v2/commands/tree.md | 55 ++++++++++++++++++++++++++------------- 1 file changed, 37 insertions(+), 18 deletions(-) diff --git a/docs/@v2/commands/tree.md b/docs/@v2/commands/tree.md index e4479e5e0e..bcbfad8da6 100644 --- a/docs/@v2/commands/tree.md +++ b/docs/@v2/commands/tree.md @@ -2,14 +2,17 @@ ## Introduction -The `tree` command prints the structure of an API description: its paths, operations, and the component dependency chains between them through `$ref`. It 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 `$ref`'d components rather than a paths and operations tree. +The `tree` command prints the structure of an API description: its paths, operations, and the component dependency chains between them through `$ref`. +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 it to: +Use `tree` to: -- get quick orientation in any API, whether single-file or multi-file; -- run impact analysis with `--affected-by` — which paths and operations are affected by a change to a component or file, useful in CI and automated code review; -- produce machine-readable JSON or a Mermaid diagram with `--format`; -- view the file-level `$ref` graph with `--files`. +- Get quick orientation in any API, whether single-file or multi-file. +- Run impact analysis with `--affected-by` — which paths and operations are affected by a change to a component or file. + This analysis is useful in CI and automated code review. +- Produce machine-readable JSON or a Mermaid diagram with `--format`. +- View the file-level `$ref` graph with `--files`. ## Usage @@ -20,20 +23,22 @@ redocly tree [--format=] [--affected-by=] [--config=] redocly tree --files [apis...] ``` -With no API argument, the command takes the API from the Redocly configuration file. The default structure view shows one API at a time — use `--files` for the multi-API file graph. +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. | -| --affected-by | [string] | Show only the part of the tree affected by the given changes. The default view accepts a JSON pointer, shorthand pointer, bare component name, or file path; `--files` mode accepts file paths only. Repeat the option to pass several values: `--affected-by Pet --affected-by /users`. | +| --affected-by | [string] | Display only the part of the tree affected by the given changes. The default view accepts a JSON pointer, shorthand pointer, bare component name, or file path. `--files` mode accepts file paths only. Repeat the option to pass several values: `--affected-by Pet --affected-by /users`. | | --config | string | Specify the path to the [Redocly configuration file](../configuration/index.md). | -| --files | boolean | Show the file-level `$ref` graph instead of the document structure. | +| --files | boolean | Display the file-level `$ref` graph instead of the document structure. | | --format | string | Output format: `stylish` (default, tree view), `json`, or `mermaid`. | -| --help | boolean | Show help. | +| --help | boolean | Display help. | | --lint-config | string | Specify the severity level for the configuration file.
**Possible values:** `warn`, `error`, `off`. Default value is `warn`. | -| --version | boolean | Show version number. | +| --version | boolean | Display version number. | ## Examples @@ -66,9 +71,12 @@ Markers legend: - `↺` — the node was already expanded earlier in the tree; its dependencies are not repeated. This is also how recursive schemas render. - `✗ not found` — an unresolvable `$ref`. -- `(external)` — a reference to a URL. +- `↺` — the node was already expanded earlier in the tree. Its dependencies are not repeated. This is also how recursive schemas render. +- `✗ not found` — an unresolvable `$ref` +- `(external)` — a reference to a URL -For multi-file APIs, components living in other files appear as file nodes (for example, `paths/pets.yaml`). Operations defined inside a `$ref`'d path-item file are represented by that file node, not expanded individually. +For multi-file APIs, components living in other files appear as file nodes (for example, `paths/pets.yaml`). +Operations defined inside a `$ref`'d path-item file are represented by that file node, not expanded individually. ### Find what a change affects @@ -104,11 +112,18 @@ openapi.yaml - Shorthand pointer (the node id, without `#/components/`): `schemas/Address` - Bare component name: `Address` — ambiguous bare names match all candidates and print a note to stderr (impact analysis over-reports rather than under-reports) - A file path (for multi-file specs): `schemas/address.yaml` -- The root file itself: the whole tree is affected +- full JSON pointer: `#/components/schemas/Address` +- shorthand pointer (the node id, without `#/components/`): `schemas/Address` +- bare component name: `Address` — ambiguous bare names match all candidates and print a note to `stderr` (impact analysis over-reports rather than under-reports) +- a file path (for multi-file specs): `schemas/address.yaml` +- the root file itself: the whole tree is affected -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. When the tree has no operation nodes at all (an AsyncAPI or Arazzo description, or a multi-file OpenAPI description whose path items live in `$ref`'d files), the summary falls back to counting nodes, for example `5 of 8 nodes affected`. +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. +When the tree has no operation nodes at all (an AsyncAPI or Arazzo description, or a multi-file OpenAPI description whose path items live in `$ref`'d files), the summary falls back to counting nodes. +For example: `5 of 8 nodes affected`. -Unknown inputs print a warning to stderr and exit with code `0`. +Unknown inputs print a warning to `stderr` and exit with code `0`. ### Machine-readable output @@ -116,7 +131,8 @@ Unknown inputs print a warning to stderr and exit with code `0`. redocly tree openapi.yaml --format=json ``` -Prints the graph as JSON with `roots`, `nodes` (`resolved` and `external` on every node; `kind` and `file` in the default view only), and `edges` (with the exact `$ref` strings). Only the JSON is written to stdout, so the output is safe to pipe. +Prints the graph as JSON with `roots`, `nodes` (`resolved` and `external` on every node; `kind` and `file` in the default view only), and `edges` (with the exact `$ref` strings). +Only the JSON is written to `stdout`, so the output is safe to pipe. ```bash redocly tree openapi.yaml --format=mermaid @@ -140,4 +156,7 @@ openapi.yaml └── components/schemas/Pet.yaml ↺ ``` -`--files` shows only which files reference which other files — not the paths, operations, and components inside them. (The default view already traverses those, following `$ref`s across files.) It also accepts multiple APIs in one run, merging their graphs; in this mode `--affected-by` takes file paths and the summary counts affected files and roots. +`--files` displays only which files reference other files - not the paths, operations, and components inside them. +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, `--affected-by` takes file paths, and the summary counts affected files and roots. From c3c29af7a3ccacb479819df28282db9facb4e230 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacek=20=C5=81=C4=99kawa?= <164185257+JLekawa@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:12:34 +0200 Subject: [PATCH 36/79] Update tree.md --- docs/@v2/commands/tree.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/@v2/commands/tree.md b/docs/@v2/commands/tree.md index bcbfad8da6..831db9e0b3 100644 --- a/docs/@v2/commands/tree.md +++ b/docs/@v2/commands/tree.md @@ -29,15 +29,15 @@ 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. | +| 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. | | --affected-by | [string] | Display only the part of the tree affected by the given changes. The default view accepts a JSON pointer, shorthand pointer, bare component name, or file path. `--files` mode accepts file paths only. Repeat the option to pass several values: `--affected-by Pet --affected-by /users`. | -| --config | string | Specify the path to the [Redocly configuration file](../configuration/index.md). | +| --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`, or `mermaid`. | +| --format | string | Output format: `stylish` (default, tree view), `json`, or `mermaid`. | | --help | boolean | Display help. | -| --lint-config | string | Specify the severity level for the configuration file.
**Possible values:** `warn`, `error`, `off`. Default value is `warn`. | +| --lint-config | string | Specify the severity level for the configuration file.
**Possible values:** `warn`, `error`, `off`. Default value is `warn`. | | --version | boolean | Display version number. | ## Examples From 21ccf18ac2faadb6c76904be8057e7e1a9b869cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacek=20=C5=81=C4=99kawa?= <164185257+JLekawa@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:15:18 +0200 Subject: [PATCH 37/79] Update docs/@v2/commands/tree.md --- docs/@v2/commands/tree.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/@v2/commands/tree.md b/docs/@v2/commands/tree.md index 831db9e0b3..ffeb4936ab 100644 --- a/docs/@v2/commands/tree.md +++ b/docs/@v2/commands/tree.md @@ -120,7 +120,7 @@ openapi.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. -When the tree has no operation nodes at all (an AsyncAPI or Arazzo description, or a multi-file OpenAPI description whose path items live in `$ref`'d files), the summary falls back to counting nodes. +When the tree has no operation nodes at all (an AsyncAPI or Arazzo description, or a multi-file OpenAPI description whose path items live in `$ref`'d files), the summary falls back to counting nodes. For example: `5 of 8 nodes affected`. Unknown inputs print a warning to `stderr` and exit with code `0`. From 808714dfd40b02259b91f9fecd74c80b360f3e99 Mon Sep 17 00:00:00 2001 From: JLekawa Date: Tue, 23 Jun 2026 12:26:52 +0200 Subject: [PATCH 38/79] docs(cli): fix issues in file --- docs/@v2/commands/tree.md | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/docs/@v2/commands/tree.md b/docs/@v2/commands/tree.md index ffeb4936ab..b179dea471 100644 --- a/docs/@v2/commands/tree.md +++ b/docs/@v2/commands/tree.md @@ -4,7 +4,7 @@ The `tree` command prints the structure of an API description: its paths, operations, and the component dependency chains between them through `$ref`. 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. +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: @@ -69,8 +69,6 @@ openapi.yaml Markers legend: -- `↺` — the node was already expanded earlier in the tree; its dependencies are not repeated. This is also how recursive schemas render. -- `✗ not found` — an unresolvable `$ref`. - `↺` — the node was already expanded earlier in the tree. Its dependencies are not repeated. This is also how recursive schemas render. - `✗ not found` — an unresolvable `$ref` - `(external)` — a reference to a URL @@ -108,10 +106,6 @@ openapi.yaml `--affected-by` accepts several input forms: -- Full JSON pointer: `#/components/schemas/Address` -- Shorthand pointer (the node id, without `#/components/`): `schemas/Address` -- Bare component name: `Address` — ambiguous bare names match all candidates and print a note to stderr (impact analysis over-reports rather than under-reports) -- A file path (for multi-file specs): `schemas/address.yaml` - full JSON pointer: `#/components/schemas/Address` - shorthand pointer (the node id, without `#/components/`): `schemas/Address` - bare component name: `Address` — ambiguous bare names match all candidates and print a note to `stderr` (impact analysis over-reports rather than under-reports) From cafbab8d74e19bab18e5a1a9cd02f5b126e1c136 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacek=20=C5=81=C4=99kawa?= <164185257+JLekawa@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:46:35 +0200 Subject: [PATCH 39/79] Apply suggestion from @JLekawa --- docs/@v2/commands/tree.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/@v2/commands/tree.md b/docs/@v2/commands/tree.md index b179dea471..cc215fa96b 100644 --- a/docs/@v2/commands/tree.md +++ b/docs/@v2/commands/tree.md @@ -154,3 +154,4 @@ openapi.yaml 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, `--affected-by` takes file paths, and the summary counts affected files and roots. + From 1ec983fbe8110026336959a1723cf006a70b9ff6 Mon Sep 17 00:00:00 2001 From: kanoru Date: Tue, 23 Jun 2026 14:07:39 +0300 Subject: [PATCH 40/79] fix: add spaces in the options-table separator --- docs/@v2/commands/tree.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/@v2/commands/tree.md b/docs/@v2/commands/tree.md index cc215fa96b..5539838c10 100644 --- a/docs/@v2/commands/tree.md +++ b/docs/@v2/commands/tree.md @@ -30,7 +30,7 @@ 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. | | --affected-by | [string] | Display only the part of the tree affected by the given changes. The default view accepts a JSON pointer, shorthand pointer, bare component name, or file path. `--files` mode accepts file paths only. Repeat the option to pass several values: `--affected-by Pet --affected-by /users`. | | --config | string | Specify the path to the [Redocly configuration file](../configuration/index.md). | @@ -154,4 +154,3 @@ openapi.yaml 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, `--affected-by` takes file paths, and the summary counts affected files and roots. - From eeede48ec1da3abc993abf218e31c41a39980c05 Mon Sep 17 00:00:00 2001 From: kanoru Date: Wed, 24 Jun 2026 15:12:44 +0300 Subject: [PATCH 41/79] fix: refactoring and update snapshots --- docs/@v2/commands/tree.md | 177 ++++++++++-------- packages/cli/src/commands/lint.ts | 3 +- .../tree/__tests__/build-graph.test.ts | 12 +- .../tree/__tests__/build-structure.test.ts | 64 +++++-- .../tree/__tests__/match-affected-by.test.ts | 24 +-- .../commands/tree/__tests__/node-id.test.ts | 29 ++- .../src/commands/tree/__tests__/print.test.ts | 87 ++++++++- packages/cli/src/commands/tree/build-graph.ts | 12 +- .../cli/src/commands/tree/build-structure.ts | 9 +- packages/cli/src/commands/tree/index.ts | 65 +++++-- .../src/commands/tree/match-affected-by.ts | 19 +- packages/cli/src/commands/tree/node-id.ts | 13 ++ packages/cli/src/commands/tree/print/dot.ts | 16 ++ packages/cli/src/commands/tree/print/json.ts | 6 +- .../cli/src/commands/tree/print/stylish.ts | 40 ++-- packages/cli/src/commands/tree/types.ts | 2 +- packages/cli/src/index.ts | 11 +- tests/e2e/tree/multi-api/a.yaml | 14 ++ tests/e2e/tree/multi-api/b.yaml | 14 ++ tests/e2e/tree/multi-api/shared.yaml | 7 + .../components/schemas/Error.yaml} | 4 +- .../components/schemas/MenuItem.yaml | 8 + .../components/schemas/Order.yaml | 10 + .../components/schemas/OrderList.yaml | 6 + .../components/schemas/OrderStatus.yaml | 5 + tests/e2e/tree/sample-split/openapi.yaml | 21 +++ tests/e2e/tree/sample-split/paths/orders.yaml | 24 +++ .../sample-split/paths/orders_{orderId}.yaml | 30 +++ .../snapshot.txt | 3 - .../tree/tree-files-affected-by/snapshot.txt | 7 - tests/e2e/tree/tree-files-json/snapshot.txt | 115 +++++++++--- .../tree/tree-files-multi-api/snapshot.txt | 16 +- .../e2e/tree/tree-files-stylish/snapshot.txt | 20 +- .../e2e/tree/tree-files-used-by/snapshot.txt | 12 ++ tests/e2e/tree/tree-multi-file/admin.yaml | 7 - .../components/schemas/Address.yaml | 4 - .../components/schemas/User.yaml | 6 - tests/e2e/tree/tree-multi-file/openapi.yaml | 9 - .../e2e/tree/tree-multi-file/paths/pets.yaml | 9 - .../e2e/tree/tree-multi-file/paths/users.yaml | 9 - tests/e2e/tree/tree-single-file/openapi.yaml | 83 -------- .../tree-structure-affected-file/snapshot.txt | 3 - .../snapshot.txt | 18 -- .../e2e/tree/tree-structure-dot/snapshot.txt | 28 +++ .../e2e/tree/tree-structure-json/snapshot.txt | 111 +++++------ .../tree/tree-structure-mermaid/snapshot.txt | 45 +++-- .../tree-structure-multi-file/snapshot.txt | 10 - .../tree/tree-structure-stylish/snapshot.txt | 28 ++- .../tree-structure-used-by-file/snapshot.txt | 3 + .../snapshot.txt | 0 .../tree/tree-structure-used-by/snapshot.txt | 13 ++ tests/e2e/tree/tree.test.ts | 153 ++++++--------- 52 files changed, 826 insertions(+), 618 deletions(-) create mode 100644 packages/cli/src/commands/tree/print/dot.ts create mode 100644 tests/e2e/tree/multi-api/a.yaml create mode 100644 tests/e2e/tree/multi-api/b.yaml create mode 100644 tests/e2e/tree/multi-api/shared.yaml rename tests/e2e/tree/{tree-multi-file/components/schemas/Pet.yaml => sample-split/components/schemas/Error.yaml} (53%) create mode 100644 tests/e2e/tree/sample-split/components/schemas/MenuItem.yaml create mode 100644 tests/e2e/tree/sample-split/components/schemas/Order.yaml create mode 100644 tests/e2e/tree/sample-split/components/schemas/OrderList.yaml create mode 100644 tests/e2e/tree/sample-split/components/schemas/OrderStatus.yaml create mode 100644 tests/e2e/tree/sample-split/openapi.yaml create mode 100644 tests/e2e/tree/sample-split/paths/orders.yaml create mode 100644 tests/e2e/tree/sample-split/paths/orders_{orderId}.yaml delete mode 100644 tests/e2e/tree/tree-files-affected-by-unknown/snapshot.txt delete mode 100644 tests/e2e/tree/tree-files-affected-by/snapshot.txt create mode 100644 tests/e2e/tree/tree-files-used-by/snapshot.txt delete mode 100644 tests/e2e/tree/tree-multi-file/admin.yaml delete mode 100644 tests/e2e/tree/tree-multi-file/components/schemas/Address.yaml delete mode 100644 tests/e2e/tree/tree-multi-file/components/schemas/User.yaml delete mode 100644 tests/e2e/tree/tree-multi-file/openapi.yaml delete mode 100644 tests/e2e/tree/tree-multi-file/paths/pets.yaml delete mode 100644 tests/e2e/tree/tree-multi-file/paths/users.yaml delete mode 100644 tests/e2e/tree/tree-single-file/openapi.yaml delete mode 100644 tests/e2e/tree/tree-structure-affected-file/snapshot.txt delete mode 100644 tests/e2e/tree/tree-structure-affected-pointer/snapshot.txt create mode 100644 tests/e2e/tree/tree-structure-dot/snapshot.txt delete mode 100644 tests/e2e/tree/tree-structure-multi-file/snapshot.txt create mode 100644 tests/e2e/tree/tree-structure-used-by-file/snapshot.txt rename tests/e2e/tree/{tree-structure-affected-unknown => tree-structure-used-by-unknown}/snapshot.txt (100%) create mode 100644 tests/e2e/tree/tree-structure-used-by/snapshot.txt diff --git a/docs/@v2/commands/tree.md b/docs/@v2/commands/tree.md index 5539838c10..c88a60ebf1 100644 --- a/docs/@v2/commands/tree.md +++ b/docs/@v2/commands/tree.md @@ -3,15 +3,16 @@ ## Introduction The `tree` command prints the structure of an API description: its paths, operations, and the component dependency chains between them through `$ref`. +The default view bundles the description first, so a multi-file API shows the same full tree as its single-file form. 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 `--affected-by` — which paths and operations are affected by a change to a component or file. +- Run impact analysis with `--used-by` — which paths and operations use a given component or file. This analysis is useful in CI and automated code review. -- Produce machine-readable JSON or a Mermaid diagram with `--format`. +- Produce machine-readable JSON, a Mermaid diagram, or a Graphviz DOT graph with `--format`. - View the file-level `$ref` graph with `--files`. ## Usage @@ -19,7 +20,7 @@ Use `tree` to: ```bash redocly tree redocly tree -redocly tree [--format=] [--affected-by=] [--config=] +redocly tree [--format=] [--used-by=] [--output=] [--config=] redocly tree --files [apis...] ``` @@ -29,128 +30,150 @@ 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. | -| --affected-by | [string] | Display only the part of the tree affected by the given changes. The default view accepts a JSON pointer, shorthand pointer, bare component name, or file path. `--files` mode accepts file paths only. Repeat the option to pass several values: `--affected-by Pet --affected-by /users`. | -| --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`, or `mermaid`. | -| --help | boolean | Display help. | -| --lint-config | string | Specify the severity level for the configuration file.
**Possible values:** `warn`, `error`, `off`. Default value is `warn`. | -| --version | boolean | Display version number. | +| 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`. | +| --help | boolean | Display help. | +| --lint-config | string | Specify the severity level for the configuration file.
**Possible values:** `warn`, `error`, `off`. Default value is `warn`. | +| --output, -o | string | Write the output to a file instead of `stdout`. | +| --used-by | [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. `--files` mode accepts file paths only. Repeat the option to pass several values. | +| --version | boolean | Display version number. | ## Examples ### Print the structure of an API description ```bash -redocly tree openapi.yaml +redocly tree cafe.yaml ``` ```treeview -openapi.yaml -├── /pets -│ ├── GET /pets -│ │ └── schemas/Pet -│ │ └── schemas/Address -│ └── POST /pets -│ └── schemas/PetInput -│ └── schemas/Pet ↺ -├── /pets/{petId} -│ ├── GET /pets/{petId} -│ │ └── schemas/Pet ↺ -│ └── parameters/PetId -└── /users - └── GET /users - └── schemas/User - └── schemas/Address ↺ +cafe.yaml +├── /menu +│ └── GET +│ ├── responses/BadRequest +│ │ └── schemas/Error +│ └── schemas/MenuItemList +│ ├── schemas/MenuItem +│ │ ├── schemas/Beverage +│ │ │ └── schemas/MenuBaseItem +│ │ └── schemas/Dessert +│ │ └── schemas/MenuBaseItem +│ └── schemas/Page +├── /orders +│ ├── GET +│ │ └── schemas/OrderList +│ │ └── schemas/Order +│ └── POST +│ └── schemas/Order +└── … (other paths) ``` +The tree above is abbreviated for readability — shared parameters and the repeated error responses are omitted. +An operation is shown as the method only (`GET`) under its path, since the path is its parent. + Markers legend: -- `↺` — the node was already expanded earlier in the tree. Its dependencies are not repeated. This is also how recursive schemas render. -- `✗ not found` — an unresolvable `$ref` +- `↺` — a cycle: the node references one of its ancestors (a recursive schema). It is not expanded again. A node that simply appears in more than one place (fan-in) is shown without a marker. +- `✗ not found` — an unresolvable `$ref` (only in `--files` mode; in the default view an unresolvable `$ref` is an error, see below) - `(external)` — a reference to a URL -For multi-file APIs, components living in other files appear as file nodes (for example, `paths/pets.yaml`). -Operations defined inside a `$ref`'d path-item file are represented by that file node, not expanded individually. +The default view bundles the description, so components and operations split across files are resolved to their canonical place. +A multi-file API therefore produces the same tree as its single-file equivalent — operations and named components, not file nodes. -### Find what a change affects +### Find what uses a component, path, or file -Pass one or more components, paths, or files to `--affected-by` to see only the impacted part of the tree: +Pass one or more components, paths, or files to `--used-by` to see only the part of the tree that depends on them: ```bash -redocly tree openapi.yaml --affected-by '#/components/schemas/Address' +redocly tree cafe.yaml --used-by schemas/Order ``` ```treeview -openapi.yaml -├── /pets -│ ├── GET /pets -│ │ └── schemas/Pet -│ │ └── schemas/Address ← changed -│ └── POST /pets -│ └── schemas/PetInput -│ └── schemas/Pet ↺ -├── /pets/{petId} -│ └── GET /pets/{petId} -│ └── schemas/Pet ↺ -└── /users - └── GET /users - └── schemas/User - └── schemas/Address ↺ ← changed - -4 of 4 operations affected · affected paths: /pets, /pets/{petId}, /users +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} ``` -`--affected-by` accepts several input forms: +`--used-by` accepts several input forms: -- full JSON pointer: `#/components/schemas/Address` -- shorthand pointer (the node id, without `#/components/`): `schemas/Address` -- bare component name: `Address` — ambiguous bare names match all candidates and print a note to `stderr` (impact analysis over-reports rather than under-reports) -- a file path (for multi-file specs): `schemas/address.yaml` +- 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 file path (in `--files` mode): `components/schemas/Order.yaml` - the root file itself: the whole tree is affected 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. -When the tree has no operation nodes at all (an AsyncAPI or Arazzo description, or a multi-file OpenAPI description whose path items live in `$ref`'d files), the summary falls back to counting nodes. -For example: `5 of 8 nodes 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`. -Unknown inputs print a warning to `stderr` and exit with code `0`. +A file path that matches no node prints a warning and points you to `--files`; other unknown inputs print a warning. Both exit with code `0`. ### Machine-readable output ```bash -redocly tree openapi.yaml --format=json +redocly tree cafe.yaml --format=json ``` -Prints the graph as JSON with `roots`, `nodes` (`resolved` and `external` on every node; `kind` and `file` in the default view only), and `edges` (with the exact `$ref` strings). -Only the JSON is written to `stdout`, so the output is safe to pipe. +Prints the graph as JSON 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. Each link carries the exact `$ref` strings. ```bash -redocly tree openapi.yaml --format=mermaid +redocly tree cafe.yaml --format=mermaid ``` Prints a [Mermaid](https://mermaid.js.org/) `flowchart` definition. +```bash +redocly tree cafe.yaml --format=dot +``` + +Prints a [Graphviz](https://graphviz.org/) `digraph`, consumable by Graphviz and most graph-drawing tools. + +### 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.mmd +``` + +### Invalid descriptions + +The default view bundles the description before walking it. +If the description cannot be bundled — for example, it has unresolvable or invalid `$ref`s — `tree` prints the bundling problems and exits with a non-zero code instead of printing a partial tree. + ### File-level graph ```bash -redocly tree openapi.yaml --files +redocly tree cafe.yaml --files ``` ```treeview -openapi.yaml -├── paths/pets.yaml -│ └── components/schemas/Pet.yaml -└── paths/users.yaml - └── components/schemas/User.yaml - ├── components/schemas/Address.yaml - └── components/schemas/Pet.yaml ↺ +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 ``` -`--files` displays only which files reference other files - not the paths, operations, and components inside them. +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, `--affected-by` takes file paths, and the summary counts affected files and roots. +In this mode, `--used-by` takes file paths, and the summary counts affected files and roots. diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index d27f2a3eee..6c0449b428 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -147,7 +147,8 @@ export async function handleLintConfig(argv: Exact, version: string argv.format === 'json' || argv.format === 'junit' || argv.format === 'checkstyle' || - argv.format === 'mermaid' + 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 index 13caf36d1e..36ccc30476 100644 --- a/packages/cli/src/commands/tree/__tests__/build-graph.test.ts +++ b/packages/cli/src/commands/tree/__tests__/build-graph.test.ts @@ -32,7 +32,7 @@ describe('buildGraph', () => { ]); const graph = buildGraph([{ rootDocument: makeDocument('/project/openapi.yaml'), refMap }], { - cwd: CWD, + base: CWD, resolveRef, }); @@ -63,7 +63,7 @@ describe('buildGraph', () => { ]); const graph = buildGraph([{ rootDocument: makeDocument('/project/openapi.yaml'), refMap }], { - cwd: CWD, + base: CWD, resolveRef, }); @@ -85,7 +85,7 @@ describe('buildGraph', () => { { rootDocument: makeDocument('/project/a.yaml'), refMap: refMapA }, { rootDocument: makeDocument('/project/b.yaml'), refMap: refMapB }, ], - { cwd: CWD, resolveRef } + { base: CWD, resolveRef } ); expect(graph.roots).toEqual(['a.yaml', 'b.yaml']); @@ -112,7 +112,7 @@ describe('buildGraph', () => { ]); const graph = buildGraph([{ rootDocument: makeDocument('/project/openapi.yaml'), refMap }], { - cwd: CWD, + base: CWD, resolveRef, }); @@ -134,7 +134,7 @@ describe('buildGraph', () => { ]); const graph = buildGraph([{ rootDocument: makeDocument('/project/openapi.yaml'), refMap }], { - cwd: CWD, + base: CWD, resolveRef, }); @@ -151,7 +151,7 @@ describe('buildGraph', () => { ]); const graph = buildGraph([{ rootDocument: makeDocument('/project/a.yaml'), refMap }], { - cwd: CWD, + base: CWD, resolveRef, }); diff --git a/packages/cli/src/commands/tree/__tests__/build-structure.test.ts b/packages/cli/src/commands/tree/__tests__/build-structure.test.ts index ae50085d6d..8726258ed8 100644 --- a/packages/cli/src/commands/tree/__tests__/build-structure.test.ts +++ b/packages/cli/src/commands/tree/__tests__/build-structure.test.ts @@ -478,7 +478,7 @@ describe('walkStructure', () => { }); describe('buildStructureGraph (multi-file parity)', () => { - const multiFile = path.join(process.cwd(), 'tests/e2e/tree/tree-multi-file/openapi.yaml'); + const sampleSplit = path.join(process.cwd(), 'tests/e2e/tree/sample-split/openapi.yaml'); async function structureGraphOf(apiPath: string): Promise { const config = await createConfig({}); @@ -487,7 +487,7 @@ describe('buildStructureGraph (multi-file parity)', () => { if (rootDocument instanceof Error) throw rootDocument; const specVersion = detectSpec(rootDocument.parsed); const types = normalizeTypes(config.extendTypes(getTypes(specVersion), specVersion), config); - return buildStructureGraph({ + const { graph } = await buildStructureGraph({ rootDocument, specVersion, types, @@ -495,22 +495,62 @@ describe('buildStructureGraph (multi-file parity)', () => { externalRefResolver, cwd: path.dirname(apiPath), }); + return graph; } it('bundles referenced path-items and components into a single-file-equivalent tree', async () => { - const graph = await structureGraphOf(multiFile); + const graph = await structureGraphOf(sampleSplit); const nodes = graph.nodes.map((node) => ({ id: node.id, kind: node.kind })); - expect(nodes).toContainEqual({ id: 'GET /pets', kind: 'operation' }); - expect(nodes).toContainEqual({ id: 'GET /users', kind: 'operation' }); - expect(nodes).toContainEqual({ id: 'schemas/Pet', kind: 'component' }); - expect(nodes).toContainEqual({ id: 'schemas/User', kind: 'component' }); - expect(nodes).toContainEqual({ id: 'schemas/Address', kind: 'component' }); + + // Operations from `$ref`'d path files and named components, not bare file nodes. + expect(nodes).toContainEqual({ id: 'GET /orders', kind: 'operation' }); + expect(nodes).toContainEqual({ id: 'POST /orders', kind: 'operation' }); + expect(nodes).toContainEqual({ id: 'schemas/Order', kind: 'component' }); + expect(nodes).toContainEqual({ id: 'schemas/OrderList', kind: 'component' }); expect(graph.nodes.some((node) => node.kind === 'file')).toBe(false); - expect(graph.edges).toContainEqual({ - from: 'schemas/User', - to: 'schemas/Address', - refs: ['#/components/schemas/Address'], + // Transitive component-to-component chains survive across files. + expect( + graph.edges.some((edge) => edge.from === 'schemas/Order' && edge.to.startsWith('schemas/')) + ).toBe(true); + }); + + it('returns bundle problems for an unresolved reference', async () => { + const config = await createConfig({}); + const externalRefResolver = new BaseResolver(); + const rootDocument = { + source: new Source('/project/openapi.yaml', ''), + parsed: { + openapi: '3.0.0', + info: { title: 't', version: '1' }, + paths: { + '/a': { + get: { + responses: { + '200': { + description: 'ok', + content: { + 'application/json': { schema: { $ref: './missing.yaml#/X' } }, + }, + }, + }, + }, + }, + }, + }, + } as Document; + const specVersion = detectSpec(rootDocument.parsed); + const types = normalizeTypes(config.extendTypes(getTypes(specVersion), specVersion), config); + + const { problems } = await buildStructureGraph({ + rootDocument, + specVersion, + types, + config, + externalRefResolver, + cwd: '/project', }); + + expect(problems.some((problem) => problem.severity === 'error')).toBe(true); }); }); 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 index f303476a89..7902314d9d 100644 --- a/packages/cli/src/commands/tree/__tests__/match-affected-by.test.ts +++ b/packages/cli/src/commands/tree/__tests__/match-affected-by.test.ts @@ -28,7 +28,6 @@ describe('matchAffectedBy', () => { it('case 1: exact node id match', () => { expect(matchAffectedBy(graph, ['schemas/Address'], { cwd: CWD, rootId: ROOT_ID })).toEqual({ changedIds: ['schemas/Address'], - markerIds: ['schemas/Address'], notes: [], warnings: [], }); @@ -37,7 +36,6 @@ describe('matchAffectedBy', () => { 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'], - markerIds: ['schemas/Pet'], notes: [], warnings: [], }); @@ -48,7 +46,6 @@ describe('matchAffectedBy', () => { matchAffectedBy(graph, ['#/components/schemas/Pet'], { cwd: CWD, rootId: ROOT_ID }) ).toEqual({ changedIds: ['schemas/Pet'], - markerIds: ['schemas/Pet'], notes: [], warnings: [], }); @@ -57,7 +54,6 @@ describe('matchAffectedBy', () => { it('case 3b: pointer form — operation pointer', () => { expect(matchAffectedBy(graph, ['#/paths/~1pets/get'], { cwd: CWD, rootId: ROOT_ID })).toEqual({ changedIds: ['GET /pets'], - markerIds: ['GET /pets'], notes: [], warnings: [], }); @@ -66,18 +62,16 @@ describe('matchAffectedBy', () => { 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'], - markerIds: ['common.yaml#/components/schemas/Pet'], notes: [], warnings: [], }); }); - it('case 5: root file — changedIds gets all ids, markerIds only rootId, note emitted', () => { + it('case 5: root file — changedIds gets all ids, note emitted', () => { const result = matchAffectedBy(graph, ['openapi.yaml'], { cwd: CWD, rootId: ROOT_ID }); const allIds = graph.nodes.map((n) => n.id); expect(result.changedIds).toEqual(allIds); - expect(result.markerIds).toEqual(['openapi.yaml']); expect(result.warnings).toEqual([]); expect(result.notes).toEqual([ 'openapi.yaml is the root document — the whole tree is affected.', @@ -92,11 +86,6 @@ describe('matchAffectedBy', () => { 'parameters/Pet', 'schemas/Pet', ]); - expect(result.markerIds).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.', @@ -106,7 +95,6 @@ describe('matchAffectedBy', () => { it('case 6b: bare component name matching exactly one — no note', () => { expect(matchAffectedBy(graph, ['Address'], { cwd: CWD, rootId: ROOT_ID })).toEqual({ changedIds: ['schemas/Address'], - markerIds: ['schemas/Address'], notes: [], warnings: [], }); @@ -115,7 +103,6 @@ describe('matchAffectedBy', () => { it('case 7: unknown input — empty arrays + warning', () => { expect(matchAffectedBy(graph, ['Ghost'], { cwd: CWD, rootId: ROOT_ID })).toEqual({ changedIds: [], - markerIds: [], notes: [], warnings: ['Ghost does not match any path, operation, or component of openapi.yaml.'], }); @@ -128,7 +115,6 @@ describe('matchAffectedBy', () => { }); expect(result.changedIds).toEqual(['schemas/Address']); - expect(result.markerIds).toEqual(['schemas/Address']); expect(result.warnings).toEqual([ 'Ghost does not match any path, operation, or component of openapi.yaml.', ]); @@ -143,11 +129,6 @@ describe('matchAffectedBy', () => { 'parameters/Pet', 'schemas/Pet', ]); - expect(result.markerIds).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', () => { @@ -155,7 +136,6 @@ describe('matchAffectedBy', () => { matchAffectedBy(graph, ['#/components/schemas/Missing'], { cwd: CWD, rootId: 'openapi.yaml' }) ).toEqual({ changedIds: [], - markerIds: [], notes: [], warnings: [ '#/components/schemas/Missing does not match any path, operation, or component of openapi.yaml.', @@ -166,7 +146,6 @@ describe('matchAffectedBy', () => { it('does not bare-match non-component nodes', () => { expect(matchAffectedBy(graph, ['pets'], { cwd: CWD, rootId: 'openapi.yaml' })).toEqual({ changedIds: [], - markerIds: [], notes: [], warnings: ['pets does not match any path, operation, or component of openapi.yaml.'], }); @@ -175,7 +154,6 @@ describe('matchAffectedBy', () => { 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: [], - markerIds: [], 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__/node-id.test.ts b/packages/cli/src/commands/tree/__tests__/node-id.test.ts index b74b343ffc..99812bdf40 100644 --- a/packages/cli/src/commands/tree/__tests__/node-id.test.ts +++ b/packages/cli/src/commands/tree/__tests__/node-id.test.ts @@ -1,4 +1,15 @@ -import { mapForeignLocation, mapRootPointer, parsePointerSegments } from '../node-id.js'; +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', () => { @@ -152,20 +163,4 @@ describe('mapForeignLocation', () => { file: 'common.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/cli/src/commands/tree/__tests__/print.test.ts b/packages/cli/src/commands/tree/__tests__/print.test.ts index e30774d895..60f96c538e 100644 --- a/packages/cli/src/commands/tree/__tests__/print.test.ts +++ b/packages/cli/src/commands/tree/__tests__/print.test.ts @@ -1,3 +1,5 @@ +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'; @@ -36,13 +38,13 @@ describe('renderStylish', () => { │ └── components/Pet.yaml └── paths/users.yaml └── components/User.yaml - ├── components/Pet.yaml ↺ + ├── components/Pet.yaml ├── components/missing.yaml ✗ not found └── https://example.com/shared.yaml (external)" `); }); - it('marks changed files and appends a summary in affected mode', () => { + it('appends a summary in affected mode', () => { const affected: DependencyGraph = { roots: ['openapi.yaml'], nodes: [ @@ -63,25 +65,24 @@ describe('renderStylish', () => { expect( renderStylish(affected, { - changed: ['components/Pet.yaml'], summary: '5 of 7 files affected · affected roots: openapi.yaml', }) ).toMatchInlineSnapshot(` "openapi.yaml ├── paths/pets.yaml - │ └── components/Pet.yaml ← changed + │ └── components/Pet.yaml └── paths/users.yaml └── components/User.yaml - └── components/Pet.yaml ↺ ← changed + └── components/Pet.yaml 5 of 7 files affected · affected roots: openapi.yaml" `); }); it('reports when nothing is affected', () => { - expect( - renderStylish({ roots: [], nodes: [], edges: [] }, { changed: [] }) - ).toMatchInlineSnapshot(`"No files affected."`); + expect(renderStylish({ roots: [], nodes: [], edges: [] }, {})).toMatchInlineSnapshot( + `"No files affected."` + ); }); it('renders one tree per root and re-expands shared files in each tree', () => { @@ -106,6 +107,53 @@ describe('renderStylish', () => { └── shared.yaml" `); }); + + it('marks a true cycle with ↺ but leaves fan-in repeats unmarked', () => { + 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('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', () => { @@ -151,3 +199,26 @@ describe('renderMermaid', () => { 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 index 1043f7834a..e19540f408 100644 --- a/packages/cli/src/commands/tree/build-graph.ts +++ b/packages/cli/src/commands/tree/build-graph.ts @@ -5,9 +5,9 @@ import type { DependencyGraph, GraphEdge, GraphNode } from './types.js'; export function buildGraph( resolutions: Array<{ rootDocument: Document; refMap: ResolvedRefMap }>, - options: { cwd: string; resolveRef: (base: string, uri: string) => string } + options: { base: string; resolveRef: (base: string, uri: string) => string } ): DependencyGraph { - const { cwd, resolveRef } = options; + const { base, resolveRef } = options; const nodes = new Map(); const edges = new Map(); @@ -20,7 +20,7 @@ export function buildGraph( }; for (const { rootDocument, refMap } of resolutions) { - upsertNode(toNodeId(rootDocument.source.absoluteRef, cwd), true, true); + upsertNode(toNodeId(rootDocument.source.absoluteRef, base), true, true); for (const [refId, resolvedRef] of refMap) { if (!resolvedRef.isRemote) continue; @@ -32,8 +32,8 @@ export function buildGraph( resolvedRef.document?.source.absoluteRef ?? resolveRef(sourceAbsolute, refString.split('#')[0]); - const from = toNodeId(sourceAbsolute, cwd); - const to = toNodeId(targetAbsolute, cwd); + const from = toNodeId(sourceAbsolute, base); + const to = toNodeId(targetAbsolute, base); upsertNode(from, true); upsertNode(to, resolvedRef.document !== undefined); @@ -47,7 +47,7 @@ export function buildGraph( } return { - roots: resolutions.map(({ rootDocument }) => toNodeId(rootDocument.source.absoluteRef, cwd)), + 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) })) diff --git a/packages/cli/src/commands/tree/build-structure.ts b/packages/cli/src/commands/tree/build-structure.ts index cfa3129c5a..421554c503 100644 --- a/packages/cli/src/commands/tree/build-structure.ts +++ b/packages/cli/src/commands/tree/build-structure.ts @@ -10,6 +10,7 @@ import { type Document, type Location, type NormalizedNodeType, + type NormalizedProblem, type Oas3Visitor, type ResolvedRefMap, type SpecVersion, @@ -35,10 +36,10 @@ export async function buildStructureGraph(options: { config: Config; externalRefResolver: BaseResolver; cwd: string; -}): Promise { +}): Promise<{ graph: DependencyGraph; problems: NormalizedProblem[] }> { const { rootDocument, specVersion, types, config, externalRefResolver, cwd } = options; - const { bundle } = await bundleDocument({ + const { bundle, problems } = await bundleDocument({ document: rootDocument, config, types: getTypes(specVersion), @@ -53,7 +54,7 @@ export async function buildStructureGraph(options: { const ctx: WalkContext = { problems: [], specVersion, config, visitorsData: {} }; - return walkStructure({ + const graph = walkStructure({ document: bundle, types, resolvedRefMap, @@ -61,6 +62,8 @@ export async function buildStructureGraph(options: { cwd, resolveRef: (base, uri) => externalRefResolver.resolveExternalRef(base, uri), }); + + return { graph, problems }; } export function walkStructure(options: { diff --git a/packages/cli/src/commands/tree/index.ts b/packages/cli/src/commands/tree/index.ts index 14b7412192..da51a7bb97 100644 --- a/packages/cli/src/commands/tree/index.ts +++ b/packages/cli/src/commands/tree/index.ts @@ -12,6 +12,7 @@ import { 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'; @@ -22,6 +23,8 @@ import { buildGraph } from './build-graph.js'; import { buildStructureGraph } from './build-structure.js'; import { filterAffected } from './filter-affected.js'; import { matchAffectedBy } from './match-affected-by.js'; +import { commonDir } from './node-id.js'; +import { renderDot } from './print/dot.js'; import { renderJson } from './print/json.js'; import { renderMermaid } from './print/mermaid.js'; import { renderStylish, type StylishOptions } from './print/stylish.js'; @@ -30,7 +33,8 @@ import type { DependencyGraph, TreeFormat } from './types.js'; export type TreeArgv = { apis?: string[]; format: TreeFormat; - 'affected-by'?: string[]; + output?: string; + 'used-by'?: string[]; files?: boolean; } & VerifyConfigOptions; @@ -116,16 +120,20 @@ async function handleFilesMode({ resolutions.push({ rootDocument, refMap }); } + const base = commonDir( + resolutions.map(({ rootDocument }) => path.dirname(rootDocument.source.absoluteRef)) + ); + const graph = buildGraph(resolutions, { - cwd, - resolveRef: (base, uri) => externalRefResolver.resolveExternalRef(base, uri), + base, + resolveRef: (refBase, uri) => externalRefResolver.resolveExternalRef(refBase, uri), }); let printedGraph = graph; let stylishOptions: StylishOptions = {}; - if (argv['affected-by']) { - const changedIds = argv['affected-by'].map((file) => - slash(path.relative(cwd, path.resolve(cwd, file))) + if (argv['used-by']) { + const changedIds = argv['used-by'].map((file) => + slash(path.relative(base, path.resolve(cwd, file))) ); const knownIds = new Set(graph.nodes.map((node) => node.id)); for (const id of changedIds) { @@ -136,14 +144,13 @@ async function handleFilesMode({ const knownChanged = changedIds.filter((id) => knownIds.has(id)); printedGraph = filterAffected(graph, knownChanged); stylishOptions = { - changed: knownChanged, summary: `${printedGraph.nodes.length} of ${graph.nodes.length} files affected · affected roots: ${ printedGraph.roots.join(', ') || 'none' }`, }; } - renderOutput(printedGraph, argv.format, stylishOptions); + renderOutput(printedGraph, argv, stylishOptions); } async function handleStructureMode({ @@ -161,7 +168,7 @@ async function handleStructureMode({ externalRefResolver, }); - const graph = await buildStructureGraph({ + const { graph, problems } = await buildStructureGraph({ rootDocument, specVersion, types, @@ -170,14 +177,21 @@ async function handleStructureMode({ cwd, }); + for (const problem of problems) { + logger.warn(`${problem.message}\n`); + } + if (problems.some((problem) => problem.severity === 'error')) { + return exitWithError(`Cannot display the tree: ${api.path} has bundling errors (see above).`); + } + // 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['affected-by']) { - const match = matchAffectedBy(graph, argv['affected-by'], { cwd, rootId }); + if (argv['used-by']) { + const match = matchAffectedBy(graph, argv['used-by'], { cwd, rootId }); for (const note of match.notes) { logger.warn(note + '\n'); @@ -201,28 +215,41 @@ async function handleStructureMode({ : `${printedGraph.nodes.length} of ${graph.nodes.length} nodes affected`; stylishOptions = { - changed: match.markerIds, summary, emptyMessage: 'No nodes affected.', }; } - renderOutput(printedGraph, argv.format, stylishOptions); + renderOutput(printedGraph, argv, stylishOptions); } function renderOutput( graph: DependencyGraph, - format: TreeFormat, + argv: TreeArgv, stylishOptions: StylishOptions ): void { + const rendered = renderGraph(graph, argv.format, stylishOptions); + 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': - logger.output(renderJson(graph) + '\n'); - break; + return renderJson(graph); case 'mermaid': - logger.output(renderMermaid(graph) + '\n'); - break; + return renderMermaid(graph); + case 'dot': + return renderDot(graph); default: - logger.output(renderStylish(graph, stylishOptions) + '\n'); + 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 index 372cdbf98a..06095b44ef 100644 --- a/packages/cli/src/commands/tree/match-affected-by.ts +++ b/packages/cli/src/commands/tree/match-affected-by.ts @@ -6,7 +6,6 @@ import type { DependencyGraph } from './types.js'; export type AffectedByMatch = { changedIds: string[]; - markerIds: string[]; notes: string[]; warnings: string[]; }; @@ -20,31 +19,23 @@ export function matchAffectedBy( const nodeIds = new Set(graph.nodes.map((n) => n.id)); const changedSet = new Set(); - const markerSet = new Set(); const notes: string[] = []; const warnings: string[] = []; - function addIds(ids: string[]): void { - for (const id of ids) { - changedSet.add(id); - markerSet.add(id); - } - } - for (const input of inputs) { const rel = slash(path.relative(cwd, path.resolve(cwd, input))); // Exact id wins — unless the input is the root file itself, which must fall through to the // whole-tree branch below rather than matching only the root node. if (nodeIds.has(input) && rel !== rootId) { - addIds([input]); + changedSet.add(input); continue; } if (input.startsWith('#')) { const mapped = mapRootPointer(input, rootId); if (nodeIds.has(mapped.id)) { - addIds([mapped.id]); + changedSet.add(mapped.id); continue; } } @@ -53,13 +44,12 @@ export function matchAffectedBy( for (const node of graph.nodes) { changedSet.add(node.id); } - markerSet.add(rootId); notes.push(`${rootId} is the root document — the whole tree is affected.`); continue; } const fileMatches = graph.nodes.filter((n) => n.file === rel).map((n) => n.id); if (fileMatches.length > 0) { - addIds(fileMatches); + for (const id of fileMatches) changedSet.add(id); continue; } @@ -69,7 +59,7 @@ export function matchAffectedBy( .filter((n) => n.id.split('/').at(-1) === input) .map((n) => n.id); if (componentMatches.length > 0) { - addIds(componentMatches); + for (const id of componentMatches) changedSet.add(id); if (componentMatches.length > 1) { notes.push( `"${input}" matches multiple components: ${componentMatches.join(', ')} — including all of them.` @@ -88,7 +78,6 @@ export function matchAffectedBy( return { changedIds: Array.from(changedSet), - markerIds: Array.from(markerSet), notes, warnings, }; diff --git a/packages/cli/src/commands/tree/node-id.ts b/packages/cli/src/commands/tree/node-id.ts index 4299737f77..fe547c1cae 100644 --- a/packages/cli/src/commands/tree/node-id.ts +++ b/packages/cli/src/commands/tree/node-id.ts @@ -14,6 +14,19 @@ 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', 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/json.ts b/packages/cli/src/commands/tree/print/json.ts index 31bd8e42a7..d9eae43dc8 100644 --- a/packages/cli/src/commands/tree/print/json.ts +++ b/packages/cli/src/commands/tree/print/json.ts @@ -1,5 +1,9 @@ import type { DependencyGraph } from '../types.js'; export function renderJson(graph: DependencyGraph): string { - return JSON.stringify(graph, null, 2); + 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/stylish.ts b/packages/cli/src/commands/tree/print/stylish.ts index 9353591528..159d2c5adb 100644 --- a/packages/cli/src/commands/tree/print/stylish.ts +++ b/packages/cli/src/commands/tree/print/stylish.ts @@ -2,7 +2,6 @@ import { compareStrings } from '../node-id.js'; import type { DependencyGraph } from '../types.js'; export type StylishOptions = { - changed?: string[]; summary?: string; emptyMessage?: string; }; @@ -23,38 +22,51 @@ export function renderStylish(graph: DependencyGraph, options: StylishOptions = } const nodesById = new Map(graph.nodes.map((node) => [node.id, node])); - const changed = new Set(options.changed ?? []); const lines: string[] = []; - const label = (id: string, isRepeat: boolean): 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?.external) text += ' (external)'; if (node && !node.resolved) text += ' ✗ not found'; - if (isRepeat) text += ' ↺'; - if (changed.has(id)) text += ' ← changed'; + if (isCycle) text += ' ↺'; return text; }; - // A child already expanded in this tree is printed with `↺` and not expanded again — - // this is what makes cycles and fan-in terminate. - const renderSubtree = (id: string, prefix: string, printed: Set) => { + // `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. A child printed elsewhere (fan-in) is shown once, unmarked, + // and not expanded again. + const renderSubtree = ( + id: string, + prefix: string, + printed: Set, + ancestors: Set + ) => { const children = childrenByNode.get(id) ?? []; children.forEach((child, index) => { const isLast = index === children.length - 1; - const isRepeat = printed.has(child); - lines.push(`${prefix}${isLast ? '└── ' : '├── '}${label(child, isRepeat)}`); - if (!isRepeat) { + const isCycle = ancestors.has(child); + lines.push(`${prefix}${isLast ? '└── ' : '├── '}${label(child, id, isCycle)}`); + if (!isCycle && !printed.has(child)) { printed.add(child); - renderSubtree(child, `${prefix}${isLast ? ' ' : '│ '}`, printed); + renderSubtree( + child, + `${prefix}${isLast ? ' ' : '│ '}`, + printed, + new Set([...ancestors, child]) + ); } }); }; graph.roots.forEach((root, index) => { if (index > 0) lines.push(''); - lines.push(label(root, false)); - renderSubtree(root, '', new Set([root])); + lines.push(label(root, undefined, false)); + renderSubtree(root, '', new Set([root]), new Set([root])); }); if (options.summary !== undefined) { diff --git a/packages/cli/src/commands/tree/types.ts b/packages/cli/src/commands/tree/types.ts index 53fb78303a..50235a1d73 100644 --- a/packages/cli/src/commands/tree/types.ts +++ b/packages/cli/src/commands/tree/types.ts @@ -1,4 +1,4 @@ -export type TreeFormat = 'stylish' | 'json' | 'mermaid'; +export type TreeFormat = 'stylish' | 'json' | 'mermaid' | 'dot'; export type NodeKind = 'root' | 'path' | 'operation' | 'component' | 'file'; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index acbbcc4af5..3faaf09a42 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -96,12 +96,17 @@ yargs(hideBin(process.argv)) }, format: { description: 'Use a specific output format.', - choices: ['stylish', 'json', 'mermaid'] as ReadonlyArray, + choices: ['stylish', 'json', 'mermaid', 'dot'] as ReadonlyArray, default: 'stylish' as TreeFormat, }, - 'affected-by': { + output: { + alias: 'o', + description: 'Write the output to a file instead of stdout.', + type: 'string', + }, + 'used-by': { description: - 'Show only the part of the tree affected by changes to the given components, paths, or files.', + 'Show only the part of the tree that uses (depends on) the given components, paths, or files.', array: true, type: 'string', requiresArg: true, 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/tree-multi-file/components/schemas/Pet.yaml b/tests/e2e/tree/sample-split/components/schemas/Error.yaml similarity index 53% rename from tests/e2e/tree/tree-multi-file/components/schemas/Pet.yaml rename to tests/e2e/tree/sample-split/components/schemas/Error.yaml index 5cb91cda73..0f39c053d7 100644 --- a/tests/e2e/tree/tree-multi-file/components/schemas/Pet.yaml +++ b/tests/e2e/tree/sample-split/components/schemas/Error.yaml @@ -1,4 +1,6 @@ type: object properties: - name: + 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..10393386f4 --- /dev/null +++ b/tests/e2e/tree/sample-split/openapi.yaml @@ -0,0 +1,21 @@ +openapi: 3.0.3 +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..3f04be99f1 --- /dev/null +++ b/tests/e2e/tree/sample-split/paths/orders.yaml @@ -0,0 +1,24 @@ +get: + summary: List orders + responses: + '200': + description: A list of orders. + content: + application/json: + schema: + $ref: ../components/schemas/OrderList.yaml +post: + 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..46abc65f5a --- /dev/null +++ b/tests/e2e/tree/sample-split/paths/orders_{orderId}.yaml @@ -0,0 +1,30 @@ +get: + 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: + 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-affected-by-unknown/snapshot.txt b/tests/e2e/tree/tree-files-affected-by-unknown/snapshot.txt deleted file mode 100644 index b95300c98b..0000000000 --- a/tests/e2e/tree/tree-files-affected-by-unknown/snapshot.txt +++ /dev/null @@ -1,3 +0,0 @@ -No files affected. - -components/schemas/Unknown.yaml is not referenced by any of the processed APIs. diff --git a/tests/e2e/tree/tree-files-affected-by/snapshot.txt b/tests/e2e/tree/tree-files-affected-by/snapshot.txt deleted file mode 100644 index 03262a004d..0000000000 --- a/tests/e2e/tree/tree-files-affected-by/snapshot.txt +++ /dev/null @@ -1,7 +0,0 @@ -openapi.yaml -└── paths/users.yaml - └── components/schemas/User.yaml - └── components/schemas/Address.yaml ← changed - -4 of 6 files affected · affected roots: openapi.yaml - diff --git a/tests/e2e/tree/tree-files-json/snapshot.txt b/tests/e2e/tree/tree-files-json/snapshot.txt index befe6c2a7d..d9cdb6986b 100644 --- a/tests/e2e/tree/tree-files-json/snapshot.txt +++ b/tests/e2e/tree/tree-files-json/snapshot.txt @@ -1,18 +1,23 @@ { - "roots": [ - "openapi.yaml" - ], "nodes": [ { - "id": "components/schemas/Address.yaml", + "id": "components/schemas/Error.yaml", + "resolved": true + }, + { + "id": "components/schemas/MenuItem.yaml", + "resolved": true + }, + { + "id": "components/schemas/Order.yaml", "resolved": true }, { - "id": "components/schemas/Pet.yaml", + "id": "components/schemas/OrderList.yaml", "resolved": true }, { - "id": "components/schemas/User.yaml", + "id": "components/schemas/OrderStatus.yaml", "resolved": true }, { @@ -21,55 +26,111 @@ "root": true }, { - "id": "paths/pets.yaml", + "id": "paths/orders.yaml", "resolved": true }, { - "id": "paths/users.yaml", + "id": "paths/orders_{orderId}.yaml", "resolved": true } ], - "edges": [ + "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" + ] + }, { - "from": "components/schemas/User.yaml", - "to": "components/schemas/Address.yaml", + "source": "openapi.yaml", + "target": "paths/orders.yaml", "refs": [ - "Address.yaml" + "paths/orders.yaml" ] }, { - "from": "components/schemas/User.yaml", - "to": "components/schemas/Pet.yaml", + "source": "openapi.yaml", + "target": "paths/orders_{orderId}.yaml", "refs": [ - "Pet.yaml" + "paths/orders_{orderId}.yaml" ] }, { - "from": "openapi.yaml", - "to": "paths/pets.yaml", + "source": "paths/orders.yaml", + "target": "components/schemas/Order.yaml", "refs": [ - "paths/pets.yaml" + "../components/schemas/Order.yaml" ] }, { - "from": "openapi.yaml", - "to": "paths/users.yaml", + "source": "paths/orders.yaml", + "target": "components/schemas/OrderList.yaml", "refs": [ - "paths/users.yaml" + "../components/schemas/OrderList.yaml" ] }, { - "from": "paths/pets.yaml", - "to": "components/schemas/Pet.yaml", + "source": "paths/orders_{orderId}.yaml", + "target": "components/schemas/Error.yaml", "refs": [ - "../components/schemas/Pet.yaml" + "../components/schemas/Error.yaml" ] }, { - "from": "paths/users.yaml", - "to": "components/schemas/User.yaml", + "source": "paths/orders_{orderId}.yaml", + "target": "components/schemas/Order.yaml", "refs": [ - "../components/schemas/User.yaml" + "../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 index abcd1c48fd..e9e3d85f47 100644 --- a/tests/e2e/tree/tree-files-multi-api/snapshot.txt +++ b/tests/e2e/tree/tree-files-multi-api/snapshot.txt @@ -1,14 +1,6 @@ -openapi.yaml -├── paths/pets.yaml -│ └── components/schemas/Pet.yaml -└── paths/users.yaml - └── components/schemas/User.yaml - ├── components/schemas/Address.yaml - └── components/schemas/Pet.yaml ↺ +a.yaml +└── shared.yaml -admin.yaml -└── paths/users.yaml - └── components/schemas/User.yaml - ├── components/schemas/Address.yaml - └── components/schemas/Pet.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 index 83d15d2d6d..12a575b6d1 100644 --- a/tests/e2e/tree/tree-files-stylish/snapshot.txt +++ b/tests/e2e/tree/tree-files-stylish/snapshot.txt @@ -1,8 +1,16 @@ openapi.yaml -├── paths/pets.yaml -│ └── components/schemas/Pet.yaml -└── paths/users.yaml - └── components/schemas/User.yaml - ├── components/schemas/Address.yaml - └── components/schemas/Pet.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/OrderStatus.yaml +├── paths/orders.yaml +│ ├── components/schemas/Order.yaml +│ └── components/schemas/OrderList.yaml +└── paths/orders_{orderId}.yaml + ├── components/schemas/Error.yaml + └── components/schemas/Order.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..4eeb6fbcd5 --- /dev/null +++ b/tests/e2e/tree/tree-files-used-by/snapshot.txt @@ -0,0 +1,12 @@ +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 +└── 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-file/admin.yaml b/tests/e2e/tree/tree-multi-file/admin.yaml deleted file mode 100644 index 9e814bd150..0000000000 --- a/tests/e2e/tree/tree-multi-file/admin.yaml +++ /dev/null @@ -1,7 +0,0 @@ -openapi: 3.0.3 -info: - title: Admin API - version: 1.0.0 -paths: - /admin/users: - $ref: paths/users.yaml diff --git a/tests/e2e/tree/tree-multi-file/components/schemas/Address.yaml b/tests/e2e/tree/tree-multi-file/components/schemas/Address.yaml deleted file mode 100644 index 04800108d3..0000000000 --- a/tests/e2e/tree/tree-multi-file/components/schemas/Address.yaml +++ /dev/null @@ -1,4 +0,0 @@ -type: object -properties: - city: - type: string diff --git a/tests/e2e/tree/tree-multi-file/components/schemas/User.yaml b/tests/e2e/tree/tree-multi-file/components/schemas/User.yaml deleted file mode 100644 index ef95a3500e..0000000000 --- a/tests/e2e/tree/tree-multi-file/components/schemas/User.yaml +++ /dev/null @@ -1,6 +0,0 @@ -type: object -properties: - address: - $ref: Address.yaml - pet: - $ref: Pet.yaml diff --git a/tests/e2e/tree/tree-multi-file/openapi.yaml b/tests/e2e/tree/tree-multi-file/openapi.yaml deleted file mode 100644 index 7272a04de6..0000000000 --- a/tests/e2e/tree/tree-multi-file/openapi.yaml +++ /dev/null @@ -1,9 +0,0 @@ -openapi: 3.0.0 -info: - title: Graph fixture - version: 1.0.0 -paths: - /pets: - $ref: paths/pets.yaml - /users: - $ref: paths/users.yaml diff --git a/tests/e2e/tree/tree-multi-file/paths/pets.yaml b/tests/e2e/tree/tree-multi-file/paths/pets.yaml deleted file mode 100644 index 162bb2b0ab..0000000000 --- a/tests/e2e/tree/tree-multi-file/paths/pets.yaml +++ /dev/null @@ -1,9 +0,0 @@ -get: - summary: List pets - responses: - '200': - description: OK - content: - application/json: - schema: - $ref: ../components/schemas/Pet.yaml diff --git a/tests/e2e/tree/tree-multi-file/paths/users.yaml b/tests/e2e/tree/tree-multi-file/paths/users.yaml deleted file mode 100644 index bd276a8888..0000000000 --- a/tests/e2e/tree/tree-multi-file/paths/users.yaml +++ /dev/null @@ -1,9 +0,0 @@ -get: - summary: List users - responses: - '200': - description: OK - content: - application/json: - schema: - $ref: ../components/schemas/User.yaml diff --git a/tests/e2e/tree/tree-single-file/openapi.yaml b/tests/e2e/tree/tree-single-file/openapi.yaml deleted file mode 100644 index c8a235bd6e..0000000000 --- a/tests/e2e/tree/tree-single-file/openapi.yaml +++ /dev/null @@ -1,83 +0,0 @@ -openapi: 3.0.0 -info: - title: Tree fixture - version: 1.0.0 -paths: - /pets: - get: - summary: List pets - responses: - '200': - description: OK - content: - application/json: - schema: - $ref: '#/components/schemas/Pet' - post: - summary: Create pet - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/PetInput' - responses: - '201': - description: Created - /pets/{petId}: - parameters: - - $ref: '#/components/parameters/PetId' - get: - summary: Get pet - responses: - '200': - description: OK - content: - application/json: - schema: - $ref: '#/components/schemas/Pet' - /users: - get: - summary: List users - responses: - '200': - description: OK - content: - application/json: - schema: - $ref: '#/components/schemas/User' -components: - parameters: - PetId: - name: petId - in: path - required: true - schema: - type: string - schemas: - Address: - type: object - properties: - city: - type: string - Orphan: - type: object - properties: - unused: - type: boolean - Pet: - type: object - properties: - address: - $ref: '#/components/schemas/Address' - name: - type: string - PetInput: - type: object - properties: - pet: - $ref: '#/components/schemas/Pet' - User: - type: object - properties: - address: - $ref: '#/components/schemas/Address' diff --git a/tests/e2e/tree/tree-structure-affected-file/snapshot.txt b/tests/e2e/tree/tree-structure-affected-file/snapshot.txt deleted file mode 100644 index 5770411f14..0000000000 --- a/tests/e2e/tree/tree-structure-affected-file/snapshot.txt +++ /dev/null @@ -1,3 +0,0 @@ -No nodes affected. - -components/schemas/Address.yaml does not match any path, operation, or component of openapi.yaml. For file-level analysis, use `--files`. diff --git a/tests/e2e/tree/tree-structure-affected-pointer/snapshot.txt b/tests/e2e/tree/tree-structure-affected-pointer/snapshot.txt deleted file mode 100644 index 587cad89ef..0000000000 --- a/tests/e2e/tree/tree-structure-affected-pointer/snapshot.txt +++ /dev/null @@ -1,18 +0,0 @@ -openapi.yaml -├── /pets -│ ├── GET /pets -│ │ └── schemas/Pet -│ │ └── schemas/Address ← changed -│ └── POST /pets -│ └── schemas/PetInput -│ └── schemas/Pet ↺ -├── /pets/{petId} -│ └── GET /pets/{petId} -│ └── schemas/Pet ↺ -└── /users - └── GET /users - └── schemas/User - └── schemas/Address ↺ ← changed - -4 of 4 operations affected · affected paths: /pets, /pets/{petId}, /users - 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 index 78ab43c5b4..bf9bd9fa44 100644 --- a/tests/e2e/tree/tree-structure-json/snapshot.txt +++ b/tests/e2e/tree/tree-structure-json/snapshot.txt @@ -1,46 +1,37 @@ { - "roots": [ - "openapi.yaml" - ], "nodes": [ { - "id": "/pets", + "id": "/orders", "resolved": true, "kind": "path", "file": "openapi.yaml" }, { - "id": "/pets/{petId}", + "id": "/orders/{orderId}", "resolved": true, "kind": "path", "file": "openapi.yaml" }, { - "id": "/users", - "resolved": true, - "kind": "path", - "file": "openapi.yaml" - }, - { - "id": "GET /pets", + "id": "DELETE /orders/{orderId}", "resolved": true, "kind": "operation", "file": "openapi.yaml" }, { - "id": "GET /pets/{petId}", + "id": "GET /orders", "resolved": true, "kind": "operation", "file": "openapi.yaml" }, { - "id": "GET /users", + "id": "GET /orders/{orderId}", "resolved": true, "kind": "operation", "file": "openapi.yaml" }, { - "id": "POST /pets", + "id": "POST /orders", "resolved": true, "kind": "operation", "file": "openapi.yaml" @@ -53,126 +44,114 @@ "root": true }, { - "id": "parameters/PetId", + "id": "schemas/Error", "resolved": true, "kind": "component", "file": "openapi.yaml" }, { - "id": "schemas/Address", + "id": "schemas/MenuItem", "resolved": true, "kind": "component", "file": "openapi.yaml" }, { - "id": "schemas/Pet", + "id": "schemas/Order", "resolved": true, "kind": "component", "file": "openapi.yaml" }, { - "id": "schemas/PetInput", + "id": "schemas/OrderList", "resolved": true, "kind": "component", "file": "openapi.yaml" }, { - "id": "schemas/User", + "id": "schemas/OrderStatus", "resolved": true, "kind": "component", "file": "openapi.yaml" } ], - "edges": [ + "links": [ { - "from": "/pets", - "to": "GET /pets", + "source": "/orders", + "target": "GET /orders", "refs": [] }, { - "from": "/pets", - "to": "POST /pets", + "source": "/orders", + "target": "POST /orders", "refs": [] }, { - "from": "/pets/{petId}", - "to": "GET /pets/{petId}", + "source": "/orders/{orderId}", + "target": "DELETE /orders/{orderId}", "refs": [] }, { - "from": "/pets/{petId}", - "to": "parameters/PetId", - "refs": [ - "#/components/parameters/PetId" - ] - }, - { - "from": "/users", - "to": "GET /users", + "source": "/orders/{orderId}", + "target": "GET /orders/{orderId}", "refs": [] }, { - "from": "GET /pets", - "to": "schemas/Pet", + "source": "DELETE /orders/{orderId}", + "target": "schemas/Error", "refs": [ - "#/components/schemas/Pet" + "#/components/schemas/Error" ] }, { - "from": "GET /pets/{petId}", - "to": "schemas/Pet", + "source": "GET /orders", + "target": "schemas/OrderList", "refs": [ - "#/components/schemas/Pet" + "#/components/schemas/OrderList" ] }, { - "from": "GET /users", - "to": "schemas/User", + "source": "GET /orders/{orderId}", + "target": "schemas/Order", "refs": [ - "#/components/schemas/User" + "#/components/schemas/Order" ] }, { - "from": "POST /pets", - "to": "schemas/PetInput", + "source": "POST /orders", + "target": "schemas/Order", "refs": [ - "#/components/schemas/PetInput" + "#/components/schemas/Order" ] }, { - "from": "openapi.yaml", - "to": "/pets", - "refs": [] - }, - { - "from": "openapi.yaml", - "to": "/pets/{petId}", + "source": "openapi.yaml", + "target": "/orders", "refs": [] }, { - "from": "openapi.yaml", - "to": "/users", + "source": "openapi.yaml", + "target": "/orders/{orderId}", "refs": [] }, { - "from": "schemas/Pet", - "to": "schemas/Address", + "source": "schemas/Order", + "target": "schemas/MenuItem", "refs": [ - "#/components/schemas/Address" + "#/components/schemas/MenuItem" ] }, { - "from": "schemas/PetInput", - "to": "schemas/Pet", + "source": "schemas/Order", + "target": "schemas/OrderStatus", "refs": [ - "#/components/schemas/Pet" + "#/components/schemas/OrderStatus" ] }, { - "from": "schemas/User", - "to": "schemas/Address", + "source": "schemas/OrderList", + "target": "schemas/Order", "refs": [ - "#/components/schemas/Address" + "#/components/schemas/Order" ] } ] diff --git a/tests/e2e/tree/tree-structure-mermaid/snapshot.txt b/tests/e2e/tree/tree-structure-mermaid/snapshot.txt index c27fed9588..1a6e96a5a4 100644 --- a/tests/e2e/tree/tree-structure-mermaid/snapshot.txt +++ b/tests/e2e/tree/tree-structure-mermaid/snapshot.txt @@ -1,31 +1,28 @@ flowchart LR - n0["/pets"] - n1["/pets/{petId}"] - n2["/users"] - n3["GET /pets"] - n4["GET /pets/{petId}"] - n5["GET /users"] - n6["POST /pets"] - n7["openapi.yaml"]:::root - n8["parameters/PetId"] - n9["schemas/Address"] - n10["schemas/Pet"] - n11["schemas/PetInput"] - n12["schemas/User"] + 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 --> n6 + n0 --> n5 + n1 --> n2 n1 --> n4 - n1 --> n8 - n2 --> n5 + n2 --> n7 n3 --> n10 - n4 --> n10 - n5 --> n12 - n6 --> n11 - n7 --> n0 - n7 --> n1 - n7 --> n2 + n4 --> n9 + n5 --> n9 + n6 --> n0 + n6 --> n1 + n9 --> n8 + n9 --> n11 n10 --> n9 - n11 --> n10 - n12 --> n9 classDef root font-weight:bold diff --git a/tests/e2e/tree/tree-structure-multi-file/snapshot.txt b/tests/e2e/tree/tree-structure-multi-file/snapshot.txt deleted file mode 100644 index 664e1a8789..0000000000 --- a/tests/e2e/tree/tree-structure-multi-file/snapshot.txt +++ /dev/null @@ -1,10 +0,0 @@ -openapi.yaml -├── /pets -│ └── GET /pets -│ └── schemas/Pet -└── /users - └── GET /users - └── schemas/User - ├── schemas/Address - └── schemas/Pet ↺ - diff --git a/tests/e2e/tree/tree-structure-stylish/snapshot.txt b/tests/e2e/tree/tree-structure-stylish/snapshot.txt index 517e593821..af83ae543b 100644 --- a/tests/e2e/tree/tree-structure-stylish/snapshot.txt +++ b/tests/e2e/tree/tree-structure-stylish/snapshot.txt @@ -1,17 +1,15 @@ openapi.yaml -├── /pets -│ ├── GET /pets -│ │ └── schemas/Pet -│ │ └── schemas/Address -│ └── POST /pets -│ └── schemas/PetInput -│ └── schemas/Pet ↺ -├── /pets/{petId} -│ ├── GET /pets/{petId} -│ │ └── schemas/Pet ↺ -│ └── parameters/PetId -└── /users - └── GET /users - └── schemas/User - └── schemas/Address ↺ +├── /orders +│ ├── GET +│ │ └── schemas/OrderList +│ │ └── schemas/Order +│ │ ├── schemas/MenuItem +│ │ └── schemas/OrderStatus +│ └── POST +│ └── schemas/Order +└── /orders/{orderId} + ├── DELETE + │ └── schemas/Error + └── GET + └── schemas/Order 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..09d13f4d4b --- /dev/null +++ b/tests/e2e/tree/tree-structure-used-by-file/snapshot.txt @@ -0,0 +1,3 @@ +No nodes affected. + +components/schemas/Order.yaml does not match any path, operation, or component of openapi.yaml. For file-level analysis, use `--files`. diff --git a/tests/e2e/tree/tree-structure-affected-unknown/snapshot.txt b/tests/e2e/tree/tree-structure-used-by-unknown/snapshot.txt similarity index 100% rename from tests/e2e/tree/tree-structure-affected-unknown/snapshot.txt rename to tests/e2e/tree/tree-structure-used-by-unknown/snapshot.txt 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.test.ts b/tests/e2e/tree/tree.test.ts index ff84420adc..1df72d2bef 100644 --- a/tests/e2e/tree/tree.test.ts +++ b/tests/e2e/tree/tree.test.ts @@ -8,137 +8,104 @@ const indexEntryPoint = join(process.cwd(), 'packages/cli/lib/index.js'); describe('tree', () => { const folderPath = __dirname; - const fixturePath = join(folderPath, 'tree-multi-file'); - const singleFilePath = join(folderPath, 'tree-single-file'); + const samplePath = join(folderPath, 'sample-split'); + const multiApiPath = join(folderPath, 'multi-api'); + const snapshot = (name: string) => join(folderPath, name, 'snapshot.txt'); - test('tree should print a stylish tree', async () => { - const args = getParams(indexEntryPoint, ['tree', 'openapi.yaml', '--files']); - const result = getCommandOutput(args, { testPath: fixturePath }); - await expect(cleanupOutput(result)).toMatchFileSnapshot( - join(folderPath, 'tree-files-stylish', '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 should print pure JSON', async () => { - const args = getParams(indexEntryPoint, ['tree', 'openapi.yaml', '--files', '--format=json']); - const result = getCommandOutput(args, { testPath: fixturePath }); - await expect(cleanupOutput(result)).toMatchFileSnapshot( - join(folderPath, 'tree-files-json', 'snapshot.txt') - ); + 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 should print only the affected subgraph', async () => { - const args = getParams(indexEntryPoint, [ - 'tree', - 'openapi.yaml', - '--files', - '--affected-by', - 'components/schemas/Address.yaml', - ]); - const result = getCommandOutput(args, { testPath: fixturePath }); - await expect(cleanupOutput(result)).toMatchFileSnapshot( - join(folderPath, 'tree-files-affected-by', 'snapshot.txt') - ); + 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 should warn when the affected-by file is not in the graph', async () => { + 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 shows what a component pointer is used by', async () => { const args = getParams(indexEntryPoint, [ 'tree', 'openapi.yaml', - '--files', - '--affected-by', - 'components/schemas/Unknown.yaml', + '--used-by', + '#/components/schemas/Order', ]); - const result = getCommandOutput(args, { testPath: fixturePath }); - await expect(cleanupOutput(result)).toMatchFileSnapshot( - join(folderPath, 'tree-files-affected-by-unknown', 'snapshot.txt') - ); - }); - - test('tree should print the document structure', async () => { - const args = getParams(indexEntryPoint, ['tree', 'openapi.yaml']); - const result = getCommandOutput(args, { testPath: singleFilePath }); - await expect(cleanupOutput(result)).toMatchFileSnapshot( - join(folderPath, 'tree-structure-stylish', 'snapshot.txt') - ); + const result = getCommandOutput(args, { testPath: samplePath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot(snapshot('tree-structure-used-by')); }); - test('tree should print the document structure as JSON', async () => { - const args = getParams(indexEntryPoint, ['tree', 'openapi.yaml', '--format=json']); - const result = getCommandOutput(args, { testPath: singleFilePath }); - await expect(cleanupOutput(result)).toMatchFileSnapshot( - join(folderPath, 'tree-structure-json', 'snapshot.txt') - ); - }); - - test('tree should print the document structure as a mermaid diagram', async () => { - const args = getParams(indexEntryPoint, ['tree', 'openapi.yaml', '--format=mermaid']); - const result = getCommandOutput(args, { testPath: singleFilePath }); - await expect(cleanupOutput(result)).toMatchFileSnapshot( - join(folderPath, 'tree-structure-mermaid', 'snapshot.txt') - ); - }); - - test('tree should show what a component pointer affects', async () => { + test('tree warns for an unknown used-by input', async () => { const args = getParams(indexEntryPoint, [ 'tree', 'openapi.yaml', - '--affected-by', - '#/components/schemas/Address', + '--used-by', + 'schemas/Unknown', ]); - const result = getCommandOutput(args, { testPath: singleFilePath }); + const result = getCommandOutput(args, { testPath: samplePath }); await expect(cleanupOutput(result)).toMatchFileSnapshot( - join(folderPath, 'tree-structure-affected-pointer', 'snapshot.txt') + snapshot('tree-structure-used-by-unknown') ); }); - test('tree should warn for an unknown affected-by input', async () => { + test('tree points a file used-by to --files in the default view', async () => { const args = getParams(indexEntryPoint, [ 'tree', 'openapi.yaml', - '--affected-by', - 'schemas/Unknown', + '--used-by', + 'components/schemas/Order.yaml', ]); - const result = getCommandOutput(args, { testPath: singleFilePath }); + const result = getCommandOutput(args, { testPath: samplePath }); await expect(cleanupOutput(result)).toMatchFileSnapshot( - join(folderPath, 'tree-structure-affected-unknown', 'snapshot.txt') + snapshot('tree-structure-used-by-file') ); }); - test('tree should blend cross-file structure in default mode', async () => { - const args = getParams(indexEntryPoint, ['tree', 'openapi.yaml']); - const result = getCommandOutput(args, { testPath: fixturePath }); - await expect(cleanupOutput(result)).toMatchFileSnapshot( - join(folderPath, 'tree-structure-multi-file', 'snapshot.txt') - ); + 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 should point a changed file to --files in the default view', async () => { + 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', - '--affected-by', - 'components/schemas/Address.yaml', + '--files', + '--used-by', + 'components/schemas/Order.yaml', ]); - const result = getCommandOutput(args, { testPath: fixturePath }); - await expect(cleanupOutput(result)).toMatchFileSnapshot( - join(folderPath, 'tree-structure-affected-file', 'snapshot.txt') - ); + const result = getCommandOutput(args, { testPath: samplePath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot(snapshot('tree-files-used-by')); }); - test('tree should reject multiple APIs in the default view', async () => { - const args = getParams(indexEntryPoint, ['tree', 'openapi.yaml', 'admin.yaml']); - const result = getCommandOutput(args, { testPath: fixturePath }); - await expect(cleanupOutput(result)).toMatchFileSnapshot( - join(folderPath, 'tree-multi-api-error', 'snapshot.txt') - ); + 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 should merge multiple APIs into one graph', async () => { - const args = getParams(indexEntryPoint, ['tree', 'openapi.yaml', 'admin.yaml', '--files']); - const result = getCommandOutput(args, { testPath: fixturePath }); - await expect(cleanupOutput(result)).toMatchFileSnapshot( - join(folderPath, 'tree-files-multi-api', 'snapshot.txt') - ); + 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')); }); }); From ed18a16f88bd09bd8ec7bfdeba8321e6eee7d82a Mon Sep 17 00:00:00 2001 From: kanoru Date: Wed, 24 Jun 2026 16:58:11 +0300 Subject: [PATCH 42/79] fix: update docs --- docs/@v2/commands/tree.md | 272 ++++++++++++++++++++--- tests/e2e/tree/sample-split/openapi.yaml | 2 +- 2 files changed, 244 insertions(+), 30 deletions(-) diff --git a/docs/@v2/commands/tree.md b/docs/@v2/commands/tree.md index c88a60ebf1..6aaef723f2 100644 --- a/docs/@v2/commands/tree.md +++ b/docs/@v2/commands/tree.md @@ -53,26 +53,45 @@ redocly tree cafe.yaml ```treeview cafe.yaml ├── /menu -│ └── GET +│ ├── 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 +│ ├── responses/Conflict │ │ └── schemas/Error -│ └── schemas/MenuItemList -│ ├── schemas/MenuItem -│ │ ├── schemas/Beverage -│ │ │ └── schemas/MenuBaseItem -│ │ └── schemas/Dessert -│ │ └── schemas/MenuBaseItem -│ └── schemas/Page -├── /orders +│ ├── responses/Forbidden +│ │ └── schemas/Error +│ ├── responses/InternalServerError +│ ├── responses/Unauthorized +│ │ └── schemas/Error +│ └── schemas/MenuItem +├── /menu-item-images/{menuItemId} │ ├── GET -│ │ └── schemas/OrderList -│ │ └── schemas/Order -│ └── POST -│ └── schemas/Order +│ │ ├── parameters/PhotoSize +│ │ ├── responses/InternalServerError +│ │ └── responses/NotFound +│ │ └── schemas/Error +│ └── parameters/MenuItemId └── … (other paths) ``` -The tree above is abbreviated for readability — shared parameters and the repeated error responses are omitted. +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: @@ -81,6 +100,103 @@ Markers legend: - `✗ not found` — an unresolvable `$ref` (only in `--files` mode; in the default view an unresolvable `$ref` is an error, see below) - `(external)` — 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 repeat is marked `↺` and not expanded again. + +{% /tab %} +{% /tabs %} + +In `--files` mode, an unresolvable `$ref` is shown as `✗ not found`, and a URL reference is marked `(external)`: + +{% tabs %} +{% tab label="API description" %} + +```yaml +# openapi.yaml +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 (external) ✗ not found +└── schemas/Order.yaml ✗ not found +``` + +`schemas/Order.yaml` does not exist, so it is `✗ not found`. The URL is `(external)`; here it is also unreachable, so it is `✗ not found` too. + +{% /tab %} +{% /tabs %} + The default view bundles the description, so components and operations split across files are resolved to their canonical place. A multi-file API therefore produces the same tree as its single-file equivalent — operations and named components, not file nodes. @@ -97,11 +213,15 @@ cafe.yaml ├── /orders │ ├── GET │ │ └── schemas/OrderList -│ │ └── schemas/Order│ └── POST -│ └── schemas/Order└── /orders/{orderId} +│ │ └── schemas/Order +│ └── POST +│ └── schemas/Order +└── /orders/{orderId} ├── GET - │ └── schemas/Order └── PATCH + │ └── schemas/Order + └── PATCH └── schemas/Order + 4 of 12 operations affected · affected paths: /orders, /orders/{orderId} ``` @@ -113,38 +233,132 @@ cafe.yaml - a file path (in `--files` mode): `components/schemas/Order.yaml` - the root file itself: the whole tree is affected +Examples of the different input forms: + +```bash +# full JSON pointer +redocly tree cafe.yaml --used-by '#/components/schemas/Order' + +# shorthand pointer (the node id) +redocly tree cafe.yaml --used-by schemas/Order + +# bare component name — matches any component with that name +redocly tree cafe.yaml --used-by Order + +# several values at once — repeat the flag +redocly tree cafe.yaml --used-by schemas/Order --used-by schemas/MenuItem + +# file-level: which files depend on a given file +redocly tree cafe.yaml --files --used-by 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`. -A file path that matches no node prints a warning and points you to `--files`; other unknown inputs print a warning. Both exit with code `0`. +An unknown `--used-by` 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 -```bash -redocly tree cafe.yaml --format=json +`--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 ``` -Prints the graph as JSON 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. Each link carries the exact `$ref` strings. - -```bash -redocly tree cafe.yaml --format=mermaid +{% /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. +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": [] } + ] +} ``` -Prints a [Mermaid](https://mermaid.js.org/) `flowchart` definition. +{% /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 +``` -```bash -redocly tree cafe.yaml --format=dot +{% /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"; +} ``` -Prints a [Graphviz](https://graphviz.org/) `digraph`, consumable by Graphviz and most graph-drawing tools. +{% /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.mmd +redocly tree cafe.yaml --format=mermaid --output cafe.md ``` ### Invalid descriptions diff --git a/tests/e2e/tree/sample-split/openapi.yaml b/tests/e2e/tree/sample-split/openapi.yaml index 10393386f4..2141db2eb7 100644 --- a/tests/e2e/tree/sample-split/openapi.yaml +++ b/tests/e2e/tree/sample-split/openapi.yaml @@ -1,4 +1,4 @@ -openapi: 3.0.3 +openapi: 3.2.0 info: title: Sample Cafe API version: 1.0.0 From 7df48d3746c6b1dbbf0de9248c3d702f424dd8f8 Mon Sep 17 00:00:00 2001 From: kanoru Date: Wed, 24 Jun 2026 17:31:05 +0300 Subject: [PATCH 43/79] fix: resolve cursor bugs --- .../tree/__tests__/filter-affected.test.ts | 49 +++++++++++++++++++ .../tree/__tests__/match-affected-by.test.ts | 22 +++++++-- .../cli/src/commands/tree/filter-affected.ts | 12 +++++ .../src/commands/tree/match-affected-by.ts | 28 +++++------ 4 files changed, 93 insertions(+), 18 deletions(-) diff --git a/packages/cli/src/commands/tree/__tests__/filter-affected.test.ts b/packages/cli/src/commands/tree/__tests__/filter-affected.test.ts index 309744b568..785c0a08fa 100644 --- a/packages/cli/src/commands/tree/__tests__/filter-affected.test.ts +++ b/packages/cli/src/commands/tree/__tests__/filter-affected.test.ts @@ -65,3 +65,52 @@ describe('filterAffected', () => { 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', + ]); + }); +}); 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 index 7902314d9d..2da4cbe55b 100644 --- a/packages/cli/src/commands/tree/__tests__/match-affected-by.test.ts +++ b/packages/cli/src/commands/tree/__tests__/match-affected-by.test.ts @@ -33,6 +33,14 @@ describe('matchAffectedBy', () => { }); }); + 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'], @@ -67,17 +75,25 @@ describe('matchAffectedBy', () => { }); }); - it('case 5: root file — changedIds gets all ids, note emitted', () => { + 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 }); - const allIds = graph.nodes.map((n) => n.id); - expect(result.changedIds).toEqual(allIds); + 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 }); diff --git a/packages/cli/src/commands/tree/filter-affected.ts b/packages/cli/src/commands/tree/filter-affected.ts index 8ebb68561a..604506a872 100644 --- a/packages/cli/src/commands/tree/filter-affected.ts +++ b/packages/cli/src/commands/tree/filter-affected.ts @@ -29,7 +29,19 @@ export function collectConnectedIds( } 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)), diff --git a/packages/cli/src/commands/tree/match-affected-by.ts b/packages/cli/src/commands/tree/match-affected-by.ts index 06095b44ef..e4495747d3 100644 --- a/packages/cli/src/commands/tree/match-affected-by.ts +++ b/packages/cli/src/commands/tree/match-affected-by.ts @@ -24,27 +24,25 @@ export function matchAffectedBy( for (const input of inputs) { const rel = slash(path.relative(cwd, path.resolve(cwd, input))); + const pointer = input.startsWith('#') ? mapRootPointer(input, rootId) : undefined; - // Exact id wins — unless the input is the root file itself, which must fall through to the - // whole-tree branch below rather than matching only the root node. - if (nodeIds.has(input) && rel !== rootId) { - changedSet.add(input); + // 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; } - if (input.startsWith('#')) { - const mapped = mapRootPointer(input, rootId); - if (nodeIds.has(mapped.id)) { - changedSet.add(mapped.id); - 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 (rel === rootId) { - for (const node of graph.nodes) { - changedSet.add(node.id); - } - notes.push(`${rootId} is the root document — the whole tree is affected.`); + if (pointer && nodeIds.has(pointer.id)) { + changedSet.add(pointer.id); continue; } const fileMatches = graph.nodes.filter((n) => n.file === rel).map((n) => n.id); From 7565984ce15ea294ba9b8bf4ebb24ece35223417 Mon Sep 17 00:00:00 2001 From: kanoru Date: Wed, 24 Jun 2026 17:46:40 +0300 Subject: [PATCH 44/79] fix: update docs --- docs/@v2/commands/tree.md | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/docs/@v2/commands/tree.md b/docs/@v2/commands/tree.md index 6aaef723f2..0ad3908ac4 100644 --- a/docs/@v2/commands/tree.md +++ b/docs/@v2/commands/tree.md @@ -30,17 +30,16 @@ 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`. | -| --help | boolean | Display help. | -| --lint-config | string | Specify the severity level for the configuration file.
**Possible values:** `warn`, `error`, `off`. Default value is `warn`. | -| --output, -o | string | Write the output to a file instead of `stdout`. | -| --used-by | [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. `--files` mode accepts file paths only. Repeat the option to pass several values. | -| --version | boolean | Display version number. | +| 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`. | +| --help | boolean | Display help. | | +| --output, -o | string | Write the output to a file instead of `stdout`. | +| --used-by | [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. `--files` mode accepts file paths only. Repeat the option to pass several values. | +| --version | boolean | Display version number. | ## Examples @@ -150,13 +149,13 @@ menu.yaml {% /tab %} {% /tabs %} -In `--files` mode, an unresolvable `$ref` is shown as `✗ not found`, and a URL reference is marked `(external)`: +`✗ not found` and `(external)` appear only with `--files`. In the default view an unresolvable `$ref` is a bundling error instead (see _Invalid descriptions_ below), so this example must be run with `--files`: {% tabs %} {% tab label="API description" %} ```yaml -# openapi.yaml +# openapi.yaml — has a missing-file ref and an unreachable URL ref openapi: 3.2.0 info: title: Cafe From ebbcc7102738323815e26f6b05bdcfdc98dee177 Mon Sep 17 00:00:00 2001 From: kanoru Date: Fri, 26 Jun 2026 12:49:06 +0300 Subject: [PATCH 45/79] chore: small fixes --- docs/@v2/commands/tree.md | 82 +++++++++++++------ .../src/commands/tree/__tests__/print.test.ts | 40 +++++++-- packages/cli/src/commands/tree/index.ts | 19 +++-- .../cli/src/commands/tree/print/stylish.ts | 23 ++---- packages/cli/src/index.ts | 2 +- .../e2e/tree/tree-files-stylish/snapshot.txt | 9 ++ .../e2e/tree/tree-files-used-by/snapshot.txt | 1 + .../tree/tree-structure-stylish/snapshot.txt | 4 + tests/e2e/tree/tree.test.ts | 27 ++++-- 9 files changed, 147 insertions(+), 60 deletions(-) diff --git a/docs/@v2/commands/tree.md b/docs/@v2/commands/tree.md index 0ad3908ac4..427aa9fe59 100644 --- a/docs/@v2/commands/tree.md +++ b/docs/@v2/commands/tree.md @@ -10,7 +10,7 @@ AsyncAPI and Arazzo descriptions are supported too, but render as a flat list of Use `tree` to: - Get quick orientation in any API, whether single-file or multi-file. -- Run impact analysis with `--used-by` — which paths and operations use a given component or 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`. @@ -20,7 +20,7 @@ Use `tree` to: ```bash redocly tree redocly tree -redocly tree [--format=] [--used-by=] [--output=] [--config=] +redocly tree [--format=] [--uses=] [--output=] [--config=] redocly tree --files [apis...] ``` @@ -38,7 +38,7 @@ Use `--files` for the multi-API file graph. | --format | string | Output format: `stylish` (default, tree view), `json`, `mermaid`, or `dot`. | | --help | boolean | Display help. | | | --output, -o | string | Write the output to a file instead of `stdout`. | -| --used-by | [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. `--files` mode accepts file paths only. Repeat the option to pass several values. | +| --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. `--files` mode accepts file paths only. Repeat the option to pass several values. | | --version | boolean | Display version number. | ## Examples @@ -72,18 +72,25 @@ cafe.yaml │ │ └── 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 @@ -95,11 +102,11 @@ An operation is shown as the method only (`GET`) under its path, since the path Markers legend: -- `↺` — a cycle: the node references one of its ancestors (a recursive schema). It is not expanded again. A node that simply appears in more than one place (fan-in) is shown without a marker. -- `✗ not found` — an unresolvable `$ref` (only in `--files` mode; in the default view an unresolvable `$ref` is an error, see below) -- `(external)` — a reference to a URL +- `🔁` — 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` (only in `--files` mode; in the default view an unresolvable `$ref` is an error, see below) +- `🔗` — a reference to a URL -A recursive schema produces the `↺` marker: +A recursive schema produces the `🔁` marker: {% tabs %} {% tab label="API description" %} @@ -141,15 +148,15 @@ menu.yaml └── /menu └── GET └── schemas/MenuSection - └── schemas/MenuSection ↺ + └── schemas/MenuSection 🔁 ``` -`MenuSection` references itself, so the repeat is marked `↺` and not expanded again. +`MenuSection` references itself, so the cycle is marked `🔁` and not expanded again. {% /tab %} {% /tabs %} -`✗ not found` and `(external)` appear only with `--files`. In the default view an unresolvable `$ref` is a bundling error instead (see _Invalid descriptions_ below), so this example must be run with `--files`: +`❌` and `🔗` appear only with `--files`. In the default view an unresolvable `$ref` is a bundling error instead (see _Invalid descriptions_ below), so this example must be run with `--files`: {% tabs %} {% tab label="API description" %} @@ -187,11 +194,11 @@ redocly tree openapi.yaml --files ```treeview openapi.yaml -├── https://example.com/schemas/Error.yaml (external) ✗ not found -└── schemas/Order.yaml ✗ not found +├── https://example.com/schemas/Error.yaml 🔗 ❌ +└── schemas/Order.yaml ❌ ``` -`schemas/Order.yaml` does not exist, so it is `✗ not found`. The URL is `(external)`; here it is also unreachable, so it is `✗ not found` too. +`schemas/Order.yaml` does not exist, so it is `❌`. The URL is `🔗`; here it is also unreachable, so it is `❌` too. {% /tab %} {% /tabs %} @@ -201,10 +208,10 @@ A multi-file API therefore produces the same tree as its single-file equivalent ### Find what uses a component, path, or file -Pass one or more components, paths, or files to `--used-by` to see only the part of the tree that depends on them: +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 --used-by schemas/Order +redocly tree cafe.yaml --uses schemas/Order ``` ```treeview @@ -224,7 +231,7 @@ cafe.yaml 4 of 12 operations affected · affected paths: /orders, /orders/{orderId} ``` -`--used-by` accepts several input forms: +`--uses` accepts several input forms: - full JSON pointer: `#/components/schemas/Order` - shorthand pointer (the node id): `schemas/Order` @@ -236,26 +243,26 @@ Examples of the different input forms: ```bash # full JSON pointer -redocly tree cafe.yaml --used-by '#/components/schemas/Order' +redocly tree cafe.yaml --uses '#/components/schemas/Order' # shorthand pointer (the node id) -redocly tree cafe.yaml --used-by schemas/Order +redocly tree cafe.yaml --uses schemas/Order # bare component name — matches any component with that name -redocly tree cafe.yaml --used-by Order +redocly tree cafe.yaml --uses Order # several values at once — repeat the flag -redocly tree cafe.yaml --used-by schemas/Order --used-by schemas/MenuItem +redocly tree cafe.yaml --uses schemas/Order --uses schemas/MenuItem # file-level: which files depend on a given file -redocly tree cafe.yaml --files --used-by components/schemas/Order.yaml +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 `--used-by` 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. +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 @@ -367,6 +374,9 @@ If the description cannot be bundled — for example, it has unresolvable or inv ### 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 ``` @@ -389,4 +399,30 @@ The tree above is abbreviated; the real output lists every file. 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, `--used-by` takes file paths, and the summary counts affected files and roots. +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 +``` diff --git a/packages/cli/src/commands/tree/__tests__/print.test.ts b/packages/cli/src/commands/tree/__tests__/print.test.ts index 60f96c538e..ef485d73a4 100644 --- a/packages/cli/src/commands/tree/__tests__/print.test.ts +++ b/packages/cli/src/commands/tree/__tests__/print.test.ts @@ -31,7 +31,7 @@ const graph: DependencyGraph = { }; describe('renderStylish', () => { - it('renders a tree with repeat, broken-ref, and external markers', () => { + it('renders a tree with broken-ref and external markers', () => { expect(renderStylish(graph)).toMatchInlineSnapshot(` "openapi.yaml ├── paths/pets.yaml @@ -39,8 +39,8 @@ describe('renderStylish', () => { └── paths/users.yaml └── components/User.yaml ├── components/Pet.yaml - ├── components/missing.yaml ✗ not found - └── https://example.com/shared.yaml (external)" + ├── components/missing.yaml ❌ + └── https://example.com/shared.yaml 🔗" `); }); @@ -108,7 +108,7 @@ describe('renderStylish', () => { `); }); - it('marks a true cycle with ↺ but leaves fan-in repeats unmarked', () => { + it('marks a true cycle with 🔁 and stops expanding', () => { const cyclic: DependencyGraph = { roots: ['root.yaml'], nodes: [ @@ -127,7 +127,37 @@ describe('renderStylish', () => { "root.yaml └── A.yaml └── B.yaml - └── A.yaml ↺" + └── A.yaml 🔁" + `); + }); + + 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" `); }); diff --git a/packages/cli/src/commands/tree/index.ts b/packages/cli/src/commands/tree/index.ts index da51a7bb97..0ba156d31d 100644 --- a/packages/cli/src/commands/tree/index.ts +++ b/packages/cli/src/commands/tree/index.ts @@ -34,7 +34,7 @@ export type TreeArgv = { apis?: string[]; format: TreeFormat; output?: string; - 'used-by'?: string[]; + uses?: string[]; files?: boolean; } & VerifyConfigOptions; @@ -131,11 +131,16 @@ async function handleFilesMode({ let printedGraph = graph; let stylishOptions: StylishOptions = {}; - if (argv['used-by']) { - const changedIds = argv['used-by'].map((file) => - slash(path.relative(base, path.resolve(cwd, file))) - ); + 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. + const changedIds = argv['uses'].map((file) => { + 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`); @@ -190,8 +195,8 @@ async function handleStructureMode({ let printedGraph = graph; let stylishOptions: StylishOptions = {}; - if (argv['used-by']) { - const match = matchAffectedBy(graph, argv['used-by'], { cwd, rootId }); + if (argv['uses']) { + const match = matchAffectedBy(graph, argv['uses'], { cwd, rootId }); for (const note of match.notes) { logger.warn(note + '\n'); diff --git a/packages/cli/src/commands/tree/print/stylish.ts b/packages/cli/src/commands/tree/print/stylish.ts index 159d2c5adb..7dcb1e7023 100644 --- a/packages/cli/src/commands/tree/print/stylish.ts +++ b/packages/cli/src/commands/tree/print/stylish.ts @@ -31,32 +31,25 @@ export function renderStylish(graph: DependencyGraph, options: StylishOptions = if (node?.kind === 'operation' && parentId && id.endsWith(` ${parentId}`)) { text = id.slice(0, -parentId.length - 1); } - if (node?.external) text += ' (external)'; - if (node && !node.resolved) text += ' ✗ not found'; - if (isCycle) text += ' ↺'; + 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. A child printed elsewhere (fan-in) is shown once, unmarked, - // and not expanded again. - const renderSubtree = ( - id: string, - prefix: string, - printed: Set, - ancestors: Set - ) => { + // 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. + const renderSubtree = (id: string, prefix: string, ancestors: Set) => { const children = childrenByNode.get(id) ?? []; children.forEach((child, index) => { const isLast = index === children.length - 1; const isCycle = ancestors.has(child); lines.push(`${prefix}${isLast ? '└── ' : '├── '}${label(child, id, isCycle)}`); - if (!isCycle && !printed.has(child)) { - printed.add(child); + if (!isCycle) { renderSubtree( child, `${prefix}${isLast ? ' ' : '│ '}`, - printed, new Set([...ancestors, child]) ); } @@ -66,7 +59,7 @@ export function renderStylish(graph: DependencyGraph, options: StylishOptions = graph.roots.forEach((root, index) => { if (index > 0) lines.push(''); lines.push(label(root, undefined, false)); - renderSubtree(root, '', new Set([root]), new Set([root])); + renderSubtree(root, '', new Set([root])); }); if (options.summary !== undefined) { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 3faaf09a42..a592f8af2a 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -104,7 +104,7 @@ yargs(hideBin(process.argv)) description: 'Write the output to a file instead of stdout.', type: 'string', }, - 'used-by': { + uses: { description: 'Show only the part of the tree that uses (depends on) the given components, paths, or files.', array: true, diff --git a/tests/e2e/tree/tree-files-stylish/snapshot.txt b/tests/e2e/tree/tree-files-stylish/snapshot.txt index 12a575b6d1..77f9d9bab1 100644 --- a/tests/e2e/tree/tree-files-stylish/snapshot.txt +++ b/tests/e2e/tree/tree-files-stylish/snapshot.txt @@ -6,11 +6,20 @@ openapi.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 index 4eeb6fbcd5..cfa3f36f25 100644 --- a/tests/e2e/tree/tree-files-used-by/snapshot.txt +++ b/tests/e2e/tree/tree-files-used-by/snapshot.txt @@ -5,6 +5,7 @@ openapi.yaml ├── paths/orders.yaml │ ├── components/schemas/Order.yaml │ └── components/schemas/OrderList.yaml +│ └── components/schemas/Order.yaml └── paths/orders_{orderId}.yaml └── components/schemas/Order.yaml diff --git a/tests/e2e/tree/tree-structure-stylish/snapshot.txt b/tests/e2e/tree/tree-structure-stylish/snapshot.txt index af83ae543b..73088b4509 100644 --- a/tests/e2e/tree/tree-structure-stylish/snapshot.txt +++ b/tests/e2e/tree/tree-structure-stylish/snapshot.txt @@ -7,9 +7,13 @@ openapi.yaml │ │ └── 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.test.ts b/tests/e2e/tree/tree.test.ts index 1df72d2bef..087aeee9ce 100644 --- a/tests/e2e/tree/tree.test.ts +++ b/tests/e2e/tree/tree.test.ts @@ -40,7 +40,7 @@ describe('tree', () => { const args = getParams(indexEntryPoint, [ 'tree', 'openapi.yaml', - '--used-by', + '--uses', '#/components/schemas/Order', ]); const result = getCommandOutput(args, { testPath: samplePath }); @@ -48,12 +48,7 @@ describe('tree', () => { }); test('tree warns for an unknown used-by input', async () => { - const args = getParams(indexEntryPoint, [ - 'tree', - 'openapi.yaml', - '--used-by', - 'schemas/Unknown', - ]); + 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') @@ -64,7 +59,7 @@ describe('tree', () => { const args = getParams(indexEntryPoint, [ 'tree', 'openapi.yaml', - '--used-by', + '--uses', 'components/schemas/Order.yaml', ]); const result = getCommandOutput(args, { testPath: samplePath }); @@ -90,13 +85,27 @@ describe('tree', () => { 'tree', 'openapi.yaml', '--files', - '--used-by', + '--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 }); From 314e9dc7594d41b111805b7223e4a6a18a1fed25 Mon Sep 17 00:00:00 2001 From: kanoru Date: Mon, 13 Jul 2026 20:28:12 +0300 Subject: [PATCH 46/79] feat: add level flag --- docs/@v2/commands/tree.md | 86 ++++++++++++++++--- .../tree/__tests__/filter-affected.test.ts | 85 +++++++++++++++++- .../tree/__tests__/match-affected-by.test.ts | 35 ++++++++ .../src/commands/tree/__tests__/print.test.ts | 33 +++++++ .../cli/src/commands/tree/filter-affected.ts | 53 ++++++++++++ packages/cli/src/commands/tree/index.ts | 48 +++++++++-- .../src/commands/tree/match-affected-by.ts | 20 +++++ .../cli/src/commands/tree/print/stylish.ts | 18 ++-- packages/cli/src/index.ts | 12 ++- .../tree/tree-structure-level/snapshot.txt | 4 + .../tree-structure-operations/snapshot.txt | 8 ++ tests/e2e/tree/tree.test.ts | 20 +++++ 12 files changed, 397 insertions(+), 25 deletions(-) create mode 100644 tests/e2e/tree/tree-structure-level/snapshot.txt create mode 100644 tests/e2e/tree/tree-structure-operations/snapshot.txt diff --git a/docs/@v2/commands/tree.md b/docs/@v2/commands/tree.md index 427aa9fe59..47929bfac6 100644 --- a/docs/@v2/commands/tree.md +++ b/docs/@v2/commands/tree.md @@ -20,7 +20,7 @@ Use `tree` to: ```bash redocly tree redocly tree -redocly tree [--format=] [--uses=] [--output=] [--config=] +redocly tree [--format=] [--uses=] [--level=] [--operations] [--output=] [--config=] redocly tree --files [apis...] ``` @@ -30,16 +30,18 @@ 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`. | -| --help | boolean | Display help. | | -| --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. `--files` mode accepts file paths only. Repeat the option to pass several values. | -| --version | boolean | Display version number. | +| 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`. | +| --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 `…`. | +| --operations | boolean | Display only the API surface — paths, operations, and webhooks — without component chains. 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. | ## Examples @@ -206,6 +208,64 @@ openapi.yaml The default view bundles the description, so components and operations split across files are resolved to their canonical place. A multi-file API therefore produces the same tree as its single-file equivalent — operations and named components, not file nodes. +### 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 +│ └── POST +├── /menu-item-images/{menuItemId} +│ └── GET +├── /menu/{menuItemId} +│ └── DELETE +├── /oauth2/register +│ └── POST +├── /order-items +│ └── GET +├── /orders +│ ├── GET +│ └── POST +├── /orders/{orderId} +│ ├── DELETE +│ ├── GET +│ └── PATCH +├── /revenue +│ └── GET +└── webhooks/order-notification +``` + +`--operations` displays every path with all of its operations and the webhook entries, hiding the component chains. +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: @@ -236,6 +296,7 @@ cafe.yaml - 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 (in `--files` mode): `components/schemas/Order.yaml` - the root file itself: the whole tree is affected @@ -254,6 +315,9 @@ 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 ``` diff --git a/packages/cli/src/commands/tree/__tests__/filter-affected.test.ts b/packages/cli/src/commands/tree/__tests__/filter-affected.test.ts index 785c0a08fa..b690b674bb 100644 --- a/packages/cli/src/commands/tree/__tests__/filter-affected.test.ts +++ b/packages/cli/src/commands/tree/__tests__/filter-affected.test.ts @@ -1,4 +1,4 @@ -import { filterAffected } from '../filter-affected.js'; +import { filterAffected, filterOperations, limitGraphLevel } from '../filter-affected.js'; import type { DependencyGraph } from '../types.js'; const graph: DependencyGraph = { @@ -114,3 +114,86 @@ describe('filterAffected — container seeds include their subtree', () => { ]); }); }); + +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__/match-affected-by.test.ts b/packages/cli/src/commands/tree/__tests__/match-affected-by.test.ts index 2da4cbe55b..c53fc9d0f6 100644 --- a/packages/cli/src/commands/tree/__tests__/match-affected-by.test.ts +++ b/packages/cli/src/commands/tree/__tests__/match-affected-by.test.ts @@ -116,6 +116,41 @@ describe('matchAffectedBy', () => { }); }); + 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: [], diff --git a/packages/cli/src/commands/tree/__tests__/print.test.ts b/packages/cli/src/commands/tree/__tests__/print.test.ts index ef485d73a4..99c8b785f3 100644 --- a/packages/cli/src/commands/tree/__tests__/print.test.ts +++ b/packages/cli/src/commands/tree/__tests__/print.test.ts @@ -131,6 +131,39 @@ describe('renderStylish', () => { `); }); + 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'], diff --git a/packages/cli/src/commands/tree/filter-affected.ts b/packages/cli/src/commands/tree/filter-affected.ts index 604506a872..e22bc1b287 100644 --- a/packages/cli/src/commands/tree/filter-affected.ts +++ b/packages/cli/src/commands/tree/filter-affected.ts @@ -28,6 +28,59 @@ export function collectConnectedIds( return seen; } +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 }); diff --git a/packages/cli/src/commands/tree/index.ts b/packages/cli/src/commands/tree/index.ts index 0ba156d31d..065824a4c2 100644 --- a/packages/cli/src/commands/tree/index.ts +++ b/packages/cli/src/commands/tree/index.ts @@ -21,8 +21,8 @@ import { getFallbackApisOrExit } from '../../utils/miscellaneous.js'; import type { CommandArgs } from '../../wrapper.js'; import { buildGraph } from './build-graph.js'; import { buildStructureGraph } from './build-structure.js'; -import { filterAffected } from './filter-affected.js'; -import { matchAffectedBy } from './match-affected-by.js'; +import { filterAffected, filterOperations, limitGraphLevel } from './filter-affected.js'; +import { matchAffectedBy, wildcardToRegExp } from './match-affected-by.js'; import { commonDir } from './node-id.js'; import { renderDot } from './print/dot.js'; import { renderJson } from './print/json.js'; @@ -34,6 +34,8 @@ export type TreeArgv = { apis?: string[]; format: TreeFormat; output?: string; + level?: number; + operations?: boolean; uses?: string[]; files?: boolean; } & VerifyConfigOptions; @@ -47,11 +49,20 @@ type TreeModeContext = { }; 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) { + 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 }); } @@ -134,12 +145,21 @@ async function handleFilesMode({ 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. - const changedIds = argv['uses'].map((file) => { + // 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; + if (knownIds.has(fromRoot)) return [fromRoot]; const fromCwd = slash(path.relative(base, path.resolve(cwd, file))); - return knownIds.has(fromCwd) ? fromCwd : fromRoot; + return [knownIds.has(fromCwd) ? fromCwd : fromRoot]; }); for (const id of changedIds) { if (!knownIds.has(id)) { @@ -225,6 +245,10 @@ async function handleStructureMode({ }; } + if (argv.operations) { + printedGraph = filterOperations(printedGraph); + } + renderOutput(printedGraph, argv, stylishOptions); } @@ -233,7 +257,17 @@ function renderOutput( argv: TreeArgv, stylishOptions: StylishOptions ): void { - const rendered = renderGraph(graph, argv.format, stylishOptions); + 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); if (argv.output) { writeFileSync(argv.output, rendered + '\n'); logger.info(`Tree written to ${argv.output}\n`); diff --git a/packages/cli/src/commands/tree/match-affected-by.ts b/packages/cli/src/commands/tree/match-affected-by.ts index e4495747d3..d7344aa58c 100644 --- a/packages/cli/src/commands/tree/match-affected-by.ts +++ b/packages/cli/src/commands/tree/match-affected-by.ts @@ -10,6 +10,14 @@ export type AffectedByMatch = { 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[], @@ -23,6 +31,18 @@ export function matchAffectedBy( 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; diff --git a/packages/cli/src/commands/tree/print/stylish.ts b/packages/cli/src/commands/tree/print/stylish.ts index 7dcb1e7023..8d265fee6c 100644 --- a/packages/cli/src/commands/tree/print/stylish.ts +++ b/packages/cli/src/commands/tree/print/stylish.ts @@ -4,6 +4,8 @@ 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; }; export function renderStylish(graph: DependencyGraph, options: StylishOptions = {}): string { @@ -40,17 +42,23 @@ export function renderStylish(graph: DependencyGraph, options: StylishOptions = // `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. - const renderSubtree = (id: string, prefix: string, ancestors: Set) => { + // `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); - lines.push(`${prefix}${isLast ? '└── ' : '├── '}${label(child, id, isCycle)}`); - if (!isCycle) { + 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]) + new Set([...ancestors, child]), + level + 1 ); } }); @@ -59,7 +67,7 @@ export function renderStylish(graph: DependencyGraph, options: StylishOptions = graph.roots.forEach((root, index) => { if (index > 0) lines.push(''); lines.push(label(root, undefined, false)); - renderSubtree(root, '', new Set([root])); + renderSubtree(root, '', new Set([root]), 1); }); if (options.summary !== undefined) { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index a592f8af2a..538f6193d3 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -104,9 +104,19 @@ yargs(hideBin(process.argv)) 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.', + '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, 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-operations/snapshot.txt b/tests/e2e/tree/tree-structure-operations/snapshot.txt new file mode 100644 index 0000000000..ec5a10219f --- /dev/null +++ b/tests/e2e/tree/tree-structure-operations/snapshot.txt @@ -0,0 +1,8 @@ +openapi.yaml +├── /orders +│ ├── GET +│ └── POST +└── /orders/{orderId} + ├── DELETE + └── GET + diff --git a/tests/e2e/tree/tree.test.ts b/tests/e2e/tree/tree.test.ts index 087aeee9ce..a88b92a5c4 100644 --- a/tests/e2e/tree/tree.test.ts +++ b/tests/e2e/tree/tree.test.ts @@ -36,6 +36,26 @@ describe('tree', () => { 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', From 879a918ac0de7a1a5f18b0aa3055ba80b494e848 Mon Sep 17 00:00:00 2001 From: kanoru Date: Mon, 13 Jul 2026 23:41:17 +0300 Subject: [PATCH 47/79] test: add snapshot --- .../tree-structure-uses-wildcard/snapshot.txt | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 tests/e2e/tree/tree-structure-uses-wildcard/snapshot.txt 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} + From 8e15c82253e6edb49a0636a74d1c85eccd1bc03c Mon Sep 17 00:00:00 2001 From: kanoru Date: Thu, 16 Jul 2026 18:23:10 +0300 Subject: [PATCH 48/79] feat(tree): show operationId on operation nodes - attach operationId from the bundled document to operation nodes - render it as `GET (listOrders)` in the --operations view - always include it on operation nodes in the json format --- docs/@v2/commands/tree.md | 29 ++++++++++--------- .../tree/__tests__/build-structure.test.ts | 16 ++++++++++ .../src/commands/tree/__tests__/print.test.ts | 27 +++++++++++++++++ .../cli/src/commands/tree/build-structure.ts | 24 +++++++++++++++ packages/cli/src/commands/tree/index.ts | 1 + .../cli/src/commands/tree/print/stylish.ts | 5 ++++ packages/cli/src/commands/tree/types.ts | 2 ++ tests/e2e/tree/sample-split/paths/orders.yaml | 2 ++ .../sample-split/paths/orders_{orderId}.yaml | 2 ++ .../e2e/tree/tree-structure-json/snapshot.txt | 12 +++++--- .../tree-structure-operations/snapshot.txt | 8 ++--- 11 files changed, 106 insertions(+), 22 deletions(-) diff --git a/docs/@v2/commands/tree.md b/docs/@v2/commands/tree.md index 47929bfac6..27ed1cc62e 100644 --- a/docs/@v2/commands/tree.md +++ b/docs/@v2/commands/tree.md @@ -38,7 +38,7 @@ Use `--files` for the multi-API file graph. | --format | string | Output format: `stylish` (default, tree view), `json`, `mermaid`, or `dot`. | | --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 `…`. | -| --operations | boolean | Display only the API surface — paths, operations, and webhooks — without component chains. Not available with `--files`. | +| --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. | @@ -240,29 +240,30 @@ redocly tree cafe.yaml --operations ```treeview cafe.yaml ├── /menu -│ ├── GET -│ └── POST +│ ├── GET (listMenuItems) +│ └── POST (createMenuItem) ├── /menu-item-images/{menuItemId} -│ └── GET +│ └── GET (getMenuItemPhoto) ├── /menu/{menuItemId} -│ └── DELETE +│ └── DELETE (deleteMenuItem) ├── /oauth2/register -│ └── POST +│ └── POST (registerOAuth2Client) ├── /order-items -│ └── GET +│ └── GET (listOrderItems) ├── /orders -│ ├── GET -│ └── POST +│ ├── GET (listOrders) +│ └── POST (createOrder) ├── /orders/{orderId} -│ ├── DELETE -│ ├── GET -│ └── PATCH +│ ├── DELETE (deleteOrder) +│ ├── GET (getOrderById) +│ └── PATCH (updateOrder) ├── /revenue -│ └── GET +│ └── 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`. @@ -367,7 +368,7 @@ components: {% 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. +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 diff --git a/packages/cli/src/commands/tree/__tests__/build-structure.test.ts b/packages/cli/src/commands/tree/__tests__/build-structure.test.ts index 8726258ed8..e5e5e7f1f9 100644 --- a/packages/cli/src/commands/tree/__tests__/build-structure.test.ts +++ b/packages/cli/src/commands/tree/__tests__/build-structure.test.ts @@ -45,6 +45,22 @@ function edgeRefs(graph: DependencyGraph, from: string, to: string): string[] | } describe('walkStructure', () => { + 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', diff --git a/packages/cli/src/commands/tree/__tests__/print.test.ts b/packages/cli/src/commands/tree/__tests__/print.test.ts index 99c8b785f3..8ab729d527 100644 --- a/packages/cli/src/commands/tree/__tests__/print.test.ts +++ b/packages/cli/src/commands/tree/__tests__/print.test.ts @@ -131,6 +131,33 @@ describe('renderStylish', () => { `); }); + 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'], diff --git a/packages/cli/src/commands/tree/build-structure.ts b/packages/cli/src/commands/tree/build-structure.ts index 421554c503..87babba63b 100644 --- a/packages/cli/src/commands/tree/build-structure.ts +++ b/packages/cli/src/commands/tree/build-structure.ts @@ -186,9 +186,33 @@ export function walkStructure(options: { ); walkDocument({ document, rootType: types.Root, normalizedVisitors, resolvedRefMap, ctx }); + attachOperationIds(nodes, document); + return finalizeGraph(rootId, nodes, edges); } +/** An operation node id is ` `; look its `operationId` up in the document. */ +function attachOperationIds(nodes: Map, document: Document): void { + const paths = ( + document.parsed as { + paths?: Record | undefined>; + } + )?.paths; + if (!paths) return; + + for (const node of nodes.values()) { + if (node.kind !== 'operation') continue; + const separator = node.id.indexOf(' '); + if (separator === -1) continue; + const method = node.id.slice(0, separator).toLowerCase(); + const pathId = node.id.slice(separator + 1); + const operationId = paths[pathId]?.[method]?.operationId; + if (typeof operationId === 'string') { + node.operationId = operationId; + } + } +} + /** Keeps only nodes reachable from the root, sorted for stable output. */ function finalizeGraph( rootId: string, diff --git a/packages/cli/src/commands/tree/index.ts b/packages/cli/src/commands/tree/index.ts index 065824a4c2..b793c6ec04 100644 --- a/packages/cli/src/commands/tree/index.ts +++ b/packages/cli/src/commands/tree/index.ts @@ -247,6 +247,7 @@ async function handleStructureMode({ if (argv.operations) { printedGraph = filterOperations(printedGraph); + stylishOptions = { ...stylishOptions, showOperationId: true }; } renderOutput(printedGraph, argv, stylishOptions); diff --git a/packages/cli/src/commands/tree/print/stylish.ts b/packages/cli/src/commands/tree/print/stylish.ts index 8d265fee6c..8bacaff123 100644 --- a/packages/cli/src/commands/tree/print/stylish.ts +++ b/packages/cli/src/commands/tree/print/stylish.ts @@ -6,6 +6,8 @@ export type StylishOptions = { 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 { @@ -33,6 +35,9 @@ export function renderStylish(graph: DependencyGraph, options: StylishOptions = 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 += ' 🔁'; diff --git a/packages/cli/src/commands/tree/types.ts b/packages/cli/src/commands/tree/types.ts index 50235a1d73..d2baee7774 100644 --- a/packages/cli/src/commands/tree/types.ts +++ b/packages/cli/src/commands/tree/types.ts @@ -10,6 +10,8 @@ export type GraphNode = { 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; }; diff --git a/tests/e2e/tree/sample-split/paths/orders.yaml b/tests/e2e/tree/sample-split/paths/orders.yaml index 3f04be99f1..d585d2ac1e 100644 --- a/tests/e2e/tree/sample-split/paths/orders.yaml +++ b/tests/e2e/tree/sample-split/paths/orders.yaml @@ -1,4 +1,5 @@ get: + operationId: listOrders summary: List orders responses: '200': @@ -8,6 +9,7 @@ get: schema: $ref: ../components/schemas/OrderList.yaml post: + operationId: createOrder summary: Create an order requestBody: required: true diff --git a/tests/e2e/tree/sample-split/paths/orders_{orderId}.yaml b/tests/e2e/tree/sample-split/paths/orders_{orderId}.yaml index 46abc65f5a..39d8e30e88 100644 --- a/tests/e2e/tree/sample-split/paths/orders_{orderId}.yaml +++ b/tests/e2e/tree/sample-split/paths/orders_{orderId}.yaml @@ -1,4 +1,5 @@ get: + operationId: getOrder summary: Get an order by id parameters: - name: orderId @@ -14,6 +15,7 @@ get: schema: $ref: ../components/schemas/Order.yaml delete: + operationId: cancelOrder summary: Cancel an order by id parameters: - name: orderId diff --git a/tests/e2e/tree/tree-structure-json/snapshot.txt b/tests/e2e/tree/tree-structure-json/snapshot.txt index bf9bd9fa44..0db2166c23 100644 --- a/tests/e2e/tree/tree-structure-json/snapshot.txt +++ b/tests/e2e/tree/tree-structure-json/snapshot.txt @@ -16,25 +16,29 @@ "id": "DELETE /orders/{orderId}", "resolved": true, "kind": "operation", - "file": "openapi.yaml" + "file": "openapi.yaml", + "operationId": "cancelOrder" }, { "id": "GET /orders", "resolved": true, "kind": "operation", - "file": "openapi.yaml" + "file": "openapi.yaml", + "operationId": "listOrders" }, { "id": "GET /orders/{orderId}", "resolved": true, "kind": "operation", - "file": "openapi.yaml" + "file": "openapi.yaml", + "operationId": "getOrder" }, { "id": "POST /orders", "resolved": true, "kind": "operation", - "file": "openapi.yaml" + "file": "openapi.yaml", + "operationId": "createOrder" }, { "id": "openapi.yaml", diff --git a/tests/e2e/tree/tree-structure-operations/snapshot.txt b/tests/e2e/tree/tree-structure-operations/snapshot.txt index ec5a10219f..d4686f30e6 100644 --- a/tests/e2e/tree/tree-structure-operations/snapshot.txt +++ b/tests/e2e/tree/tree-structure-operations/snapshot.txt @@ -1,8 +1,8 @@ openapi.yaml ├── /orders -│ ├── GET -│ └── POST +│ ├── GET (listOrders) +│ └── POST (createOrder) └── /orders/{orderId} - ├── DELETE - └── GET + ├── DELETE (cancelOrder) + └── GET (getOrder) From 890b3362572f778e24c505148ff3b49c924fed97 Mon Sep 17 00:00:00 2001 From: kanoru Date: Fri, 17 Jul 2026 10:47:35 +0300 Subject: [PATCH 49/79] docs(tree): fix options table having a stray fourth column --- docs/@v2/commands/tree.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/@v2/commands/tree.md b/docs/@v2/commands/tree.md index 27ed1cc62e..a3a3b64111 100644 --- a/docs/@v2/commands/tree.md +++ b/docs/@v2/commands/tree.md @@ -31,12 +31,12 @@ 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`. | -| --help | boolean | Display help. | | +| --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 `…`. | | --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`. | From b72d3d20fe274883528a675ae2e83ed5184468b4 Mon Sep 17 00:00:00 2001 From: kanoru Date: Sat, 1 Aug 2026 12:13:47 +0300 Subject: [PATCH 50/79] feat(core): add api-graph node model and node-id mapping --- .../src/api-graph/__tests__/node-id.test.ts | 166 ++++++++++++++++++ packages/core/src/api-graph/node-id.ts | 117 ++++++++++++ packages/core/src/api-graph/types.ts | 28 +++ packages/core/src/index.ts | 11 ++ 4 files changed, 322 insertions(+) create mode 100644 packages/core/src/api-graph/__tests__/node-id.test.ts create mode 100644 packages/core/src/api-graph/node-id.ts create mode 100644 packages/core/src/api-graph/types.ts 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/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/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 401bc33d5a..72200328af 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -126,6 +126,17 @@ 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 { logger, type LoggerInterface } from './logger.js'; export { HandledError } from './utils/error.js'; export { isSupportedExtension } from './utils/is-supported-extension.js'; From 79be2677ea25907d079db506ed13db46fc3e5e54 Mon Sep 17 00:00:00 2001 From: kanoru Date: Sat, 1 Aug 2026 13:11:41 +0300 Subject: [PATCH 51/79] feat(core): add api-graph builder walking the original document --- .../api-graph/__tests__/build-graph.test.ts | 130 ++++++++++ .../split/components/schemas/Ticket.yaml | 4 + .../__tests__/fixtures/split/openapi.yaml | 11 + .../fixtures/split/paths/tickets.yaml | 17 ++ packages/core/src/api-graph/build-graph.ts | 233 ++++++++++++++++++ packages/core/src/index.ts | 1 + 6 files changed, 396 insertions(+) create mode 100644 packages/core/src/api-graph/__tests__/build-graph.test.ts create mode 100644 packages/core/src/api-graph/__tests__/fixtures/split/components/schemas/Ticket.yaml create mode 100644 packages/core/src/api-graph/__tests__/fixtures/split/openapi.yaml create mode 100644 packages/core/src/api-graph/__tests__/fixtures/split/paths/tickets.yaml create mode 100644 packages/core/src/api-graph/build-graph.ts 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..fa95df5e5b --- /dev/null +++ b/packages/core/src/api-graph/__tests__/build-graph.test.ts @@ -0,0 +1,130 @@ +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 { buildApiGraph } 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), {}); + return buildApiGraph({ + rootDocument: document, + specVersion, + types, + externalRefResolver: new BaseResolver(), + cwd, + resolveRef: (base, uri) => join(dirname(base), uri), + }); +} + +describe('buildApiGraph', () => { + 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 has no INCOMING edge on the original + // document (the operation's $ref points straight at the file), so the graph drops it + // as unreachable — the real file node replaces it. Phase 2's index view restores + // semantic component names from the Named* visitors. + expect(graph.nodes.find((node) => node.id === 'schemas/Ticket')).toBeUndefined(); + + const schemaFile = graph.nodes.find((node) => node.id === 'components/schemas/Ticket.yaml'); + expect(schemaFile).toMatchObject({ kind: 'file', resolved: true }); + + const pathItemFile = graph.nodes.find((node) => node.id === 'paths/tickets.yaml'); + expect(pathItemFile).toMatchObject({ kind: 'file', resolved: true }); + + expect( + graph.edges.some((edge) => edge.from === '/tickets' && edge.to === 'paths/tickets.yaml') + ).toBe(true); + expect( + graph.edges.some( + (edge) => edge.from === 'paths/tickets.yaml' && edge.to === 'components/schemas/Ticket.yaml' + ) + ).toBe(true); + + 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(); + }); +}); 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..78dcf53210 --- /dev/null +++ b/packages/core/src/api-graph/__tests__/fixtures/split/components/schemas/Ticket.yaml @@ -0,0 +1,4 @@ +type: object +properties: + ticketId: + type: string 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..7776e62f42 --- /dev/null +++ b/packages/core/src/api-graph/__tests__/fixtures/split/openapi.yaml @@ -0,0 +1,11 @@ +openapi: 3.0.3 +info: + title: Split API + version: 1.0.0 +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..c2713519b8 --- /dev/null +++ b/packages/core/src/api-graph/__tests__/fixtures/split/paths/tickets.yaml @@ -0,0 +1,17 @@ +post: + 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 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..1fe178779d --- /dev/null +++ b/packages/core/src/api-graph/build-graph.ts @@ -0,0 +1,233 @@ +import type { SpecVersion } from '../oas-types.js'; +import { isAbsoluteUrl, 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 { normalizeVisitors, type Oas3Visitor } from '../visitors.js'; +import { walkDocument, type WalkContext } from '../walk.js'; +import { + compareStrings, + mapForeignLocation, + mapRootPointer, + OPERATION_METHODS, + parsePointerSegments, + toNodeId, + type MappedNode, +} from './node-id.js'; +import type { DependencyGraph, GraphEdge, GraphNode } from './types.js'; + +export async function buildApiGraph(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: {} }; + + return walkStructure({ document: rootDocument, types, resolvedRefMap, ctx, cwd, resolveRef }); +} + +export function walkStructure(options: { + document: Document; + types: Record; + resolvedRefMap: ResolvedRefMap; + ctx: WalkContext; + cwd: string; + resolveRef: (base: string, uri: string) => string; +}): DependencyGraph { + 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 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 } => + absoluteRef === rootAbs + ? { ...mapRootPointer(pointer, rootId), file: rootId } + : mapForeignLocation(toNodeId(absoluteRef, cwd), pointer); + + 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; + + const visitor: Oas3Visitor = { + 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; + } + }, + }, + Operation: { + enter(node, vctx) { + 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); + }, + }, + ref: { + enter(refNode, vctx, resolved) { + const ownerId = 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); + }, + }, + }; + + 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 finalizeGraph(rootId, nodes, edges); +} + +/** 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/index.ts b/packages/core/src/index.ts index 72200328af..c072892d6c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -137,6 +137,7 @@ export { type MappedNode, } from './api-graph/node-id.js'; export type { DependencyGraph, GraphEdge, GraphNode, NodeKind } from './api-graph/types.js'; +export { buildApiGraph, collectConnectedIds, walkStructure } from './api-graph/build-graph.js'; export { logger, type LoggerInterface } from './logger.js'; export { HandledError } from './utils/error.js'; export { isSupportedExtension } from './utils/is-supported-extension.js'; From 67dabde2cd575585d1a04d6710b5b5066986659c Mon Sep 17 00:00:00 2001 From: kanoru Date: Sat, 1 Aug 2026 13:24:15 +0300 Subject: [PATCH 52/79] refactor(cli): back the tree structure view with the core api-graph --- .../tree/__tests__/build-structure.test.ts | 87 ++----- .../commands/tree/__tests__/node-id.test.ts | 166 ------------- .../cli/src/commands/tree/build-structure.ts | 222 +----------------- .../cli/src/commands/tree/filter-affected.ts | 30 +-- packages/cli/src/commands/tree/index.ts | 11 +- packages/cli/src/commands/tree/node-id.ts | 129 +--------- packages/cli/src/commands/tree/types.ts | 29 +-- 7 files changed, 52 insertions(+), 622 deletions(-) delete mode 100644 packages/cli/src/commands/tree/__tests__/node-id.test.ts diff --git a/packages/cli/src/commands/tree/__tests__/build-structure.test.ts b/packages/cli/src/commands/tree/__tests__/build-structure.test.ts index e5e5e7f1f9..7928fc5102 100644 --- a/packages/cli/src/commands/tree/__tests__/build-structure.test.ts +++ b/packages/cli/src/commands/tree/__tests__/build-structure.test.ts @@ -4,14 +4,12 @@ import { detectSpec, getTypes, normalizeTypes, - resolveDocument, Source, type Document, - type WalkContext, } from '@redocly/openapi-core'; import * as path from 'node:path'; -import { buildStructureGraph, walkStructure } from '../build-structure.js'; +import { buildStructureGraph } from '../build-structure.js'; import type { DependencyGraph } from '../types.js'; const CWD = '/project'; @@ -21,30 +19,26 @@ async function structureOf( parsed: Record, externalRefResolver: BaseResolver = new BaseResolver() ): Promise { - const document = { source: new Source(ROOT_ABS, ''), parsed } as Document; + const rootDocument = { source: new Source(ROOT_ABS, ''), parsed } as Document; const specVersion = detectSpec(parsed); - const types = normalizeTypes(getTypes(specVersion), {}); - const resolvedRefMap = await resolveDocument({ - rootDocument: document, - rootType: types.Root, - externalRefResolver, - }); - const ctx = { problems: [], specVersion, visitorsData: {} } as unknown as WalkContext; - return walkStructure({ - document, + const config = await createConfig({}); + const types = normalizeTypes(config.extendTypes(getTypes(specVersion), specVersion), config); + const { graph } = await buildStructureGraph({ + rootDocument, + specVersion, types, - resolvedRefMap, - ctx, + config, + 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('walkStructure', () => { +describe('buildStructureGraph', () => { it('attaches operationId to operation nodes when defined', async () => { const graph = await structureOf({ openapi: '3.0.0', @@ -514,59 +508,28 @@ describe('buildStructureGraph (multi-file parity)', () => { return graph; } - it('bundles referenced path-items and components into a single-file-equivalent tree', async () => { + 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 from `$ref`'d path files and named components, not bare file nodes. + // 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' }); - expect(nodes).toContainEqual({ id: 'schemas/Order', kind: 'component' }); - expect(nodes).toContainEqual({ id: 'schemas/OrderList', kind: 'component' }); - expect(graph.nodes.some((node) => node.kind === 'file')).toBe(false); + + // The root's `components.schemas.*` aliases only $ref out to a file and have no incoming + // edge on the original document, so they are unreachable and dropped — the real file node + // they point to stands in for the component instead. + expect(graph.nodes.find((node) => node.id === 'schemas/Order')).toBeUndefined(); + expect(graph.nodes.find((node) => node.id === 'schemas/OrderList')).toBeUndefined(); + expect(nodes).toContainEqual({ id: 'components/schemas/Order.yaml', kind: 'file' }); + expect(nodes).toContainEqual({ id: 'components/schemas/OrderList.yaml', kind: 'file' }); // Transitive component-to-component chains survive across files. expect( - graph.edges.some((edge) => edge.from === 'schemas/Order' && edge.to.startsWith('schemas/')) + graph.edges.some( + (edge) => + edge.from === 'components/schemas/Order.yaml' && edge.to.startsWith('components/schemas/') + ) ).toBe(true); }); - - it('returns bundle problems for an unresolved reference', async () => { - const config = await createConfig({}); - const externalRefResolver = new BaseResolver(); - const rootDocument = { - source: new Source('/project/openapi.yaml', ''), - parsed: { - openapi: '3.0.0', - info: { title: 't', version: '1' }, - paths: { - '/a': { - get: { - responses: { - '200': { - description: 'ok', - content: { - 'application/json': { schema: { $ref: './missing.yaml#/X' } }, - }, - }, - }, - }, - }, - }, - }, - } as Document; - const specVersion = detectSpec(rootDocument.parsed); - const types = normalizeTypes(config.extendTypes(getTypes(specVersion), specVersion), config); - - const { problems } = await buildStructureGraph({ - rootDocument, - specVersion, - types, - config, - externalRefResolver, - cwd: '/project', - }); - - expect(problems.some((problem) => problem.severity === 'error')).toBe(true); - }); }); diff --git a/packages/cli/src/commands/tree/__tests__/node-id.test.ts b/packages/cli/src/commands/tree/__tests__/node-id.test.ts deleted file mode 100644 index 99812bdf40..0000000000 --- a/packages/cli/src/commands/tree/__tests__/node-id.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -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/cli/src/commands/tree/build-structure.ts b/packages/cli/src/commands/tree/build-structure.ts index 87babba63b..2aa97d973f 100644 --- a/packages/cli/src/commands/tree/build-structure.ts +++ b/packages/cli/src/commands/tree/build-structure.ts @@ -1,33 +1,13 @@ import { - bundleDocument, - getTypes, - isAbsoluteUrl, - normalizeVisitors, - resolveDocument, - walkDocument, + buildApiGraph, type BaseResolver, type Config, type Document, - type Location, type NormalizedNodeType, - type NormalizedProblem, - type Oas3Visitor, - type ResolvedRefMap, type SpecVersion, - type WalkContext, } from '@redocly/openapi-core'; -import { collectConnectedIds } from './filter-affected.js'; -import { - compareStrings, - mapForeignLocation, - mapRootPointer, - OPERATION_METHODS, - parsePointerSegments, - toNodeId, - type MappedNode, -} from './node-id.js'; -import type { DependencyGraph, GraphEdge, GraphNode } from './types.js'; +import type { DependencyGraph } from './types.js'; export async function buildStructureGraph(options: { rootDocument: Document; @@ -36,199 +16,15 @@ export async function buildStructureGraph(options: { config: Config; externalRefResolver: BaseResolver; cwd: string; -}): Promise<{ graph: DependencyGraph; problems: NormalizedProblem[] }> { - const { rootDocument, specVersion, types, config, externalRefResolver, cwd } = options; - - const { bundle, problems } = await bundleDocument({ - document: rootDocument, - config, - types: getTypes(specVersion), - externalRefResolver, - }); - - const resolvedRefMap = await resolveDocument({ - rootDocument: bundle, - rootType: types.Root, - externalRefResolver, - }); - - const ctx: WalkContext = { problems: [], specVersion, config, visitorsData: {} }; - - const graph = walkStructure({ - document: bundle, +}): Promise<{ graph: DependencyGraph }> { + const { rootDocument, specVersion, types, externalRefResolver, cwd } = options; + const graph = await buildApiGraph({ + rootDocument, + specVersion, types, - resolvedRefMap, - ctx, + externalRefResolver, cwd, resolveRef: (base, uri) => externalRefResolver.resolveExternalRef(base, uri), }); - - return { graph, problems }; -} - -export function walkStructure(options: { - document: Document; - types: Record; - resolvedRefMap: ResolvedRefMap; - ctx: WalkContext; - cwd: string; - resolveRef: (base: string, uri: string) => string; -}): DependencyGraph { - 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 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 } => - absoluteRef === rootAbs - ? { ...mapRootPointer(pointer, rootId), file: rootId } - : mapForeignLocation(toNodeId(absoluteRef, cwd), pointer); - - 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) { - addOrUpdateNode({ id: ancestorId, kind: 'path', file: rootId }, 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; - }; - - const visitor: Oas3Visitor = { - PathItem: { - enter(_node, vctx) { - if (vctx.rawLocation.source.absoluteRef !== rootAbs) return; - const segments = parsePointerSegments(vctx.rawLocation.pointer); - if (segments.length === 2 && segments[0] === 'paths') { - nodeFor(vctx.rawLocation); - } - }, - }, - Operation: { - enter(_node, vctx) { - if (vctx.rawLocation.source.absoluteRef !== rootAbs) return; - const segments = parsePointerSegments(vctx.rawLocation.pointer); - if ( - segments.length === 3 && - segments[0] === 'paths' && - OPERATION_METHODS.has(segments[2]) - ) { - nodeFor(vctx.rawLocation); - } - }, - }, - ref: { - enter(refNode, vctx, resolved) { - const ownerId = nodeFor(vctx.location); - const refString = String(refNode.$ref); - const targetId = resolved.location - ? nodeFor(resolved.location) - : unresolvedTargetId(vctx.location, refString); - addEdge(ownerId, targetId, refString); - }, - }, - }; - - 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 }); - - attachOperationIds(nodes, document); - - return finalizeGraph(rootId, nodes, edges); -} - -/** An operation node id is ` `; look its `operationId` up in the document. */ -function attachOperationIds(nodes: Map, document: Document): void { - const paths = ( - document.parsed as { - paths?: Record | undefined>; - } - )?.paths; - if (!paths) return; - - for (const node of nodes.values()) { - if (node.kind !== 'operation') continue; - const separator = node.id.indexOf(' '); - if (separator === -1) continue; - const method = node.id.slice(0, separator).toLowerCase(); - const pathId = node.id.slice(separator + 1); - const operationId = paths[pathId]?.[method]?.operationId; - if (typeof operationId === 'string') { - node.operationId = operationId; - } - } -} - -/** 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 }; + return { graph }; } diff --git a/packages/cli/src/commands/tree/filter-affected.ts b/packages/cli/src/commands/tree/filter-affected.ts index e22bc1b287..41a071e227 100644 --- a/packages/cli/src/commands/tree/filter-affected.ts +++ b/packages/cli/src/commands/tree/filter-affected.ts @@ -1,32 +1,8 @@ -import type { DependencyGraph, GraphEdge } from './types.js'; +import { collectConnectedIds } from '@redocly/openapi-core'; -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); - } +import type { DependencyGraph } from './types.js'; - 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; -} +export { collectConnectedIds }; export function filterOperations(graph: DependencyGraph): DependencyGraph { // The API surface: paths, operations, and webhook entries. Webhook nodes carry the generic diff --git a/packages/cli/src/commands/tree/index.ts b/packages/cli/src/commands/tree/index.ts index b793c6ec04..b46f5400b4 100644 --- a/packages/cli/src/commands/tree/index.ts +++ b/packages/cli/src/commands/tree/index.ts @@ -193,7 +193,7 @@ async function handleStructureMode({ externalRefResolver, }); - const { graph, problems } = await buildStructureGraph({ + const { graph } = await buildStructureGraph({ rootDocument, specVersion, types, @@ -202,11 +202,10 @@ async function handleStructureMode({ cwd, }); - for (const problem of problems) { - logger.warn(`${problem.message}\n`); - } - if (problems.some((problem) => problem.severity === 'error')) { - return exitWithError(`Cannot display the tree: ${api.path} has bundling errors (see above).`); + for (const node of graph.nodes) { + if (!node.resolved) { + logger.warn(`Could not resolve ${node.id} — shown as unresolved (❌).\n`); + } } // Structure mode resolves exactly one API (handleTree rejects more), so there is a single root. diff --git a/packages/cli/src/commands/tree/node-id.ts b/packages/cli/src/commands/tree/node-id.ts index fe547c1cae..d6dad8b098 100644 --- a/packages/cli/src/commands/tree/node-id.ts +++ b/packages/cli/src/commands/tree/node-id.ts @@ -1,121 +1,10 @@ -import { - escapePointerFragment, - isAbsoluteUrl, - slash, - unescapePointerFragment, +export { + commonDir, + compareStrings, + mapForeignLocation, + mapRootPointer, + OPERATION_METHODS, + parsePointerSegments, + toNodeId, + type MappedNode, } from '@redocly/openapi-core'; -import * as path from 'node:path'; - -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/cli/src/commands/tree/types.ts b/packages/cli/src/commands/tree/types.ts index d2baee7774..2fa2e981ac 100644 --- a/packages/cli/src/commands/tree/types.ts +++ b/packages/cli/src/commands/tree/types.ts @@ -1,30 +1,3 @@ export type TreeFormat = 'stylish' | 'json' | 'mermaid' | 'dot'; -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[]; -}; +export type { DependencyGraph, GraphEdge, GraphNode, NodeKind } from '@redocly/openapi-core'; From 849d289f10a9622dc74a187fea121fe4a2c1e8bc Mon Sep 17 00:00:00 2001 From: kanoru Date: Sat, 1 Aug 2026 13:42:58 +0300 Subject: [PATCH 53/79] test(cli): update split-fixture tree snapshots for real source files --- .../e2e/tree/tree-structure-dot/snapshot.txt | 28 +++-- .../e2e/tree/tree-structure-json/snapshot.txt | 118 +++++++++++------- .../tree/tree-structure-mermaid/snapshot.txt | 32 ++--- .../tree/tree-structure-stylish/snapshot.txt | 28 +++-- .../tree-structure-used-by-file/snapshot.txt | 13 +- .../tree/tree-structure-used-by/snapshot.txt | 14 +-- .../tree-structure-uses-wildcard/snapshot.txt | 17 +-- 7 files changed, 136 insertions(+), 114 deletions(-) diff --git a/tests/e2e/tree/tree-structure-dot/snapshot.txt b/tests/e2e/tree/tree-structure-dot/snapshot.txt index 090e06319e..c15bb2d163 100644 --- a/tests/e2e/tree/tree-structure-dot/snapshot.txt +++ b/tests/e2e/tree/tree-structure-dot/snapshot.txt @@ -5,24 +5,28 @@ digraph tree { "GET /orders"; "GET /orders/{orderId}"; "POST /orders"; + "components/schemas/Error.yaml"; + "components/schemas/MenuItem.yaml"; + "components/schemas/Order.yaml"; + "components/schemas/OrderList.yaml"; + "components/schemas/OrderStatus.yaml"; "openapi.yaml" [shape=box, style=bold]; - "schemas/Error"; - "schemas/MenuItem"; - "schemas/Order"; - "schemas/OrderList"; - "schemas/OrderStatus"; + "paths/orders.yaml"; + "paths/orders_{orderId}.yaml"; "/orders" -> "GET /orders"; "/orders" -> "POST /orders"; + "/orders" -> "paths/orders.yaml"; "/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"; + "/orders/{orderId}" -> "paths/orders_{orderId}.yaml"; + "components/schemas/Order.yaml" -> "components/schemas/MenuItem.yaml"; + "components/schemas/Order.yaml" -> "components/schemas/OrderStatus.yaml"; + "components/schemas/OrderList.yaml" -> "components/schemas/Order.yaml"; "openapi.yaml" -> "/orders"; "openapi.yaml" -> "/orders/{orderId}"; - "schemas/Order" -> "schemas/MenuItem"; - "schemas/Order" -> "schemas/OrderStatus"; - "schemas/OrderList" -> "schemas/Order"; + "paths/orders.yaml" -> "components/schemas/Order.yaml"; + "paths/orders.yaml" -> "components/schemas/OrderList.yaml"; + "paths/orders_{orderId}.yaml" -> "components/schemas/Error.yaml"; + "paths/orders_{orderId}.yaml" -> "components/schemas/Order.yaml"; } diff --git a/tests/e2e/tree/tree-structure-json/snapshot.txt b/tests/e2e/tree/tree-structure-json/snapshot.txt index 0db2166c23..3c1c0f752a 100644 --- a/tests/e2e/tree/tree-structure-json/snapshot.txt +++ b/tests/e2e/tree/tree-structure-json/snapshot.txt @@ -4,78 +4,90 @@ "id": "/orders", "resolved": true, "kind": "path", - "file": "openapi.yaml" + "file": "paths/orders.yaml" }, { "id": "/orders/{orderId}", "resolved": true, "kind": "path", - "file": "openapi.yaml" + "file": "paths/orders_{orderId}.yaml" }, { "id": "DELETE /orders/{orderId}", "resolved": true, "kind": "operation", - "file": "openapi.yaml", + "file": "paths/orders_{orderId}.yaml", "operationId": "cancelOrder" }, { "id": "GET /orders", "resolved": true, "kind": "operation", - "file": "openapi.yaml", + "file": "paths/orders.yaml", "operationId": "listOrders" }, { "id": "GET /orders/{orderId}", "resolved": true, "kind": "operation", - "file": "openapi.yaml", + "file": "paths/orders_{orderId}.yaml", "operationId": "getOrder" }, { "id": "POST /orders", "resolved": true, "kind": "operation", - "file": "openapi.yaml", + "file": "paths/orders.yaml", "operationId": "createOrder" }, { - "id": "openapi.yaml", + "id": "components/schemas/Error.yaml", "resolved": true, - "kind": "root", - "file": "openapi.yaml", - "root": true + "kind": "file", + "file": "components/schemas/Error.yaml" + }, + { + "id": "components/schemas/MenuItem.yaml", + "resolved": true, + "kind": "file", + "file": "components/schemas/MenuItem.yaml" + }, + { + "id": "components/schemas/Order.yaml", + "resolved": true, + "kind": "file", + "file": "components/schemas/Order.yaml" }, { - "id": "schemas/Error", + "id": "components/schemas/OrderList.yaml", "resolved": true, - "kind": "component", - "file": "openapi.yaml" + "kind": "file", + "file": "components/schemas/OrderList.yaml" }, { - "id": "schemas/MenuItem", + "id": "components/schemas/OrderStatus.yaml", "resolved": true, - "kind": "component", - "file": "openapi.yaml" + "kind": "file", + "file": "components/schemas/OrderStatus.yaml" }, { - "id": "schemas/Order", + "id": "openapi.yaml", "resolved": true, - "kind": "component", - "file": "openapi.yaml" + "kind": "root", + "file": "openapi.yaml", + "root": true }, { - "id": "schemas/OrderList", + "id": "paths/orders.yaml", "resolved": true, - "kind": "component", - "file": "openapi.yaml" + "kind": "file", + "file": "paths/orders.yaml" }, { - "id": "schemas/OrderStatus", + "id": "paths/orders_{orderId}.yaml", "resolved": true, - "kind": "component", - "file": "openapi.yaml" + "kind": "file", + "file": "paths/orders_{orderId}.yaml" } ], "links": [ @@ -89,6 +101,13 @@ "target": "POST /orders", "refs": [] }, + { + "source": "/orders", + "target": "paths/orders.yaml", + "refs": [ + "paths/orders.yaml" + ] + }, { "source": "/orders/{orderId}", "target": "DELETE /orders/{orderId}", @@ -100,31 +119,31 @@ "refs": [] }, { - "source": "DELETE /orders/{orderId}", - "target": "schemas/Error", + "source": "/orders/{orderId}", + "target": "paths/orders_{orderId}.yaml", "refs": [ - "#/components/schemas/Error" + "paths/orders_{orderId}.yaml" ] }, { - "source": "GET /orders", - "target": "schemas/OrderList", + "source": "components/schemas/Order.yaml", + "target": "components/schemas/MenuItem.yaml", "refs": [ - "#/components/schemas/OrderList" + "MenuItem.yaml" ] }, { - "source": "GET /orders/{orderId}", - "target": "schemas/Order", + "source": "components/schemas/Order.yaml", + "target": "components/schemas/OrderStatus.yaml", "refs": [ - "#/components/schemas/Order" + "OrderStatus.yaml" ] }, { - "source": "POST /orders", - "target": "schemas/Order", + "source": "components/schemas/OrderList.yaml", + "target": "components/schemas/Order.yaml", "refs": [ - "#/components/schemas/Order" + "Order.yaml" ] }, { @@ -138,24 +157,31 @@ "refs": [] }, { - "source": "schemas/Order", - "target": "schemas/MenuItem", + "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/MenuItem" + "../components/schemas/OrderList.yaml" ] }, { - "source": "schemas/Order", - "target": "schemas/OrderStatus", + "source": "paths/orders_{orderId}.yaml", + "target": "components/schemas/Error.yaml", "refs": [ - "#/components/schemas/OrderStatus" + "../components/schemas/Error.yaml" ] }, { - "source": "schemas/OrderList", - "target": "schemas/Order", + "source": "paths/orders_{orderId}.yaml", + "target": "components/schemas/Order.yaml", "refs": [ - "#/components/schemas/Order" + "../components/schemas/Order.yaml" ] } ] diff --git a/tests/e2e/tree/tree-structure-mermaid/snapshot.txt b/tests/e2e/tree/tree-structure-mermaid/snapshot.txt index 1a6e96a5a4..21d9d52be4 100644 --- a/tests/e2e/tree/tree-structure-mermaid/snapshot.txt +++ b/tests/e2e/tree/tree-structure-mermaid/snapshot.txt @@ -5,24 +5,28 @@ flowchart LR 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"] + n6["components/schemas/Error.yaml"] + n7["components/schemas/MenuItem.yaml"] + n8["components/schemas/Order.yaml"] + n9["components/schemas/OrderList.yaml"] + n10["components/schemas/OrderStatus.yaml"] + n11["openapi.yaml"]:::root + n12["paths/orders.yaml"] + n13["paths/orders_{orderId}.yaml"] n0 --> n3 n0 --> n5 + n0 --> n12 n1 --> n2 n1 --> n4 - n2 --> n7 - n3 --> n10 - n4 --> n9 - n5 --> n9 - n6 --> n0 - n6 --> n1 + n1 --> n13 + n8 --> n7 + n8 --> n10 n9 --> n8 - n9 --> n11 - n10 --> n9 + n11 --> n0 + n11 --> n1 + n12 --> n8 + n12 --> n9 + n13 --> n6 + n13 --> n8 classDef root font-weight:bold diff --git a/tests/e2e/tree/tree-structure-stylish/snapshot.txt b/tests/e2e/tree/tree-structure-stylish/snapshot.txt index 73088b4509..5bbfdac2b2 100644 --- a/tests/e2e/tree/tree-structure-stylish/snapshot.txt +++ b/tests/e2e/tree/tree-structure-stylish/snapshot.txt @@ -1,19 +1,21 @@ openapi.yaml ├── /orders │ ├── GET -│ │ └── schemas/OrderList -│ │ └── schemas/Order -│ │ ├── schemas/MenuItem -│ │ └── schemas/OrderStatus -│ └── POST -│ └── schemas/Order -│ ├── schemas/MenuItem -│ └── schemas/OrderStatus +│ ├── POST +│ └── 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 └── /orders/{orderId} ├── DELETE - │ └── schemas/Error - └── GET - └── schemas/Order - ├── schemas/MenuItem - └── schemas/OrderStatus + ├── GET + └── 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-structure-used-by-file/snapshot.txt b/tests/e2e/tree/tree-structure-used-by-file/snapshot.txt index 09d13f4d4b..d463ac27eb 100644 --- a/tests/e2e/tree/tree-structure-used-by-file/snapshot.txt +++ b/tests/e2e/tree/tree-structure-used-by-file/snapshot.txt @@ -1,3 +1,12 @@ -No nodes affected. +openapi.yaml +├── /orders +│ └── paths/orders.yaml +│ ├── components/schemas/Order.yaml +│ └── components/schemas/OrderList.yaml +│ └── components/schemas/Order.yaml +└── /orders/{orderId} + └── paths/orders_{orderId}.yaml + └── components/schemas/Order.yaml + +0 of 4 operations affected · affected paths: /orders, /orders/{orderId} -components/schemas/Order.yaml does not match any path, operation, or component of openapi.yaml. For file-level analysis, use `--files`. diff --git a/tests/e2e/tree/tree-structure-used-by/snapshot.txt b/tests/e2e/tree/tree-structure-used-by/snapshot.txt index bec97cf1ba..ea0103b5d6 100644 --- a/tests/e2e/tree/tree-structure-used-by/snapshot.txt +++ b/tests/e2e/tree/tree-structure-used-by/snapshot.txt @@ -1,13 +1,3 @@ -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} +No nodes affected. +#/components/schemas/Order does not match any path, operation, or component of openapi.yaml. diff --git a/tests/e2e/tree/tree-structure-uses-wildcard/snapshot.txt b/tests/e2e/tree/tree-structure-uses-wildcard/snapshot.txt index 29afd451fe..ff7320fe06 100644 --- a/tests/e2e/tree/tree-structure-uses-wildcard/snapshot.txt +++ b/tests/e2e/tree/tree-structure-uses-wildcard/snapshot.txt @@ -1,16 +1,3 @@ -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} +No nodes affected. +schemas/Order* does not match any path, operation, or component of openapi.yaml. From badc7c60bd9316eaff867e9607bf7b9d82dbb77a Mon Sep 17 00:00:00 2001 From: kanoru Date: Sat, 1 Aug 2026 14:04:54 +0300 Subject: [PATCH 54/79] fix(core): attribute foreign-file ref edges to their spine operation --- .../api-graph/__tests__/build-graph.test.ts | 12 ++++- packages/core/src/api-graph/build-graph.ts | 32 ++++++++++- .../e2e/tree/tree-structure-dot/snapshot.txt | 8 +-- .../e2e/tree/tree-structure-json/snapshot.txt | 54 +++++++++---------- .../tree/tree-structure-mermaid/snapshot.txt | 8 +-- .../tree/tree-structure-stylish/snapshot.txt | 22 ++++---- .../tree-structure-used-by-file/snapshot.txt | 13 ++--- 7 files changed, 95 insertions(+), 54 deletions(-) diff --git a/packages/core/src/api-graph/__tests__/build-graph.test.ts b/packages/core/src/api-graph/__tests__/build-graph.test.ts index fa95df5e5b..50d55ae8ab 100644 --- a/packages/core/src/api-graph/__tests__/build-graph.test.ts +++ b/packages/core/src/api-graph/__tests__/build-graph.test.ts @@ -110,11 +110,21 @@ describe('buildApiGraph', () => { expect( graph.edges.some((edge) => edge.from === '/tickets' && edge.to === 'paths/tickets.yaml') ).toBe(true); + // The operation's response schema $ref lives directly in the operation's own file, so its + // owner is the operation itself, not the file — matching the old bundled walk, where this + // ref's owner was the operation. (A ref found after hopping into a further file, e.g. a + // component schema referencing another schema, would still collapse to the file — that + // case isn't exercised by this fixture.) expect( graph.edges.some( - (edge) => edge.from === 'paths/tickets.yaml' && edge.to === 'components/schemas/Ticket.yaml' + (edge) => edge.from === 'POST /tickets' && edge.to === 'components/schemas/Ticket.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' }); diff --git a/packages/core/src/api-graph/build-graph.ts b/packages/core/src/api-graph/build-graph.ts index 1fe178779d..c175521ca9 100644 --- a/packages/core/src/api-graph/build-graph.ts +++ b/packages/core/src/api-graph/build-graph.ts @@ -128,6 +128,19 @@ export function walkStructure(options: { 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; + const visitor: Oas3Visitor = { PathItem: { enter(node, vctx) { @@ -154,11 +167,28 @@ export function walkStructure(options: { 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; + }, + leave(node) { + if (node === currentOperationNode) { + currentOperationNode = undefined; + currentOperationNodeId = undefined; + currentOperationFileAbs = undefined; + } }, }, ref: { enter(refNode, vctx, resolved) { - const ownerId = nodeFor(vctx.location); + 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 diff --git a/tests/e2e/tree/tree-structure-dot/snapshot.txt b/tests/e2e/tree/tree-structure-dot/snapshot.txt index c15bb2d163..eaddccda62 100644 --- a/tests/e2e/tree/tree-structure-dot/snapshot.txt +++ b/tests/e2e/tree/tree-structure-dot/snapshot.txt @@ -19,14 +19,14 @@ digraph tree { "/orders/{orderId}" -> "DELETE /orders/{orderId}"; "/orders/{orderId}" -> "GET /orders/{orderId}"; "/orders/{orderId}" -> "paths/orders_{orderId}.yaml"; + "DELETE /orders/{orderId}" -> "components/schemas/Error.yaml"; + "GET /orders" -> "components/schemas/OrderList.yaml"; + "GET /orders/{orderId}" -> "components/schemas/Order.yaml"; + "POST /orders" -> "components/schemas/Order.yaml"; "components/schemas/Order.yaml" -> "components/schemas/MenuItem.yaml"; "components/schemas/Order.yaml" -> "components/schemas/OrderStatus.yaml"; "components/schemas/OrderList.yaml" -> "components/schemas/Order.yaml"; "openapi.yaml" -> "/orders"; "openapi.yaml" -> "/orders/{orderId}"; - "paths/orders.yaml" -> "components/schemas/Order.yaml"; - "paths/orders.yaml" -> "components/schemas/OrderList.yaml"; - "paths/orders_{orderId}.yaml" -> "components/schemas/Error.yaml"; - "paths/orders_{orderId}.yaml" -> "components/schemas/Order.yaml"; } diff --git a/tests/e2e/tree/tree-structure-json/snapshot.txt b/tests/e2e/tree/tree-structure-json/snapshot.txt index 3c1c0f752a..9cb3cafdeb 100644 --- a/tests/e2e/tree/tree-structure-json/snapshot.txt +++ b/tests/e2e/tree/tree-structure-json/snapshot.txt @@ -126,63 +126,63 @@ ] }, { - "source": "components/schemas/Order.yaml", - "target": "components/schemas/MenuItem.yaml", + "source": "DELETE /orders/{orderId}", + "target": "components/schemas/Error.yaml", "refs": [ - "MenuItem.yaml" + "../components/schemas/Error.yaml" ] }, { - "source": "components/schemas/Order.yaml", - "target": "components/schemas/OrderStatus.yaml", + "source": "GET /orders", + "target": "components/schemas/OrderList.yaml", "refs": [ - "OrderStatus.yaml" + "../components/schemas/OrderList.yaml" ] }, { - "source": "components/schemas/OrderList.yaml", + "source": "GET /orders/{orderId}", "target": "components/schemas/Order.yaml", "refs": [ - "Order.yaml" + "../components/schemas/Order.yaml" ] }, { - "source": "openapi.yaml", - "target": "/orders", - "refs": [] - }, - { - "source": "openapi.yaml", - "target": "/orders/{orderId}", - "refs": [] - }, - { - "source": "paths/orders.yaml", + "source": "POST /orders", "target": "components/schemas/Order.yaml", "refs": [ "../components/schemas/Order.yaml" ] }, { - "source": "paths/orders.yaml", - "target": "components/schemas/OrderList.yaml", + "source": "components/schemas/Order.yaml", + "target": "components/schemas/MenuItem.yaml", "refs": [ - "../components/schemas/OrderList.yaml" + "MenuItem.yaml" ] }, { - "source": "paths/orders_{orderId}.yaml", - "target": "components/schemas/Error.yaml", + "source": "components/schemas/Order.yaml", + "target": "components/schemas/OrderStatus.yaml", "refs": [ - "../components/schemas/Error.yaml" + "OrderStatus.yaml" ] }, { - "source": "paths/orders_{orderId}.yaml", + "source": "components/schemas/OrderList.yaml", "target": "components/schemas/Order.yaml", "refs": [ - "../components/schemas/Order.yaml" + "Order.yaml" ] + }, + { + "source": "openapi.yaml", + "target": "/orders", + "refs": [] + }, + { + "source": "openapi.yaml", + "target": "/orders/{orderId}", + "refs": [] } ] } diff --git a/tests/e2e/tree/tree-structure-mermaid/snapshot.txt b/tests/e2e/tree/tree-structure-mermaid/snapshot.txt index 21d9d52be4..54382f9c87 100644 --- a/tests/e2e/tree/tree-structure-mermaid/snapshot.txt +++ b/tests/e2e/tree/tree-structure-mermaid/snapshot.txt @@ -19,14 +19,14 @@ flowchart LR n1 --> n2 n1 --> n4 n1 --> n13 + n2 --> n6 + n3 --> n9 + n4 --> n8 + n5 --> n8 n8 --> n7 n8 --> n10 n9 --> n8 n11 --> n0 n11 --> n1 - n12 --> n8 - n12 --> n9 - n13 --> n6 - n13 --> n8 classDef root font-weight:bold diff --git a/tests/e2e/tree/tree-structure-stylish/snapshot.txt b/tests/e2e/tree/tree-structure-stylish/snapshot.txt index 5bbfdac2b2..beca3cf47d 100644 --- a/tests/e2e/tree/tree-structure-stylish/snapshot.txt +++ b/tests/e2e/tree/tree-structure-stylish/snapshot.txt @@ -1,21 +1,21 @@ openapi.yaml ├── /orders │ ├── GET +│ │ └── components/schemas/OrderList.yaml +│ │ └── components/schemas/Order.yaml +│ │ ├── components/schemas/MenuItem.yaml +│ │ └── components/schemas/OrderStatus.yaml │ ├── POST +│ │ └── components/schemas/Order.yaml +│ │ ├── components/schemas/MenuItem.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 └── /orders/{orderId} ├── DELETE + │ └── components/schemas/Error.yaml ├── GET + │ └── 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-structure-used-by-file/snapshot.txt b/tests/e2e/tree/tree-structure-used-by-file/snapshot.txt index d463ac27eb..e77c6e7991 100644 --- a/tests/e2e/tree/tree-structure-used-by-file/snapshot.txt +++ b/tests/e2e/tree/tree-structure-used-by-file/snapshot.txt @@ -1,12 +1,13 @@ openapi.yaml ├── /orders -│ └── paths/orders.yaml -│ ├── components/schemas/Order.yaml -│ └── components/schemas/OrderList.yaml -│ └── components/schemas/Order.yaml +│ ├── GET +│ │ └── components/schemas/OrderList.yaml +│ │ └── components/schemas/Order.yaml +│ └── POST +│ └── components/schemas/Order.yaml └── /orders/{orderId} - └── paths/orders_{orderId}.yaml + └── GET └── components/schemas/Order.yaml -0 of 4 operations affected · affected paths: /orders, /orders/{orderId} +3 of 4 operations affected · affected paths: /orders, /orders/{orderId} From 369815eb487d16d8e68ae6f960adbd67471dd05e Mon Sep 17 00:00:00 2001 From: kanoru Date: Sat, 1 Aug 2026 14:06:45 +0300 Subject: [PATCH 55/79] docs(cli): document unbundled tree structure view --- .changeset/unified-tree-phase1.md | 7 ++++ docs/@v2/commands/tree.md | 54 +++++++++++++++++++++++++++++-- 2 files changed, 58 insertions(+), 3 deletions(-) create mode 100644 .changeset/unified-tree-phase1.md diff --git a/.changeset/unified-tree-phase1.md b/.changeset/unified-tree-phase1.md new file mode 100644 index 0000000000..60c1a49ddb --- /dev/null +++ b/.changeset/unified-tree-phase1.md @@ -0,0 +1,7 @@ +--- +'@redocly/openapi-core': minor +'@redocly/cli': minor +--- + +Reworked the experimental `tree` command's structure view to walk the original files instead of a bundled copy: every node now reports the file that defines it, `operationId`s survive `$ref`'d path items, and an unresolvable `$ref` is shown as an unresolved node with a warning instead of failing the command. +The graph model moved to `@redocly/openapi-core` as the new `api-graph` module (`buildApiGraph`), reusable outside the CLI. diff --git a/docs/@v2/commands/tree.md b/docs/@v2/commands/tree.md index a3a3b64111..bcdda46e4e 100644 --- a/docs/@v2/commands/tree.md +++ b/docs/@v2/commands/tree.md @@ -3,7 +3,7 @@ ## Introduction The `tree` command prints the structure of an API description: its paths, operations, and the component dependency chains between them through `$ref`. -The default view bundles the description first, so a multi-file API shows the same full tree as its single-file form. +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. @@ -434,8 +434,56 @@ redocly tree cafe.yaml --format=mermaid --output cafe.md ### Invalid descriptions -The default view bundles the description before walking it. -If the description cannot be bundled — for example, it has unresolvable or invalid `$ref`s — `tree` prints the bundling problems and exits with a non-zero code instead of printing a partial tree. +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 From 2fb842c66a507e1ae1c3a890cc3d89669484fd79 Mon Sep 17 00:00:00 2001 From: kanoru Date: Sat, 1 Aug 2026 14:18:45 +0300 Subject: [PATCH 56/79] docs(cli): fix remaining bundling-era statements in tree reference --- docs/@v2/commands/tree.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/@v2/commands/tree.md b/docs/@v2/commands/tree.md index bcdda46e4e..e17743a1b2 100644 --- a/docs/@v2/commands/tree.md +++ b/docs/@v2/commands/tree.md @@ -105,7 +105,7 @@ An operation is shown as the method only (`GET`) under its path, since the path 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` (only in `--files` mode; in the default view an unresolvable `$ref` is an error, see below) +- `❌` — 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: @@ -158,7 +158,8 @@ menu.yaml {% /tab %} {% /tabs %} -`❌` and `🔗` appear only with `--files`. In the default view an unresolvable `$ref` is a bundling error instead (see _Invalid descriptions_ below), so this example must be run with `--files`: +`❌` and `🔗` appear in both views. +This example uses `--files` to show the file-level graph: {% tabs %} {% tab label="API description" %} @@ -205,8 +206,8 @@ openapi.yaml {% /tab %} {% /tabs %} -The default view bundles the description, so components and operations split across files are resolved to their canonical place. -A multi-file API therefore produces the same tree as its single-file equivalent — operations and named components, not file nodes. +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 @@ -463,7 +464,7 @@ paths: $ref: 'https://example.com/error.yaml' ``` -{% /tab %} +{% /tab %} {% tab label="Output" %} ```bash @@ -482,8 +483,8 @@ openapi.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 %} +{% /tab %} +{% /tabs %} ### File-level graph From 9a708c573ce46f6787c35fa037d10536c5b0d351 Mon Sep 17 00:00:00 2001 From: kanoru Date: Sat, 1 Aug 2026 14:51:30 +0300 Subject: [PATCH 57/79] fix(cli): align uses docs, e2e titles, and callback-ref test with unbundled tree --- docs/@v2/commands/tree.md | 4 +++- packages/core/src/api-graph/__tests__/build-graph.test.ts | 4 ++++ .../src/api-graph/__tests__/fixtures/split/paths/tickets.yaml | 4 ++++ tests/e2e/tree/tree.test.ts | 4 ++-- 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/@v2/commands/tree.md b/docs/@v2/commands/tree.md index e17743a1b2..4a2b8e3e46 100644 --- a/docs/@v2/commands/tree.md +++ b/docs/@v2/commands/tree.md @@ -299,9 +299,11 @@ cafe.yaml - 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 (in `--files` mode): `components/schemas/Order.yaml` +- a file path: `components/schemas/Order.yaml` — in the structure view this addresses a component that lives in its own file; in `--files` mode it addresses the file node. - the root file itself: the whole tree is affected +For a multi-file API, components split into their own files are addressed by file path — their `schemas/` ids belong to components defined inline in the root file. + Examples of the different input forms: ```bash diff --git a/packages/core/src/api-graph/__tests__/build-graph.test.ts b/packages/core/src/api-graph/__tests__/build-graph.test.ts index 50d55ae8ab..2a9bc70525 100644 --- a/packages/core/src/api-graph/__tests__/build-graph.test.ts +++ b/packages/core/src/api-graph/__tests__/build-graph.test.ts @@ -136,5 +136,9 @@ describe('buildApiGraph', () => { 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__/fixtures/split/paths/tickets.yaml b/packages/core/src/api-graph/__tests__/fixtures/split/paths/tickets.yaml index c2713519b8..31e3f4c0f3 100644 --- a/packages/core/src/api-graph/__tests__/fixtures/split/paths/tickets.yaml +++ b/packages/core/src/api-graph/__tests__/fixtures/split/paths/tickets.yaml @@ -15,3 +15,7 @@ post: responses: '200': description: ok + content: + application/json: + schema: + $ref: '../components/schemas/Ticket.yaml' diff --git a/tests/e2e/tree/tree.test.ts b/tests/e2e/tree/tree.test.ts index a88b92a5c4..be42ae22a2 100644 --- a/tests/e2e/tree/tree.test.ts +++ b/tests/e2e/tree/tree.test.ts @@ -56,7 +56,7 @@ describe('tree', () => { ); }); - test('tree shows what a component pointer is used by', async () => { + test('tree reports no matches for a component pointer split into its own file', async () => { const args = getParams(indexEntryPoint, [ 'tree', 'openapi.yaml', @@ -75,7 +75,7 @@ describe('tree', () => { ); }); - test('tree points a file used-by to --files in the default view', async () => { + test('tree shows what a component file is used by in the default view', async () => { const args = getParams(indexEntryPoint, [ 'tree', 'openapi.yaml', From 442dfd77839a877cd57c3e168b20a1db6a134fec Mon Sep 17 00:00:00 2001 From: kanoru Date: Sat, 1 Aug 2026 20:19:41 +0300 Subject: [PATCH 58/79] feat(core): collect index metadata in the api-graph walk --- .../src/api-graph/__tests__/analyze.test.ts | 79 ++++++++ .../__tests__/fixtures/split/openapi.yaml | 6 + .../fixtures/split/paths/tickets.yaml | 2 + .../__tests__/fixtures/webhooks/openapi.yaml | 12 ++ packages/core/src/api-graph/build-graph.ts | 187 +++++++++++++++++- packages/core/src/index.ts | 11 +- 6 files changed, 289 insertions(+), 8 deletions(-) create mode 100644 packages/core/src/api-graph/__tests__/analyze.test.ts create mode 100644 packages/core/src/api-graph/__tests__/fixtures/webhooks/openapi.yaml 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__/fixtures/split/openapi.yaml b/packages/core/src/api-graph/__tests__/fixtures/split/openapi.yaml index 7776e62f42..0925bcedf7 100644 --- a/packages/core/src/api-graph/__tests__/fixtures/split/openapi.yaml +++ b/packages/core/src/api-graph/__tests__/fixtures/split/openapi.yaml @@ -2,6 +2,12 @@ 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' 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 index 31e3f4c0f3..c4c3803912 100644 --- a/packages/core/src/api-graph/__tests__/fixtures/split/paths/tickets.yaml +++ b/packages/core/src/api-graph/__tests__/fixtures/split/paths/tickets.yaml @@ -1,4 +1,6 @@ post: + summary: Buy museum tickets + tags: [Tickets] operationId: buyTickets responses: '201': 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/build-graph.ts b/packages/core/src/api-graph/build-graph.ts index c175521ca9..7e667cd147 100644 --- a/packages/core/src/api-graph/build-graph.ts +++ b/packages/core/src/api-graph/build-graph.ts @@ -1,5 +1,5 @@ import type { SpecVersion } from '../oas-types.js'; -import { isAbsoluteUrl, type Location } from '../ref-utils.js'; +import { isAbsoluteUrl, isRef, type Location } from '../ref-utils.js'; import { resolveDocument, type BaseResolver, @@ -8,7 +8,7 @@ import { } from '../resolve.js'; import type { NormalizedNodeType } from '../types/index.js'; import { normalizeVisitors, type Oas3Visitor } from '../visitors.js'; -import { walkDocument, type WalkContext } from '../walk.js'; +import { walkDocument, type UserContext, type WalkContext } from '../walk.js'; import { compareStrings, mapForeignLocation, @@ -20,6 +20,57 @@ import { } 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; +}; + +const COMPONENT_SECTIONS = [ + ['NamedSchemas', 'schemas'], + ['NamedResponses', 'responses'], + ['NamedParameters', 'parameters'], + ['NamedRequestBodies', 'requestBodies'], + ['NamedHeaders', 'headers'], + ['NamedSecuritySchemes', 'securitySchemes'], + ['NamedExamples', 'examples'], + ['NamedLinks', 'links'], + ['NamedCallbacks', 'callbacks'], +] as const; + export async function buildApiGraph(options: { rootDocument: Document; specVersion: SpecVersion; @@ -28,6 +79,18 @@ export async function buildApiGraph(options: { cwd: string; resolveRef: (base: string, uri: string) => string; }): Promise { + const { graph } = await analyzeApi(options); + return graph; +} + +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({ @@ -38,7 +101,16 @@ export async function buildApiGraph(options: { const ctx: WalkContext = { problems: [], specVersion, visitorsData: {} }; - return walkStructure({ document: rootDocument, types, resolvedRefMap, ctx, cwd, resolveRef }); + const { graph, meta } = walkStructure({ + document: rootDocument, + types, + resolvedRefMap, + ctx, + cwd, + resolveRef, + }); + + return { graph, meta, resolvedRefMap, rootDocument }; } export function walkStructure(options: { @@ -48,7 +120,7 @@ export function walkStructure(options: { ctx: WalkContext; cwd: string; resolveRef: (base: string, uri: string) => string; -}): DependencyGraph { +}): { graph: DependencyGraph; meta: ApiIndexMeta } { const { document, types, resolvedRefMap, ctx, cwd, resolveRef } = options; const rootAbs = document.source.absoluteRef; @@ -56,6 +128,7 @@ export function walkStructure(options: { const nodes = new Map(); const edges = new Map(); + const meta: ApiIndexMeta = { declaredTags: [], operations: [], components: [] }; const addOrUpdateNode = (mapped: MappedNode & { file: string }, resolved: boolean) => { const node = nodes.get(mapped.id) ?? { id: mapped.id, resolved: false }; @@ -141,7 +214,66 @@ export function walkStructure(options: { let currentOperationNodeId: string | undefined; let currentOperationFileAbs: string | undefined; - const visitor: Oas3Visitor = { + 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 }); + } + }; + + const namedComponentVisitors = Object.fromEntries( + COMPONENT_SECTIONS.map(([visitorName, section]) => [visitorName, 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) { + 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; @@ -152,10 +284,37 @@ export function walkStructure(options: { 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; } @@ -171,6 +330,20 @@ export function walkStructure(options: { 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) { @@ -200,7 +373,7 @@ export function walkStructure(options: { addEdge(ownerId, targetId, refString); }, }, - }; + } as Oas3Visitor; addOrUpdateNode({ id: rootId, kind: 'root', file: rootId }, true); nodes.get(rootId)!.root = true; @@ -211,7 +384,7 @@ export function walkStructure(options: { ); walkDocument({ document, rootType: types.Root, normalizedVisitors, resolvedRefMap, ctx }); - return finalizeGraph(rootId, nodes, edges); + return { graph: finalizeGraph(rootId, nodes, edges), meta }; } /** Keeps only nodes reachable from the root, sorted for stable output. */ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c072892d6c..9a6d6b32ed 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -137,7 +137,16 @@ export { type MappedNode, } from './api-graph/node-id.js'; export type { DependencyGraph, GraphEdge, GraphNode, NodeKind } from './api-graph/types.js'; -export { buildApiGraph, collectConnectedIds, walkStructure } from './api-graph/build-graph.js'; +export { + analyzeApi, + buildApiGraph, + collectConnectedIds, + walkStructure, + type ApiAnalysis, + type ApiIndexMeta, + type CollectedComponent, + type CollectedOperation, +} from './api-graph/build-graph.js'; export { logger, type LoggerInterface } from './logger.js'; export { HandledError } from './utils/error.js'; export { isSupportedExtension } from './utils/is-supported-extension.js'; From c0adb4167f5ae358aa15a4f6c6c8c3f9a2e85590 Mon Sep 17 00:00:00 2001 From: kanoru Date: Sat, 1 Aug 2026 22:40:22 +0300 Subject: [PATCH 59/79] feat(core): assemble the hierarchical api index from walk metadata --- .../api-graph/__tests__/build-index.test.ts | 88 ++++++ .../fixtures/split/paths/tickets.yaml | 13 + packages/core/src/api-graph/build-index.ts | 250 ++++++++++++++++++ packages/core/src/index.ts | 7 + 4 files changed, 358 insertions(+) create mode 100644 packages/core/src/api-graph/__tests__/build-index.test.ts create mode 100644 packages/core/src/api-graph/build-index.ts 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..62324fe1c2 --- /dev/null +++ b/packages/core/src/api-graph/__tests__/build-index.test.ts @@ -0,0 +1,88 @@ +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('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/split/paths/tickets.yaml b/packages/core/src/api-graph/__tests__/fixtures/split/paths/tickets.yaml index c4c3803912..5783e621f9 100644 --- a/packages/core/src/api-graph/__tests__/fixtures/split/paths/tickets.yaml +++ b/packages/core/src/api-graph/__tests__/fixtures/split/paths/tickets.yaml @@ -1,3 +1,16 @@ +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] 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..ab1599ff18 --- /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'; + +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, + }); + } + + return { + docName: toRelativePath(rootDocument.source.absoluteRef, cwd), + spec: specVersion, + ...(meta.info ? spreadDefined('docDescription', buildDocDescription(meta.info)) : {}), + structure, + }; +} + +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 spreadDefined(key: 'docDescription', value: string | undefined) { + return value === undefined ? {} : { [key]: value }; +} + +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))]; + const canonicalOrder = [ + 'schemas', + 'responses', + 'parameters', + 'requestBodies', + 'headers', + 'securitySchemes', + 'examples', + 'links', + 'callbacks', + ]; + sections.sort((a, b) => canonicalOrder.indexOf(a) - canonicalOrder.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/index.ts b/packages/core/src/index.ts index 9a6d6b32ed..682366dbfb 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -147,6 +147,13 @@ export { type CollectedComponent, type CollectedOperation, } from './api-graph/build-graph.js'; +export { + buildApiIndex, + SUMMARY_LIMIT, + type ApiIndex, + type ApiIndexNode, + type IndexGroupBy, +} from './api-graph/build-index.js'; export { logger, type LoggerInterface } from './logger.js'; export { HandledError } from './utils/error.js'; export { isSupportedExtension } from './utils/is-supported-extension.js'; From 0153e3cd6c06263cf0ef547e1cf092395cfadd46 Mon Sep 17 00:00:00 2001 From: kanoru Date: Sat, 1 Aug 2026 22:52:04 +0300 Subject: [PATCH 60/79] refactor(core): inline single-use docDescription spread helper --- packages/core/src/api-graph/build-index.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/core/src/api-graph/build-index.ts b/packages/core/src/api-graph/build-index.ts index ab1599ff18..2a49f564e5 100644 --- a/packages/core/src/api-graph/build-index.ts +++ b/packages/core/src/api-graph/build-index.ts @@ -97,10 +97,12 @@ export function buildApiIndex( }); } + const docDescription = meta.info ? buildDocDescription(meta.info) : undefined; + return { docName: toRelativePath(rootDocument.source.absoluteRef, cwd), spec: specVersion, - ...(meta.info ? spreadDefined('docDescription', buildDocDescription(meta.info)) : {}), + ...(docDescription ? { docDescription } : {}), structure, }; } @@ -143,10 +145,6 @@ function buildDocDescription(docInfo: { return truncateSummary([docInfo.title, docInfo.description].filter(Boolean).join(' — ')); } -function spreadDefined(key: 'docDescription', value: string | undefined) { - return value === undefined ? {} : { [key]: value }; -} - function toOperationNode(operation: CollectedOperation, cwd: string): ApiIndexNode { const titleSuffix = truncateSummary(operation.summary); const summary = truncateSummary(operation.summary ?? operation.description); From 8b8d43c12354d801ec41bfca6342d56f4c8ca84f Mon Sep 17 00:00:00 2001 From: kanoru Date: Sat, 1 Aug 2026 23:02:22 +0300 Subject: [PATCH 61/79] feat(core): add api-graph retrieval slice with envelopes and refs --- .../src/api-graph/__tests__/slice.test.ts | 84 ++++++++++ packages/core/src/api-graph/slice.ts | 143 ++++++++++++++++++ packages/core/src/index.ts | 8 + 3 files changed, 235 insertions(+) create mode 100644 packages/core/src/api-graph/__tests__/slice.test.ts create mode 100644 packages/core/src/api-graph/slice.ts 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..a0a75f8829 --- /dev/null +++ b/packages/core/src/api-graph/__tests__/slice.test.ts @@ -0,0 +1,84 @@ +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 { 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'); + }); +}); diff --git a/packages/core/src/api-graph/slice.ts b/packages/core/src/api-graph/slice.ts new file mode 100644 index 0000000000..c3466caed5 --- /dev/null +++ b/packages/core/src/api-graph/slice.ts @@ -0,0 +1,143 @@ +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 type { ApiIndexNode } from './build-index.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: relativeToCwd(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, + }; +} + +function relativeToCwd(absoluteRef: string, cwd: string): string { + // Mirrors build-index's toRelativePath; kept local to avoid a cross-module helper for two lines. + return absoluteRef.startsWith(cwd) + ? absoluteRef.slice(cwd.length).replace(/^\//, '') + : absoluteRef; +} + +function documentsByFile(analysis: ApiAnalysis, cwd: string): Map { + const documents = new Map(); + documents.set( + relativeToCwd(analysis.rootDocument.source.absoluteRef, cwd), + analysis.rootDocument + ); + for (const resolvedRef of analysis.resolvedRefMap.values()) { + if (resolvedRef.document) { + documents.set( + relativeToCwd(resolvedRef.document.source.absoluteRef, cwd), + resolvedRef.document + ); + } + } + 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/index.ts b/packages/core/src/index.ts index 682366dbfb..0920066797 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -154,6 +154,14 @@ export { type ApiIndexNode, type IndexGroupBy, } from './api-graph/build-index.js'; +export { + buildNodeEnvelope, + 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'; From 9dfdd208192ba3771218298a80ad9366521aa93d Mon Sep 17 00:00:00 2001 From: kanoru Date: Sat, 1 Aug 2026 23:22:11 +0300 Subject: [PATCH 62/79] fix(core): share path normalization between index and retrieval slice --- .../fixtures/outside/common/Error.yaml | 4 +++ .../fixtures/outside/sub/openapi.yaml | 9 ++++++ .../src/api-graph/__tests__/slice.test.ts | 31 +++++++++++++++++++ packages/core/src/api-graph/build-index.ts | 2 +- packages/core/src/api-graph/slice.ts | 15 +++------ 5 files changed, 49 insertions(+), 12 deletions(-) create mode 100644 packages/core/src/api-graph/__tests__/fixtures/outside/common/Error.yaml create mode 100644 packages/core/src/api-graph/__tests__/fixtures/outside/sub/openapi.yaml 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__/slice.test.ts b/packages/core/src/api-graph/__tests__/slice.test.ts index a0a75f8829..a751f26c80 100644 --- a/packages/core/src/api-graph/__tests__/slice.test.ts +++ b/packages/core/src/api-graph/__tests__/slice.test.ts @@ -82,3 +82,34 @@ describe('buildNodeEnvelope', () => { 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'); + }); +}); diff --git a/packages/core/src/api-graph/build-index.ts b/packages/core/src/api-graph/build-index.ts index 2a49f564e5..057a1f2bef 100644 --- a/packages/core/src/api-graph/build-index.ts +++ b/packages/core/src/api-graph/build-index.ts @@ -107,7 +107,7 @@ export function buildApiIndex( }; } -function toRelativePath(absoluteRef: string, cwd: string): string { +export function toRelativePath(absoluteRef: string, cwd: string): string { return isAbsoluteUrl(absoluteRef) ? absoluteRef : path.relative(cwd, absoluteRef).split(path.sep).join('/'); diff --git a/packages/core/src/api-graph/slice.ts b/packages/core/src/api-graph/slice.ts index c3466caed5..4d5dda169a 100644 --- a/packages/core/src/api-graph/slice.ts +++ b/packages/core/src/api-graph/slice.ts @@ -2,7 +2,7 @@ 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 type { ApiIndexNode } from './build-index.js'; +import { toRelativePath, type ApiIndexNode } from './build-index.js'; export type ApiNodeRef = { ref: string; @@ -78,7 +78,7 @@ export function buildNodeEnvelope(options: { return { ref, resolved: true, - file: relativeToCwd(resolvedRef.document.source.absoluteRef, cwd), + file: toRelativePath(resolvedRef.document.source.absoluteRef, cwd), pointer: resolvedRef.nodePointer.startsWith('#') ? resolvedRef.nodePointer : `#${resolvedRef.nodePointer}`, @@ -96,23 +96,16 @@ export function buildNodeEnvelope(options: { }; } -function relativeToCwd(absoluteRef: string, cwd: string): string { - // Mirrors build-index's toRelativePath; kept local to avoid a cross-module helper for two lines. - return absoluteRef.startsWith(cwd) - ? absoluteRef.slice(cwd.length).replace(/^\//, '') - : absoluteRef; -} - function documentsByFile(analysis: ApiAnalysis, cwd: string): Map { const documents = new Map(); documents.set( - relativeToCwd(analysis.rootDocument.source.absoluteRef, cwd), + toRelativePath(analysis.rootDocument.source.absoluteRef, cwd), analysis.rootDocument ); for (const resolvedRef of analysis.resolvedRefMap.values()) { if (resolvedRef.document) { documents.set( - relativeToCwd(resolvedRef.document.source.absoluteRef, cwd), + toRelativePath(resolvedRef.document.source.absoluteRef, cwd), resolvedRef.document ); } From 9b0bedac2c4237da9c6c265b2fa32a29f2224c3f Mon Sep 17 00:00:00 2001 From: kanoru Date: Sat, 1 Aug 2026 23:30:22 +0300 Subject: [PATCH 63/79] feat(core): add dependency closure to api-graph envelopes --- .../split/components/schemas/Ticket.yaml | 2 +- .../split/components/schemas/TicketId.yaml | 2 + .../src/api-graph/__tests__/slice.test.ts | 47 +++++++- packages/core/src/api-graph/slice.ts | 104 +++++++++++++++++- packages/core/src/index.ts | 2 + 5 files changed, 154 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/api-graph/__tests__/fixtures/split/components/schemas/TicketId.yaml 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 index 78dcf53210..81c010bc80 100644 --- 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 @@ -1,4 +1,4 @@ type: object properties: ticketId: - type: string + $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__/slice.test.ts b/packages/core/src/api-graph/__tests__/slice.test.ts index a751f26c80..b899ad8c12 100644 --- a/packages/core/src/api-graph/__tests__/slice.test.ts +++ b/packages/core/src/api-graph/__tests__/slice.test.ts @@ -7,7 +7,7 @@ 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 { buildNodeEnvelope, findIndexNode, hasIndexLocation } from '../slice.js'; +import { appendDepsClosure, buildNodeEnvelope, findIndexNode, hasIndexLocation } from '../slice.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const FIXTURE_ROOT = join(__dirname, 'fixtures', 'split'); @@ -113,3 +113,48 @@ describe('buildNodeEnvelope outside cwd', () => { 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); + }); +}); diff --git a/packages/core/src/api-graph/slice.ts b/packages/core/src/api-graph/slice.ts index 4d5dda169a..bf059872f8 100644 --- a/packages/core/src/api-graph/slice.ts +++ b/packages/core/src/api-graph/slice.ts @@ -2,7 +2,7 @@ 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 { toRelativePath, type ApiIndexNode } from './build-index.js'; +import { toRelativePath, type ApiIndex, type ApiIndexNode } from './build-index.js'; export type ApiNodeRef = { ref: string; @@ -96,7 +96,108 @@ export function buildNodeEnvelope(options: { }; } +// Keyed by analysis only: an ApiAnalysis is always paired with the cwd it was built with, +// so cwd doesn't need to be part of the cache key. +const documentsByFileCache = new WeakMap>(); + +export const DEPS_CONTENT_CAP_BYTES = 65536; + +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 leavesById = new Map(); + const leavesByFile = new Map(); + collectLocatedLeaves(index.structure, leavesById, leavesByFile); + + const graphIds = new Set(analysis.graph.nodes.map((node) => node.id)); + const seed = graphIds.has(indexNode.id) ? indexNode.id : indexNode.file; + + 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 } : {}) }; +} + +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); + // First leaf wins per file: whole-file components map a graph file node to an envelope. + if (!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 cached = documentsByFileCache.get(analysis); + if (cached) return cached; + const documents = new Map(); documents.set( toRelativePath(analysis.rootDocument.source.absoluteRef, cwd), @@ -110,6 +211,7 @@ function documentsByFile(analysis: ApiAnalysis, cwd: string): Map Date: Sun, 2 Aug 2026 00:01:10 +0300 Subject: [PATCH 64/79] fix(core): gate deps-closure seeds and restrict file aliasing to component leaves --- .../src/api-graph/__tests__/slice.test.ts | 19 +++++++ packages/core/src/api-graph/slice.ts | 54 +++++++++++++++---- 2 files changed, 63 insertions(+), 10 deletions(-) diff --git a/packages/core/src/api-graph/__tests__/slice.test.ts b/packages/core/src/api-graph/__tests__/slice.test.ts index b899ad8c12..13a258d675 100644 --- a/packages/core/src/api-graph/__tests__/slice.test.ts +++ b/packages/core/src/api-graph/__tests__/slice.test.ts @@ -157,4 +157,23 @@ describe('appendDepsClosure', () => { 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/slice.ts b/packages/core/src/api-graph/slice.ts index bf059872f8..d604bb28b2 100644 --- a/packages/core/src/api-graph/slice.ts +++ b/packages/core/src/api-graph/slice.ts @@ -3,6 +3,7 @@ import type { Document } from '../resolve.js'; import { isPlainObject } from '../utils/is-plain-object.js'; import type { ApiAnalysis } from './build-graph.js'; import { toRelativePath, type ApiIndex, type ApiIndexNode } from './build-index.js'; +import type { NodeKind } from './types.js'; export type ApiNodeRef = { ref: string; @@ -96,12 +97,19 @@ export function buildNodeEnvelope(options: { }; } -// Keyed by analysis only: an ApiAnalysis is always paired with the cwd it was built with, -// so cwd doesn't need to be part of the cache key. -const documentsByFileCache = new WeakMap>(); +// 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; @@ -113,13 +121,17 @@ export function appendDepsClosure(options: { 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 graphIds = new Set(analysis.graph.nodes.map((node) => node.id)); - const seed = graphIds.has(indexNode.id) ? indexNode.id : indexNode.file; - const adjacency = new Map(); for (const edge of analysis.graph.edges) { const neighbours = adjacency.get(edge.from) ?? []; @@ -158,6 +170,24 @@ export function appendDepsClosure(options: { return { ...envelope, deps, ...(truncated ? { truncated: true } : {}) }; } +// Mirrors the component sections in build-index.ts's groupComponents: a leaf's id is +// `${section}/${name}`, e.g. `schemas/Ticket`. +const COMPONENT_SECTIONS = [ + 'schemas', + 'responses', + 'parameters', + 'requestBodies', + 'headers', + 'securitySchemes', + 'examples', + 'links', + 'callbacks', +]; + +function isComponentLeafId(id: string): boolean { + return COMPONENT_SECTIONS.some((section) => id.startsWith(`${section}/`)); +} + function collectLocatedLeaves( nodes: ApiIndexNode[], byId: Map, @@ -170,8 +200,10 @@ function collectLocatedLeaves( } if (hasIndexLocation(node)) { byId.set(node.id, node); - // First leaf wins per file: whole-file components map a graph file node to an envelope. - if (!byFile.has(node.file)) byFile.set(node.file, 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); } } } @@ -195,7 +227,8 @@ function wholeFileEnvelope( } function documentsByFile(analysis: ApiAnalysis, cwd: string): Map { - const cached = documentsByFileCache.get(analysis); + const byCwd = documentsByFileCache.get(analysis) ?? new Map>(); + const cached = byCwd.get(cwd); if (cached) return cached; const documents = new Map(); @@ -211,7 +244,8 @@ function documentsByFile(analysis: ApiAnalysis, cwd: string): Map Date: Sun, 2 Aug 2026 00:23:40 +0300 Subject: [PATCH 65/79] feat(cli): render the agent index for tree --format json --- .../tree/__tests__/build-structure.test.ts | 8 +- .../tree/__tests__/filter-index.test.ts | 59 ++++ .../cli/src/commands/tree/build-structure.ts | 11 +- .../cli/src/commands/tree/filter-index.ts | 40 +++ packages/cli/src/commands/tree/index.ts | 34 +- .../cli/src/commands/tree/print/index-json.ts | 5 + packages/cli/src/index.ts | 8 +- .../e2e/tree/tree-structure-json/snapshot.txt | 293 +++++++----------- 8 files changed, 269 insertions(+), 189 deletions(-) create mode 100644 packages/cli/src/commands/tree/__tests__/filter-index.test.ts create mode 100644 packages/cli/src/commands/tree/filter-index.ts create mode 100644 packages/cli/src/commands/tree/print/index-json.ts diff --git a/packages/cli/src/commands/tree/__tests__/build-structure.test.ts b/packages/cli/src/commands/tree/__tests__/build-structure.test.ts index 7928fc5102..d51cdc10a7 100644 --- a/packages/cli/src/commands/tree/__tests__/build-structure.test.ts +++ b/packages/cli/src/commands/tree/__tests__/build-structure.test.ts @@ -23,7 +23,7 @@ async function structureOf( const specVersion = detectSpec(parsed); const config = await createConfig({}); const types = normalizeTypes(config.extendTypes(getTypes(specVersion), specVersion), config); - const { graph } = await buildStructureGraph({ + const { analysis } = await buildStructureGraph({ rootDocument, specVersion, types, @@ -31,7 +31,7 @@ async function structureOf( externalRefResolver, cwd: CWD, }); - return graph; + return analysis.graph; } function edgeRefs(graph: DependencyGraph, from: string, to: string): string[] | undefined { @@ -497,7 +497,7 @@ describe('buildStructureGraph (multi-file parity)', () => { if (rootDocument instanceof Error) throw rootDocument; const specVersion = detectSpec(rootDocument.parsed); const types = normalizeTypes(config.extendTypes(getTypes(specVersion), specVersion), config); - const { graph } = await buildStructureGraph({ + const { analysis } = await buildStructureGraph({ rootDocument, specVersion, types, @@ -505,7 +505,7 @@ describe('buildStructureGraph (multi-file parity)', () => { externalRefResolver, cwd: path.dirname(apiPath), }); - return graph; + return analysis.graph; } it('walks a split multi-file description into real file nodes and cross-file edges', async () => { 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..94e776f32a --- /dev/null +++ b/packages/cli/src/commands/tree/__tests__/filter-index.test.ts @@ -0,0 +1,59 @@ +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: [ + { id: 'GET /tickets', title: 'GET /tickets' }, + { id: 'POST /tickets', title: 'POST /tickets' }, + ], + }, + ], + }, + { + id: 'Webhooks', + title: 'Webhooks', + nodes: [{ id: 'POST newTicket', title: 'POST newTicket' }], + }, + ], +}; + +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', + ]); + }); +}); + +describe('limitIndexLevel', () => { + it('prunes below the requested depth', () => { + const limited = limitIndexLevel(INDEX, 1); + expect(limited.structure.map((section) => section.id)).toEqual([ + 'Overview', + 'Operations', + 'Webhooks', + ]); + 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/build-structure.ts b/packages/cli/src/commands/tree/build-structure.ts index 2aa97d973f..5c775f05c9 100644 --- a/packages/cli/src/commands/tree/build-structure.ts +++ b/packages/cli/src/commands/tree/build-structure.ts @@ -1,5 +1,6 @@ import { - buildApiGraph, + analyzeApi, + type ApiAnalysis, type BaseResolver, type Config, type Document, @@ -7,8 +8,6 @@ import { type SpecVersion, } from '@redocly/openapi-core'; -import type { DependencyGraph } from './types.js'; - export async function buildStructureGraph(options: { rootDocument: Document; specVersion: SpecVersion; @@ -16,9 +15,9 @@ export async function buildStructureGraph(options: { config: Config; externalRefResolver: BaseResolver; cwd: string; -}): Promise<{ graph: DependencyGraph }> { +}): Promise<{ analysis: ApiAnalysis }> { const { rootDocument, specVersion, types, externalRefResolver, cwd } = options; - const graph = await buildApiGraph({ + const analysis = await analyzeApi({ rootDocument, specVersion, types, @@ -26,5 +25,5 @@ export async function buildStructureGraph(options: { cwd, resolveRef: (base, uri) => externalRefResolver.resolveExternalRef(base, uri), }); - return { graph }; + return { analysis }; } 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..7cf630bc86 --- /dev/null +++ b/packages/cli/src/commands/tree/filter-index.ts @@ -0,0 +1,40 @@ +import type { ApiIndex, ApiIndexNode } from '@redocly/openapi-core'; + +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 index b46f5400b4..d2b712b62f 100644 --- a/packages/cli/src/commands/tree/index.ts +++ b/packages/cli/src/commands/tree/index.ts @@ -1,5 +1,6 @@ import { BaseResolver, + buildApiIndex, detectSpec, getTypes, logger, @@ -8,6 +9,7 @@ import { slash, type CollectFn, type Document, + type IndexGroupBy, type NormalizedNodeType, type ResolvedRefMap, type SpecVersion, @@ -22,9 +24,11 @@ import type { CommandArgs } from '../../wrapper.js'; import { buildGraph } from './build-graph.js'; import { buildStructureGraph } from './build-structure.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'; @@ -38,6 +42,7 @@ export type TreeArgv = { operations?: boolean; uses?: string[]; files?: boolean; + 'group-by': IndexGroupBy; } & VerifyConfigOptions; type TreeModeContext = { @@ -193,7 +198,7 @@ async function handleStructureMode({ externalRefResolver, }); - const { graph } = await buildStructureGraph({ + const { analysis } = await buildStructureGraph({ rootDocument, specVersion, types, @@ -201,6 +206,7 @@ async function handleStructureMode({ externalRefResolver, cwd, }); + const graph = analysis.graph; for (const node of graph.nodes) { if (!node.resolved) { @@ -208,6 +214,12 @@ async function handleStructureMode({ } } + const isOpenApi = specVersion.startsWith('oas'); + 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]; @@ -249,6 +261,22 @@ async function handleStructureMode({ 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 (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); } @@ -268,6 +296,10 @@ function renderOutput( } } 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`); 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/index.ts b/packages/cli/src/index.ts index 039fcef7ee..9ed752bdb6 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'; @@ -103,6 +104,11 @@ yargs(hideBin(process.argv)) 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, + }, output: { alias: 'o', description: 'Write the output to a file instead of stdout.', diff --git a/tests/e2e/tree/tree-structure-json/snapshot.txt b/tests/e2e/tree/tree-structure-json/snapshot.txt index 9cb3cafdeb..7f2bce283b 100644 --- a/tests/e2e/tree/tree-structure-json/snapshot.txt +++ b/tests/e2e/tree/tree-structure-json/snapshot.txt @@ -1,188 +1,127 @@ { - "nodes": [ - { - "id": "/orders", - "resolved": true, - "kind": "path", - "file": "paths/orders.yaml" - }, - { - "id": "/orders/{orderId}", - "resolved": true, - "kind": "path", - "file": "paths/orders_{orderId}.yaml" - }, - { - "id": "DELETE /orders/{orderId}", - "resolved": true, - "kind": "operation", - "file": "paths/orders_{orderId}.yaml", - "operationId": "cancelOrder" - }, - { - "id": "GET /orders", - "resolved": true, - "kind": "operation", - "file": "paths/orders.yaml", - "operationId": "listOrders" - }, - { - "id": "GET /orders/{orderId}", - "resolved": true, - "kind": "operation", - "file": "paths/orders_{orderId}.yaml", - "operationId": "getOrder" - }, - { - "id": "POST /orders", - "resolved": true, - "kind": "operation", - "file": "paths/orders.yaml", - "operationId": "createOrder" - }, - { - "id": "components/schemas/Error.yaml", - "resolved": true, - "kind": "file", - "file": "components/schemas/Error.yaml" - }, - { - "id": "components/schemas/MenuItem.yaml", - "resolved": true, - "kind": "file", - "file": "components/schemas/MenuItem.yaml" - }, - { - "id": "components/schemas/Order.yaml", - "resolved": true, - "kind": "file", - "file": "components/schemas/Order.yaml" - }, - { - "id": "components/schemas/OrderList.yaml", - "resolved": true, - "kind": "file", - "file": "components/schemas/OrderList.yaml" - }, - { - "id": "components/schemas/OrderStatus.yaml", - "resolved": true, - "kind": "file", - "file": "components/schemas/OrderStatus.yaml" - }, - { - "id": "openapi.yaml", - "resolved": true, - "kind": "root", + "docName": "openapi.yaml", + "spec": "oas3_2", + "docDescription": "Sample Cafe API", + "structure": [ + { + "id": "Overview", + "title": "Overview", + "pointer": "#/info", "file": "openapi.yaml", - "root": true - }, - { - "id": "paths/orders.yaml", - "resolved": true, - "kind": "file", - "file": "paths/orders.yaml" - }, - { - "id": "paths/orders_{orderId}.yaml", - "resolved": true, - "kind": "file", - "file": "paths/orders_{orderId}.yaml" - } - ], - "links": [ - { - "source": "/orders", - "target": "GET /orders", - "refs": [] - }, - { - "source": "/orders", - "target": "POST /orders", - "refs": [] + "start_line": 3, + "end_line": 4 }, { - "source": "/orders", - "target": "paths/orders.yaml", - "refs": [ - "paths/orders.yaml" - ] - }, - { - "source": "/orders/{orderId}", - "target": "DELETE /orders/{orderId}", - "refs": [] - }, - { - "source": "/orders/{orderId}", - "target": "GET /orders/{orderId}", - "refs": [] - }, - { - "source": "/orders/{orderId}", - "target": "paths/orders_{orderId}.yaml", - "refs": [ - "paths/orders_{orderId}.yaml" - ] - }, - { - "source": "DELETE /orders/{orderId}", - "target": "components/schemas/Error.yaml", - "refs": [ - "../components/schemas/Error.yaml" - ] - }, - { - "source": "GET /orders", - "target": "components/schemas/OrderList.yaml", - "refs": [ - "../components/schemas/OrderList.yaml" - ] - }, - { - "source": "GET /orders/{orderId}", - "target": "components/schemas/Order.yaml", - "refs": [ - "../components/schemas/Order.yaml" - ] - }, - { - "source": "POST /orders", - "target": "components/schemas/Order.yaml", - "refs": [ - "../components/schemas/Order.yaml" - ] - }, - { - "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" + "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" + } + ] + } ] }, { - "source": "components/schemas/OrderList.yaml", - "target": "components/schemas/Order.yaml", - "refs": [ - "Order.yaml" + "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 + } + ] + } ] - }, - { - "source": "openapi.yaml", - "target": "/orders", - "refs": [] - }, - { - "source": "openapi.yaml", - "target": "/orders/{orderId}", - "refs": [] } ] } From fdf18e4de96235f56952e3bd648f3dd094ef43d2 Mon Sep 17 00:00:00 2001 From: kanoru Date: Sun, 2 Aug 2026 00:38:53 +0300 Subject: [PATCH 66/79] refactor(core): consolidate component-section list into one export --- packages/core/src/api-graph/build-graph.ts | 19 +++++----------- packages/core/src/api-graph/build-index.ts | 26 ++++++++++++---------- packages/core/src/api-graph/slice.ts | 22 ++++++------------ 3 files changed, 27 insertions(+), 40 deletions(-) diff --git a/packages/core/src/api-graph/build-graph.ts b/packages/core/src/api-graph/build-graph.ts index 7e667cd147..fb03440d67 100644 --- a/packages/core/src/api-graph/build-graph.ts +++ b/packages/core/src/api-graph/build-graph.ts @@ -9,6 +9,7 @@ import { import type { NormalizedNodeType } from '../types/index.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, @@ -59,18 +60,6 @@ export type ApiAnalysis = { rootDocument: Document; }; -const COMPONENT_SECTIONS = [ - ['NamedSchemas', 'schemas'], - ['NamedResponses', 'responses'], - ['NamedParameters', 'parameters'], - ['NamedRequestBodies', 'requestBodies'], - ['NamedHeaders', 'headers'], - ['NamedSecuritySchemes', 'securitySchemes'], - ['NamedExamples', 'examples'], - ['NamedLinks', 'links'], - ['NamedCallbacks', 'callbacks'], -] as const; - export async function buildApiGraph(options: { rootDocument: Document; specVersion: SpecVersion; @@ -233,8 +222,12 @@ export function walkStructure(options: { } }; + // Each section's visitor is its Named* node type: schemas → NamedSchemas, and so on. const namedComponentVisitors = Object.fromEntries( - COMPONENT_SECTIONS.map(([visitorName, section]) => [visitorName, collectNamed(section)]) + 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, diff --git a/packages/core/src/api-graph/build-index.ts b/packages/core/src/api-graph/build-index.ts index 057a1f2bef..ac8c81a783 100644 --- a/packages/core/src/api-graph/build-index.ts +++ b/packages/core/src/api-graph/build-index.ts @@ -13,6 +13,19 @@ import type { export const SUMMARY_LIMIT = 160; const UNTAGGED = 'untagged'; +/** OpenAPI component sections, in the order the index lists them. */ +export const COMPONENT_SECTIONS = [ + 'schemas', + 'responses', + 'parameters', + 'requestBodies', + 'headers', + 'securitySchemes', + 'examples', + 'links', + 'callbacks', +]; + export type IndexGroupBy = 'tags' | 'paths'; export type ApiIndexNode = { @@ -218,18 +231,7 @@ function groupByPaths(operations: CollectedOperation[], cwd: string): ApiIndexNo function groupComponents(components: CollectedComponent[], cwd: string): ApiIndexNode[] { const sections = [...new Set(components.map((component) => component.section))]; - const canonicalOrder = [ - 'schemas', - 'responses', - 'parameters', - 'requestBodies', - 'headers', - 'securitySchemes', - 'examples', - 'links', - 'callbacks', - ]; - sections.sort((a, b) => canonicalOrder.indexOf(a) - canonicalOrder.indexOf(b)); + sections.sort((a, b) => COMPONENT_SECTIONS.indexOf(a) - COMPONENT_SECTIONS.indexOf(b)); return sections.map((section) => ({ id: `components/${section}`, title: section, diff --git a/packages/core/src/api-graph/slice.ts b/packages/core/src/api-graph/slice.ts index d604bb28b2..6207f9bbb3 100644 --- a/packages/core/src/api-graph/slice.ts +++ b/packages/core/src/api-graph/slice.ts @@ -2,7 +2,12 @@ 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 { toRelativePath, type ApiIndex, type ApiIndexNode } from './build-index.js'; +import { + COMPONENT_SECTIONS, + toRelativePath, + type ApiIndex, + type ApiIndexNode, +} from './build-index.js'; import type { NodeKind } from './types.js'; export type ApiNodeRef = { @@ -170,20 +175,7 @@ export function appendDepsClosure(options: { return { ...envelope, deps, ...(truncated ? { truncated: true } : {}) }; } -// Mirrors the component sections in build-index.ts's groupComponents: a leaf's id is -// `${section}/${name}`, e.g. `schemas/Ticket`. -const COMPONENT_SECTIONS = [ - 'schemas', - 'responses', - 'parameters', - 'requestBodies', - 'headers', - 'securitySchemes', - 'examples', - 'links', - 'callbacks', -]; - +// 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}/`)); } From 842b8426677683adaf59ddc38add3b01c6324b35 Mon Sep 17 00:00:00 2001 From: kanoru Date: Mon, 3 Aug 2026 10:57:29 +0300 Subject: [PATCH 67/79] fix(cli): keep split components and warn about webhooks in uses-filtered index --- .../tree/__tests__/filter-index.test.ts | 30 ++++++- .../cli/src/commands/tree/filter-index.ts | 23 ++++- packages/cli/src/commands/tree/index.ts | 5 ++ packages/core/src/api-graph/build-index.ts | 2 +- packages/core/src/index.ts | 1 + tests/e2e/tree/tree.test.ts | 12 +++ tests/e2e/tree/uses-json/snapshot.txt | 86 +++++++++++++++++++ 7 files changed, 154 insertions(+), 5 deletions(-) create mode 100644 tests/e2e/tree/uses-json/snapshot.txt diff --git a/packages/cli/src/commands/tree/__tests__/filter-index.test.ts b/packages/cli/src/commands/tree/__tests__/filter-index.test.ts index 94e776f32a..65f941f635 100644 --- a/packages/cli/src/commands/tree/__tests__/filter-index.test.ts +++ b/packages/cli/src/commands/tree/__tests__/filter-index.test.ts @@ -15,7 +15,13 @@ const INDEX: ApiIndex = { id: 'Tickets', title: 'Tickets', nodes: [ - { id: 'GET /tickets', title: 'GET /tickets' }, + // 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' }, ], }, @@ -26,6 +32,17 @@ const INDEX: ApiIndex = { 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' }], + }, + ], + }, ], }; @@ -37,6 +54,16 @@ describe('filterIndexByIds', () => { 'POST /tickets', ]); }); + + it('keeps a split component leaf by its file id, and never keeps an operation by file alone', () => { + const filtered = filterIndexByIds(INDEX, new Set(['components/schemas/Order.yaml'])); + // Only 'Components' survives: 'GET /tickets' shares the same file but isn't a component + // leaf, so id-matching alone decides its fate, and 'components/schemas/Order.yaml' is not + // its id. + 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', () => { @@ -46,6 +73,7 @@ describe('limitIndexLevel', () => { 'Overview', 'Operations', 'Webhooks', + 'Components', ]); expect(limited.structure[1].nodes).toBeUndefined(); }); diff --git a/packages/cli/src/commands/tree/filter-index.ts b/packages/cli/src/commands/tree/filter-index.ts index 7cf630bc86..2619c1e237 100644 --- a/packages/cli/src/commands/tree/filter-index.ts +++ b/packages/cli/src/commands/tree/filter-index.ts @@ -1,4 +1,21 @@ -import type { ApiIndex, ApiIndexNode } from '@redocly/openapi-core'; +import { COMPONENT_SECTIONS, type ApiIndex, type ApiIndexNode } from '@redocly/openapi-core'; + +const COMPONENT_LEAF_PREFIXES = COMPONENT_SECTIONS.map((section) => `${section}/`); + +// A split component's graph id is the file that defines it (e.g. `components/schemas/Order.yaml`), +// while its index id is semantic (`schemas/Order`) — so a component leaf is also kept when its +// `file` is in the keep set. Operations keep pure id-matching: an unrelated operation that +// happens to live in the same file as a kept one must still be dropped. +function isComponentLeaf(node: ApiIndexNode): boolean { + return COMPONENT_LEAF_PREFIXES.some((prefix) => node.id.startsWith(prefix)); +} + +function isKept(node: ApiIndexNode, keepIds: Set): boolean { + return ( + keepIds.has(node.id) || + (isComponentLeaf(node) && node.file !== undefined && keepIds.has(node.file)) + ); +} export function filterIndexByIds(index: ApiIndex, keepIds: Set): ApiIndex { return { ...index, structure: keepNodes(index.structure, keepIds) }; @@ -8,9 +25,9 @@ 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) { + if (isKept(node, keepIds) && keptChildren.length === 0) { kept.push(node.nodes ? { ...node, nodes: undefined } : node); - } else if (keepIds.has(node.id) || keptChildren.length > 0) { + } else if (isKept(node, keepIds) || keptChildren.length > 0) { kept.push({ ...node, nodes: keptChildren }); } } diff --git a/packages/cli/src/commands/tree/index.ts b/packages/cli/src/commands/tree/index.ts index d2b712b62f..dd98123fd8 100644 --- a/packages/cli/src/commands/tree/index.ts +++ b/packages/cli/src/commands/tree/index.ts @@ -266,6 +266,11 @@ async function handleStructureMode({ 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']); diff --git a/packages/core/src/api-graph/build-index.ts b/packages/core/src/api-graph/build-index.ts index ac8c81a783..d4e4d08617 100644 --- a/packages/core/src/api-graph/build-index.ts +++ b/packages/core/src/api-graph/build-index.ts @@ -14,7 +14,7 @@ export const SUMMARY_LIMIT = 160; const UNTAGGED = 'untagged'; /** OpenAPI component sections, in the order the index lists them. */ -export const COMPONENT_SECTIONS = [ +export const COMPONENT_SECTIONS: readonly string[] = [ 'schemas', 'responses', 'parameters', diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7a79872dee..d1f9612df5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -149,6 +149,7 @@ export { } from './api-graph/build-graph.js'; export { buildApiIndex, + COMPONENT_SECTIONS, SUMMARY_LIMIT, type ApiIndex, type ApiIndexNode, diff --git a/tests/e2e/tree/tree.test.ts b/tests/e2e/tree/tree.test.ts index be42ae22a2..6cd06abc93 100644 --- a/tests/e2e/tree/tree.test.ts +++ b/tests/e2e/tree/tree.test.ts @@ -88,6 +88,18 @@ describe('tree', () => { ); }); + 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 }); 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 + } + ] + } + ] + } + ] +} + From d1d321c445508ff1bd611ad11d2f71cfc77f2b54 Mon Sep 17 00:00:00 2001 From: kanoru Date: Mon, 3 Aug 2026 11:23:17 +0300 Subject: [PATCH 68/79] feat(cli): add --node retrieval and --with-deps closure to tree --- packages/cli/src/commands/tree/index.ts | 47 ++++++ packages/cli/src/index.ts | 11 ++ tests/e2e/tree/index-fixture/openapi.yaml | 58 +++++++ .../e2e/tree/index-json-by-paths/snapshot.txt | 154 ++++++++++++++++++ tests/e2e/tree/index-json/snapshot.txt | 141 ++++++++++++++++ tests/e2e/tree/node-branch/snapshot.txt | 39 +++++ tests/e2e/tree/node-leaf-pointer/snapshot.txt | 17 ++ tests/e2e/tree/node-leaf/snapshot.txt | 17 ++ tests/e2e/tree/node-unknown/snapshot.txt | 3 + tests/e2e/tree/node-with-deps/snapshot.txt | 75 +++++++++ tests/e2e/tree/tree.test.ts | 70 ++++++++ .../uses-json-webhooks-warning/snapshot.txt | 65 ++++++++ 12 files changed, 697 insertions(+) create mode 100644 tests/e2e/tree/index-fixture/openapi.yaml create mode 100644 tests/e2e/tree/index-json-by-paths/snapshot.txt create mode 100644 tests/e2e/tree/index-json/snapshot.txt create mode 100644 tests/e2e/tree/node-branch/snapshot.txt create mode 100644 tests/e2e/tree/node-leaf-pointer/snapshot.txt create mode 100644 tests/e2e/tree/node-leaf/snapshot.txt create mode 100644 tests/e2e/tree/node-unknown/snapshot.txt create mode 100644 tests/e2e/tree/node-with-deps/snapshot.txt create mode 100644 tests/e2e/tree/uses-json-webhooks-warning/snapshot.txt diff --git a/packages/cli/src/commands/tree/index.ts b/packages/cli/src/commands/tree/index.ts index dd98123fd8..c477cff8c0 100644 --- a/packages/cli/src/commands/tree/index.ts +++ b/packages/cli/src/commands/tree/index.ts @@ -1,8 +1,12 @@ import { + appendDepsClosure, BaseResolver, buildApiIndex, + buildNodeEnvelope, detectSpec, + findIndexNode, getTypes, + hasIndexLocation, logger, normalizeTypes, resolveDocument, @@ -43,6 +47,8 @@ export type TreeArgv = { uses?: string[]; files?: boolean; 'group-by': IndexGroupBy; + node?: string; + 'with-deps'?: boolean; } & VerifyConfigOptions; type TreeModeContext = { @@ -62,6 +68,12 @@ export async function handleTree({ argv, config, collectSpecData }: CommandArgs< 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( @@ -215,6 +227,41 @@ async function handleStructureMode({ } 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) { + const subIndex = { ...fullIndex, structure: [indexNode] }; + const limited = + argv.level !== undefined ? limitIndexLevel(subIndex, argv.level + 1) : 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'] }) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 9ed752bdb6..0f72dd8f20 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -109,6 +109,17 @@ yargs(hideBin(process.argv)) 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.', 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/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/tree.test.ts b/tests/e2e/tree/tree.test.ts index 6cd06abc93..7df96b43f7 100644 --- a/tests/e2e/tree/tree.test.ts +++ b/tests/e2e/tree/tree.test.ts @@ -149,4 +149,74 @@ describe('tree', () => { 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. From c1e680a6de311c0289f93b22e4d02817ca9a3360 Mon Sep 17 00:00:00 2001 From: kanoru Date: Mon, 3 Aug 2026 11:23:21 +0300 Subject: [PATCH 69/79] fix(cli): match components by file only when split from the root document --- .../tree/__tests__/filter-index.test.ts | 24 ++++++++++++++++++ .../cli/src/commands/tree/filter-index.ts | 25 ++++++++++++------- 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/commands/tree/__tests__/filter-index.test.ts b/packages/cli/src/commands/tree/__tests__/filter-index.test.ts index 65f941f635..3f33784ad1 100644 --- a/packages/cli/src/commands/tree/__tests__/filter-index.test.ts +++ b/packages/cli/src/commands/tree/__tests__/filter-index.test.ts @@ -64,6 +64,30 @@ describe('filterIndexByIds', () => { const schemas = filtered.structure[0].nodes!.find((node) => node.id === 'components/schemas')!; expect(schemas.nodes!.map((node) => node.id)).toEqual(['schemas/Order']); }); + + it('does not keep an inline component leaf by file — the root document is not a split-out file', () => { + const index: ApiIndex = { + docName: 'openapi.yaml', + spec: 'oas3_0', + structure: [ + { + id: 'Components', + title: 'Components', + nodes: [ + { + id: 'components/schemas', + title: 'schemas', + nodes: [{ id: 'schemas/Pet', title: 'Pet', file: 'openapi.yaml' }], + }, + ], + }, + ], + }; + // keepIds contains the root document's own id (the file every inline node shares), but not + // 'schemas/Pet' itself — before the fix this kept every inline component unconditionally. + const filtered = filterIndexByIds(index, new Set(['openapi.yaml'])); + expect(filtered.structure).toEqual([]); + }); }); describe('limitIndexLevel', () => { diff --git a/packages/cli/src/commands/tree/filter-index.ts b/packages/cli/src/commands/tree/filter-index.ts index 2619c1e237..5525f2da79 100644 --- a/packages/cli/src/commands/tree/filter-index.ts +++ b/packages/cli/src/commands/tree/filter-index.ts @@ -4,30 +4,37 @@ const COMPONENT_LEAF_PREFIXES = COMPONENT_SECTIONS.map((section) => `${section}/ // A split component's graph id is the file that defines it (e.g. `components/schemas/Order.yaml`), // while its index id is semantic (`schemas/Order`) — so a component leaf is also kept when its -// `file` is in the keep set. Operations keep pure id-matching: an unrelated operation that -// happens to live in the same file as a kept one must still be dropped. +// `file` is in the keep set. An inline component's `file` is the root document itself, so that +// fallback must exclude `docName`: otherwise every inline component would be kept as soon as the +// root document is affected, which is true for almost any match. Inline components fall back to +// pure id-matching instead, which the graph already supports for them. Operations always use pure +// id-matching: an unrelated operation that happens to live in the same file as a kept one must +// still be dropped. function isComponentLeaf(node: ApiIndexNode): boolean { return COMPONENT_LEAF_PREFIXES.some((prefix) => node.id.startsWith(prefix)); } -function isKept(node: ApiIndexNode, keepIds: Set): boolean { +function isKept(node: ApiIndexNode, keepIds: Set, docName: string): boolean { return ( keepIds.has(node.id) || - (isComponentLeaf(node) && node.file !== undefined && keepIds.has(node.file)) + (isComponentLeaf(node) && + node.file !== undefined && + node.file !== docName && + keepIds.has(node.file)) ); } export function filterIndexByIds(index: ApiIndex, keepIds: Set): ApiIndex { - return { ...index, structure: keepNodes(index.structure, keepIds) }; + return { ...index, structure: keepNodes(index.structure, keepIds, index.docName) }; } -function keepNodes(nodes: ApiIndexNode[], keepIds: Set): ApiIndexNode[] { +function keepNodes(nodes: ApiIndexNode[], keepIds: Set, docName: string): ApiIndexNode[] { const kept: ApiIndexNode[] = []; for (const node of nodes) { - const keptChildren = node.nodes ? keepNodes(node.nodes, keepIds) : []; - if (isKept(node, keepIds) && keptChildren.length === 0) { + const keptChildren = node.nodes ? keepNodes(node.nodes, keepIds, docName) : []; + if (isKept(node, keepIds, docName) && keptChildren.length === 0) { kept.push(node.nodes ? { ...node, nodes: undefined } : node); - } else if (isKept(node, keepIds) || keptChildren.length > 0) { + } else if (isKept(node, keepIds, docName) || keptChildren.length > 0) { kept.push({ ...node, nodes: keptChildren }); } } From 39fd09582584e93f7738d51ecdb4ca5824071ec2 Mon Sep 17 00:00:00 2001 From: kanoru Date: Mon, 3 Aug 2026 11:42:00 +0300 Subject: [PATCH 70/79] fix(cli): apply --level as-is to --node sub-indexes and add the changeset --- .changeset/tree-agent-index.md | 7 +++++++ packages/cli/src/commands/tree/index.ts | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 .changeset/tree-agent-index.md diff --git a/.changeset/tree-agent-index.md b/.changeset/tree-agent-index.md new file mode 100644 index 0000000000..d8a7568bda --- /dev/null +++ b/.changeset/tree-agent-index.md @@ -0,0 +1,7 @@ +--- +'@redocly/openapi-core': minor +'@redocly/cli': minor +--- + +Added the agent surface to the experimental `tree` command: `--format=json` now prints a hierarchical index of the API description (sections, tags, operations, and components 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 index, retrieval, and dependency-closure engines live in `@redocly/openapi-core`'s `api-graph` module (`analyzeApi`, `buildApiIndex`, `buildNodeEnvelope`, `appendDepsClosure`). diff --git a/packages/cli/src/commands/tree/index.ts b/packages/cli/src/commands/tree/index.ts index c477cff8c0..036cadab91 100644 --- a/packages/cli/src/commands/tree/index.ts +++ b/packages/cli/src/commands/tree/index.ts @@ -243,9 +243,9 @@ async function handleStructureMode({ ); } 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 + 1) : subIndex; + const limited = argv.level !== undefined ? limitIndexLevel(subIndex, argv.level) : subIndex; emitRendered(renderIndexJson(limited), argv); return; } From 4beb2d91665c28b4670ad6b467baa97ec7593a2a Mon Sep 17 00:00:00 2001 From: kanoru Date: Mon, 3 Aug 2026 12:18:13 +0300 Subject: [PATCH 71/79] refactor: consolidate tree changesets, drop interim wrappers, document the agent index --- .changeset/graph-command.md | 5 --- .changeset/tree-agent-index.md | 7 ---- .changeset/tree-command.md | 8 +++++ .changeset/unified-tree-phase1.md | 7 ---- docs/@v2/commands/tree.md | 34 ++++++++++++++++++- .../tree/__tests__/build-structure.test.ts | 18 +++++----- .../cli/src/commands/tree/build-structure.ts | 29 ---------------- packages/cli/src/commands/tree/index.ts | 6 ++-- .../api-graph/__tests__/build-graph.test.ts | 7 ++-- packages/core/src/api-graph/build-graph.ts | 14 +------- packages/core/src/index.ts | 2 -- 11 files changed, 58 insertions(+), 79 deletions(-) delete mode 100644 .changeset/graph-command.md delete mode 100644 .changeset/tree-agent-index.md create mode 100644 .changeset/tree-command.md delete mode 100644 .changeset/unified-tree-phase1.md delete mode 100644 packages/cli/src/commands/tree/build-structure.ts diff --git a/.changeset/graph-command.md b/.changeset/graph-command.md deleted file mode 100644 index 39465609e2..0000000000 --- a/.changeset/graph-command.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@redocly/cli': minor ---- - -Added the `tree` command that displays the structure of an API description — its paths, operations, and component dependency chains. diff --git a/.changeset/tree-agent-index.md b/.changeset/tree-agent-index.md deleted file mode 100644 index d8a7568bda..0000000000 --- a/.changeset/tree-agent-index.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@redocly/openapi-core': minor -'@redocly/cli': minor ---- - -Added the agent surface to the experimental `tree` command: `--format=json` now prints a hierarchical index of the API description (sections, tags, operations, and components 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 index, retrieval, and dependency-closure engines live in `@redocly/openapi-core`'s `api-graph` module (`analyzeApi`, `buildApiIndex`, `buildNodeEnvelope`, `appendDepsClosure`). 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/.changeset/unified-tree-phase1.md b/.changeset/unified-tree-phase1.md deleted file mode 100644 index 60c1a49ddb..0000000000 --- a/.changeset/unified-tree-phase1.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@redocly/openapi-core': minor -'@redocly/cli': minor ---- - -Reworked the experimental `tree` command's structure view to walk the original files instead of a bundled copy: every node now reports the file that defines it, `operationId`s survive `$ref`'d path items, and an unresolvable `$ref` is shown as an unresolved node with a warning instead of failing the command. -The graph model moved to `@redocly/openapi-core` as the new `api-graph` module (`buildApiGraph`), reusable outside the CLI. diff --git a/docs/@v2/commands/tree.md b/docs/@v2/commands/tree.md index 4a2b8e3e46..5728faf832 100644 --- a/docs/@v2/commands/tree.md +++ b/docs/@v2/commands/tree.md @@ -35,13 +35,16 @@ Use `--files` for the multi-API file graph. | 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`. | +| --format | string | Output format: `stylish` (default, tree view), `json` (the machine-readable index, see _The agent index_ below), `mermaid`, or `dot`. | +| --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 `…`. | +| --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 @@ -542,3 +545,32 @@ flowchart LR 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. + +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/packages/cli/src/commands/tree/__tests__/build-structure.test.ts b/packages/cli/src/commands/tree/__tests__/build-structure.test.ts index d51cdc10a7..069ca4bf83 100644 --- a/packages/cli/src/commands/tree/__tests__/build-structure.test.ts +++ b/packages/cli/src/commands/tree/__tests__/build-structure.test.ts @@ -1,4 +1,5 @@ import { + analyzeApi, BaseResolver, createConfig, detectSpec, @@ -9,7 +10,6 @@ import { } from '@redocly/openapi-core'; import * as path from 'node:path'; -import { buildStructureGraph } from '../build-structure.js'; import type { DependencyGraph } from '../types.js'; const CWD = '/project'; @@ -23,22 +23,22 @@ async function structureOf( const specVersion = detectSpec(parsed); const config = await createConfig({}); const types = normalizeTypes(config.extendTypes(getTypes(specVersion), specVersion), config); - const { analysis } = await buildStructureGraph({ + const { graph } = await analyzeApi({ rootDocument, specVersion, types, - config, externalRefResolver, cwd: CWD, + resolveRef: (base, uri) => path.resolve(path.dirname(base), uri), }); - return analysis.graph; + 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('buildStructureGraph', () => { +describe('tree structure graph', () => { it('attaches operationId to operation nodes when defined', async () => { const graph = await structureOf({ openapi: '3.0.0', @@ -487,7 +487,7 @@ describe('buildStructureGraph', () => { }); }); -describe('buildStructureGraph (multi-file parity)', () => { +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 { @@ -497,15 +497,15 @@ describe('buildStructureGraph (multi-file parity)', () => { if (rootDocument instanceof Error) throw rootDocument; const specVersion = detectSpec(rootDocument.parsed); const types = normalizeTypes(config.extendTypes(getTypes(specVersion), specVersion), config); - const { analysis } = await buildStructureGraph({ + const { graph } = await analyzeApi({ rootDocument, specVersion, types, - config, externalRefResolver, cwd: path.dirname(apiPath), + resolveRef: (base, uri) => path.resolve(path.dirname(base), uri), }); - return analysis.graph; + return graph; } it('walks a split multi-file description into real file nodes and cross-file edges', async () => { diff --git a/packages/cli/src/commands/tree/build-structure.ts b/packages/cli/src/commands/tree/build-structure.ts deleted file mode 100644 index 5c775f05c9..0000000000 --- a/packages/cli/src/commands/tree/build-structure.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { - analyzeApi, - type ApiAnalysis, - type BaseResolver, - type Config, - type Document, - type NormalizedNodeType, - type SpecVersion, -} from '@redocly/openapi-core'; - -export async function buildStructureGraph(options: { - rootDocument: Document; - specVersion: SpecVersion; - types: Record; - config: Config; - externalRefResolver: BaseResolver; - cwd: string; -}): Promise<{ analysis: ApiAnalysis }> { - const { rootDocument, specVersion, types, externalRefResolver, cwd } = options; - const analysis = await analyzeApi({ - rootDocument, - specVersion, - types, - externalRefResolver, - cwd, - resolveRef: (base, uri) => externalRefResolver.resolveExternalRef(base, uri), - }); - return { analysis }; -} diff --git a/packages/cli/src/commands/tree/index.ts b/packages/cli/src/commands/tree/index.ts index 036cadab91..6a8eb7eda9 100644 --- a/packages/cli/src/commands/tree/index.ts +++ b/packages/cli/src/commands/tree/index.ts @@ -1,4 +1,5 @@ import { + analyzeApi, appendDepsClosure, BaseResolver, buildApiIndex, @@ -26,7 +27,6 @@ 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 { buildStructureGraph } from './build-structure.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'; @@ -210,13 +210,13 @@ async function handleStructureMode({ externalRefResolver, }); - const { analysis } = await buildStructureGraph({ + const analysis = await analyzeApi({ rootDocument, specVersion, types, - config, externalRefResolver, cwd, + resolveRef: (base, uri) => externalRefResolver.resolveExternalRef(base, uri), }); const graph = analysis.graph; diff --git a/packages/core/src/api-graph/__tests__/build-graph.test.ts b/packages/core/src/api-graph/__tests__/build-graph.test.ts index 2a9bc70525..5eb5ddb57c 100644 --- a/packages/core/src/api-graph/__tests__/build-graph.test.ts +++ b/packages/core/src/api-graph/__tests__/build-graph.test.ts @@ -5,7 +5,7 @@ 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 { buildApiGraph } from '../build-graph.js'; +import { analyzeApi } from '../build-graph.js'; import type { DependencyGraph } from '../types.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -19,7 +19,7 @@ async function graphOfString(yaml: string): Promise { async function graphOfDocument(document: Document, cwd: string): Promise { const specVersion = detectSpec(document.parsed); const types = normalizeTypes(getTypes(specVersion), {}); - return buildApiGraph({ + const { graph } = await analyzeApi({ rootDocument: document, specVersion, types, @@ -27,9 +27,10 @@ async function graphOfDocument(document: Document, cwd: string): Promise join(dirname(base), uri), }); + return graph; } -describe('buildApiGraph', () => { +describe('analyzeApi structure graph', () => { it('builds the root -> path -> operation spine for a single file', async () => { const graph = await graphOfString( [ diff --git a/packages/core/src/api-graph/build-graph.ts b/packages/core/src/api-graph/build-graph.ts index fb03440d67..39313f8c4c 100644 --- a/packages/core/src/api-graph/build-graph.ts +++ b/packages/core/src/api-graph/build-graph.ts @@ -60,18 +60,6 @@ export type ApiAnalysis = { rootDocument: Document; }; -export async function buildApiGraph(options: { - rootDocument: Document; - specVersion: SpecVersion; - types: Record; - externalRefResolver: BaseResolver; - cwd: string; - resolveRef: (base: string, uri: string) => string; -}): Promise { - const { graph } = await analyzeApi(options); - return graph; -} - export async function analyzeApi(options: { rootDocument: Document; specVersion: SpecVersion; @@ -102,7 +90,7 @@ export async function analyzeApi(options: { return { graph, meta, resolvedRefMap, rootDocument }; } -export function walkStructure(options: { +function walkStructure(options: { document: Document; types: Record; resolvedRefMap: ResolvedRefMap; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d1f9612df5..a54fc65968 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -139,9 +139,7 @@ export { export type { DependencyGraph, GraphEdge, GraphNode, NodeKind } from './api-graph/types.js'; export { analyzeApi, - buildApiGraph, collectConnectedIds, - walkStructure, type ApiAnalysis, type ApiIndexMeta, type CollectedComponent, From 74e4b8e90f9d1acd5749e5f0554b23ebb2d3a3c6 Mon Sep 17 00:00:00 2001 From: kanoru Date: Mon, 3 Aug 2026 12:23:28 +0300 Subject: [PATCH 72/79] docs(cli): complete tree usage and options for the agent index flags --- docs/@v2/commands/tree.md | 33 ++++++++++++++++++--------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/docs/@v2/commands/tree.md b/docs/@v2/commands/tree.md index 5728faf832..d319573e54 100644 --- a/docs/@v2/commands/tree.md +++ b/docs/@v2/commands/tree.md @@ -21,6 +21,8 @@ Use `tree` to: 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...] ``` @@ -30,21 +32,22 @@ 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` (the machine-readable index, see _The agent index_ below), `mermaid`, or `dot`. | -| --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 `…`. | -| --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. | +| 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` (the machine-readable index, see _The agent index_ below), `mermaid`, or `dot`. | +| --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 From 26a35f3c8ed6bd10e6bf2ae6dc89c7459c6f42a2 Mon Sep 17 00:00:00 2001 From: kanoru Date: Mon, 3 Aug 2026 19:37:03 +0300 Subject: [PATCH 73/79] feat(core): keep canonical component ids for split whole-file refs --- docs/@v2/commands/tree.md | 5 +- .../tree/__tests__/build-structure.test.ts | 25 +++-- .../tree/__tests__/filter-index.test.ts | 33 +------ .../cli/src/commands/tree/filter-index.ts | 38 ++------ packages/cli/src/commands/tree/index.ts | 2 +- .../api-graph/__tests__/build-graph.test.ts | 37 ++++---- packages/core/src/api-graph/build-graph.ts | 93 ++++++++++++++++++- .../e2e/tree/tree-structure-dot/snapshot.txt | 28 +++--- .../tree/tree-structure-mermaid/snapshot.txt | 32 +++---- .../tree/tree-structure-stylish/snapshot.txt | 28 +++--- .../tree-structure-used-by-file/snapshot.txt | 8 +- .../tree/tree-structure-used-by/snapshot.txt | 14 ++- .../tree-structure-uses-wildcard/snapshot.txt | 17 +++- tests/e2e/tree/tree.test.ts | 2 +- 14 files changed, 207 insertions(+), 155 deletions(-) diff --git a/docs/@v2/commands/tree.md b/docs/@v2/commands/tree.md index d319573e54..61c643e37a 100644 --- a/docs/@v2/commands/tree.md +++ b/docs/@v2/commands/tree.md @@ -305,10 +305,11 @@ cafe.yaml - 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` — in the structure view this addresses a component that lives in its own file; in `--files` mode it addresses the file node. +- 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 -For a multi-file API, components split into their own files are addressed by file path — their `schemas/` ids belong to components defined inline in the root file. +Components split into their own files keep their canonical `schemas/` ids, +so every form above works the same for single-file and multi-file APIs. Examples of the different input forms: diff --git a/packages/cli/src/commands/tree/__tests__/build-structure.test.ts b/packages/cli/src/commands/tree/__tests__/build-structure.test.ts index 069ca4bf83..9cd5fa3bda 100644 --- a/packages/cli/src/commands/tree/__tests__/build-structure.test.ts +++ b/packages/cli/src/commands/tree/__tests__/build-structure.test.ts @@ -516,20 +516,19 @@ describe('tree structure graph (multi-file parity)', () => { expect(nodes).toContainEqual({ id: 'GET /orders', kind: 'operation' }); expect(nodes).toContainEqual({ id: 'POST /orders', kind: 'operation' }); - // The root's `components.schemas.*` aliases only $ref out to a file and have no incoming - // edge on the original document, so they are unreachable and dropped — the real file node - // they point to stands in for the component instead. - expect(graph.nodes.find((node) => node.id === 'schemas/Order')).toBeUndefined(); - expect(graph.nodes.find((node) => node.id === 'schemas/OrderList')).toBeUndefined(); - expect(nodes).toContainEqual({ id: 'components/schemas/Order.yaml', kind: 'file' }); - expect(nodes).toContainEqual({ id: 'components/schemas/OrderList.yaml', kind: 'file' }); - - // Transitive component-to-component chains survive across files. + // 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 === 'components/schemas/Order.yaml' && edge.to.startsWith('components/schemas/') - ) + graph.edges.some((edge) => edge.from === 'schemas/Order' && edge.to.startsWith('schemas/')) ).toBe(true); }); }); diff --git a/packages/cli/src/commands/tree/__tests__/filter-index.test.ts b/packages/cli/src/commands/tree/__tests__/filter-index.test.ts index 3f33784ad1..028a1df767 100644 --- a/packages/cli/src/commands/tree/__tests__/filter-index.test.ts +++ b/packages/cli/src/commands/tree/__tests__/filter-index.test.ts @@ -55,39 +55,14 @@ describe('filterIndexByIds', () => { ]); }); - it('keeps a split component leaf by its file id, and never keeps an operation by file alone', () => { - const filtered = filterIndexByIds(INDEX, new Set(['components/schemas/Order.yaml'])); - // Only 'Components' survives: 'GET /tickets' shares the same file but isn't a component - // leaf, so id-matching alone decides its fate, and 'components/schemas/Order.yaml' is not - // its id. + 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']); }); - - it('does not keep an inline component leaf by file — the root document is not a split-out file', () => { - const index: ApiIndex = { - docName: 'openapi.yaml', - spec: 'oas3_0', - structure: [ - { - id: 'Components', - title: 'Components', - nodes: [ - { - id: 'components/schemas', - title: 'schemas', - nodes: [{ id: 'schemas/Pet', title: 'Pet', file: 'openapi.yaml' }], - }, - ], - }, - ], - }; - // keepIds contains the root document's own id (the file every inline node shares), but not - // 'schemas/Pet' itself — before the fix this kept every inline component unconditionally. - const filtered = filterIndexByIds(index, new Set(['openapi.yaml'])); - expect(filtered.structure).toEqual([]); - }); }); describe('limitIndexLevel', () => { diff --git a/packages/cli/src/commands/tree/filter-index.ts b/packages/cli/src/commands/tree/filter-index.ts index 5525f2da79..8db14ebc35 100644 --- a/packages/cli/src/commands/tree/filter-index.ts +++ b/packages/cli/src/commands/tree/filter-index.ts @@ -1,40 +1,18 @@ -import { COMPONENT_SECTIONS, type ApiIndex, type ApiIndexNode } from '@redocly/openapi-core'; - -const COMPONENT_LEAF_PREFIXES = COMPONENT_SECTIONS.map((section) => `${section}/`); - -// A split component's graph id is the file that defines it (e.g. `components/schemas/Order.yaml`), -// while its index id is semantic (`schemas/Order`) — so a component leaf is also kept when its -// `file` is in the keep set. An inline component's `file` is the root document itself, so that -// fallback must exclude `docName`: otherwise every inline component would be kept as soon as the -// root document is affected, which is true for almost any match. Inline components fall back to -// pure id-matching instead, which the graph already supports for them. Operations always use pure -// id-matching: an unrelated operation that happens to live in the same file as a kept one must -// still be dropped. -function isComponentLeaf(node: ApiIndexNode): boolean { - return COMPONENT_LEAF_PREFIXES.some((prefix) => node.id.startsWith(prefix)); -} - -function isKept(node: ApiIndexNode, keepIds: Set, docName: string): boolean { - return ( - keepIds.has(node.id) || - (isComponentLeaf(node) && - node.file !== undefined && - node.file !== docName && - keepIds.has(node.file)) - ); -} +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, index.docName) }; + return { ...index, structure: keepNodes(index.structure, keepIds) }; } -function keepNodes(nodes: ApiIndexNode[], keepIds: Set, docName: string): ApiIndexNode[] { +function keepNodes(nodes: ApiIndexNode[], keepIds: Set): ApiIndexNode[] { const kept: ApiIndexNode[] = []; for (const node of nodes) { - const keptChildren = node.nodes ? keepNodes(node.nodes, keepIds, docName) : []; - if (isKept(node, keepIds, docName) && keptChildren.length === 0) { + 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 (isKept(node, keepIds, docName) || keptChildren.length > 0) { + } else if (keepIds.has(node.id) || keptChildren.length > 0) { kept.push({ ...node, nodes: keptChildren }); } } diff --git a/packages/cli/src/commands/tree/index.ts b/packages/cli/src/commands/tree/index.ts index 6a8eb7eda9..223940f88f 100644 --- a/packages/cli/src/commands/tree/index.ts +++ b/packages/cli/src/commands/tree/index.ts @@ -118,7 +118,7 @@ async function loadApi({ if (rootDocument instanceof Error) { return exitWithError(`Failed to load ${apiPath}: ${rootDocument.message}`); } - collectSpecData?.(rootDocument.parsed); + collectSpecData?.(rootDocument); const specVersion = detectSpec(rootDocument.parsed); const types = normalizeTypes(config.extendTypes(getTypes(specVersion), specVersion), config); return { rootDocument, specVersion, types }; diff --git a/packages/core/src/api-graph/__tests__/build-graph.test.ts b/packages/core/src/api-graph/__tests__/build-graph.test.ts index 5eb5ddb57c..18b69c6b6b 100644 --- a/packages/core/src/api-graph/__tests__/build-graph.test.ts +++ b/packages/core/src/api-graph/__tests__/build-graph.test.ts @@ -96,29 +96,30 @@ describe('analyzeApi structure graph', () => { file: 'paths/tickets.yaml', }); - // The root's `components.schemas.Ticket` alias has no INCOMING edge on the original - // document (the operation's $ref points straight at the file), so the graph drops it - // as unreachable — the real file node replaces it. Phase 2's index view restores - // semantic component names from the Named* visitors. - expect(graph.nodes.find((node) => node.id === 'schemas/Ticket')).toBeUndefined(); - - const schemaFile = graph.nodes.find((node) => node.id === 'components/schemas/Ticket.yaml'); - expect(schemaFile).toMatchObject({ kind: 'file', resolved: true }); - - const pathItemFile = graph.nodes.find((node) => node.id === 'paths/tickets.yaml'); - expect(pathItemFile).toMatchObject({ kind: 'file', resolved: true }); + // 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 === '/tickets' && edge.to === 'paths/tickets.yaml') + graph.edges.some((edge) => edge.from === 'POST /tickets' && edge.to === 'schemas/Ticket') ).toBe(true); - // The operation's response schema $ref lives directly in the operation's own file, so its - // owner is the operation itself, not the file — matching the old bundled walk, where this - // ref's owner was the operation. (A ref found after hopping into a further file, e.g. a - // component schema referencing another schema, would still collapse to the file — that - // case isn't exercised by this fixture.) + // TicketId.yaml has no root alias, so it stays a plain file node behind the schema. expect( graph.edges.some( - (edge) => edge.from === 'POST /tickets' && edge.to === 'components/schemas/Ticket.yaml' + (edge) => edge.from === 'schemas/Ticket' && edge.to === 'components/schemas/TicketId.yaml' ) ).toBe(true); expect( diff --git a/packages/core/src/api-graph/build-graph.ts b/packages/core/src/api-graph/build-graph.ts index 39313f8c4c..a7fde53695 100644 --- a/packages/core/src/api-graph/build-graph.ts +++ b/packages/core/src/api-graph/build-graph.ts @@ -7,6 +7,7 @@ import { 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'; @@ -107,6 +108,16 @@ function walkStructure(options: { 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; @@ -125,10 +136,20 @@ function walkStructure(options: { edges.set(edgeKey, edge); }; - const mapToNode = (absoluteRef: string, pointer: string): MappedNode & { file: string } => - absoluteRef === rootAbs - ? { ...mapRootPointer(pointer, rootId), file: rootId } - : mapForeignLocation(toNodeId(absoluteRef, cwd), pointer); + 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); @@ -336,6 +357,21 @@ function walkStructure(options: { }, 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 && @@ -368,6 +404,55 @@ function walkStructure(options: { 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, diff --git a/tests/e2e/tree/tree-structure-dot/snapshot.txt b/tests/e2e/tree/tree-structure-dot/snapshot.txt index eaddccda62..090e06319e 100644 --- a/tests/e2e/tree/tree-structure-dot/snapshot.txt +++ b/tests/e2e/tree/tree-structure-dot/snapshot.txt @@ -5,28 +5,24 @@ digraph tree { "GET /orders"; "GET /orders/{orderId}"; "POST /orders"; - "components/schemas/Error.yaml"; - "components/schemas/MenuItem.yaml"; - "components/schemas/Order.yaml"; - "components/schemas/OrderList.yaml"; - "components/schemas/OrderStatus.yaml"; "openapi.yaml" [shape=box, style=bold]; - "paths/orders.yaml"; - "paths/orders_{orderId}.yaml"; + "schemas/Error"; + "schemas/MenuItem"; + "schemas/Order"; + "schemas/OrderList"; + "schemas/OrderStatus"; "/orders" -> "GET /orders"; "/orders" -> "POST /orders"; - "/orders" -> "paths/orders.yaml"; "/orders/{orderId}" -> "DELETE /orders/{orderId}"; "/orders/{orderId}" -> "GET /orders/{orderId}"; - "/orders/{orderId}" -> "paths/orders_{orderId}.yaml"; - "DELETE /orders/{orderId}" -> "components/schemas/Error.yaml"; - "GET /orders" -> "components/schemas/OrderList.yaml"; - "GET /orders/{orderId}" -> "components/schemas/Order.yaml"; - "POST /orders" -> "components/schemas/Order.yaml"; - "components/schemas/Order.yaml" -> "components/schemas/MenuItem.yaml"; - "components/schemas/Order.yaml" -> "components/schemas/OrderStatus.yaml"; - "components/schemas/OrderList.yaml" -> "components/schemas/Order.yaml"; + "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-mermaid/snapshot.txt b/tests/e2e/tree/tree-structure-mermaid/snapshot.txt index 54382f9c87..1a6e96a5a4 100644 --- a/tests/e2e/tree/tree-structure-mermaid/snapshot.txt +++ b/tests/e2e/tree/tree-structure-mermaid/snapshot.txt @@ -5,28 +5,24 @@ flowchart LR n3["GET /orders"] n4["GET /orders/{orderId}"] n5["POST /orders"] - n6["components/schemas/Error.yaml"] - n7["components/schemas/MenuItem.yaml"] - n8["components/schemas/Order.yaml"] - n9["components/schemas/OrderList.yaml"] - n10["components/schemas/OrderStatus.yaml"] - n11["openapi.yaml"]:::root - n12["paths/orders.yaml"] - n13["paths/orders_{orderId}.yaml"] + n6["openapi.yaml"]:::root + n7["schemas/Error"] + n8["schemas/MenuItem"] + n9["schemas/Order"] + n10["schemas/OrderList"] + n11["schemas/OrderStatus"] n0 --> n3 n0 --> n5 - n0 --> n12 n1 --> n2 n1 --> n4 - n1 --> n13 - n2 --> n6 - n3 --> n9 - n4 --> n8 - n5 --> n8 - n8 --> n7 - n8 --> n10 + n2 --> n7 + n3 --> n10 + n4 --> n9 + n5 --> n9 + n6 --> n0 + n6 --> n1 n9 --> n8 - n11 --> n0 - n11 --> n1 + n9 --> n11 + n10 --> n9 classDef root font-weight:bold diff --git a/tests/e2e/tree/tree-structure-stylish/snapshot.txt b/tests/e2e/tree/tree-structure-stylish/snapshot.txt index beca3cf47d..73088b4509 100644 --- a/tests/e2e/tree/tree-structure-stylish/snapshot.txt +++ b/tests/e2e/tree/tree-structure-stylish/snapshot.txt @@ -1,21 +1,19 @@ openapi.yaml ├── /orders │ ├── GET -│ │ └── components/schemas/OrderList.yaml -│ │ └── components/schemas/Order.yaml -│ │ ├── components/schemas/MenuItem.yaml -│ │ └── components/schemas/OrderStatus.yaml -│ ├── POST -│ │ └── components/schemas/Order.yaml -│ │ ├── components/schemas/MenuItem.yaml -│ │ └── components/schemas/OrderStatus.yaml -│ └── paths/orders.yaml +│ │ └── schemas/OrderList +│ │ └── schemas/Order +│ │ ├── schemas/MenuItem +│ │ └── schemas/OrderStatus +│ └── POST +│ └── schemas/Order +│ ├── schemas/MenuItem +│ └── schemas/OrderStatus └── /orders/{orderId} ├── DELETE - │ └── components/schemas/Error.yaml - ├── GET - │ └── components/schemas/Order.yaml - │ ├── components/schemas/MenuItem.yaml - │ └── components/schemas/OrderStatus.yaml - └── paths/orders_{orderId}.yaml + │ └── 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 index e77c6e7991..bec97cf1ba 100644 --- a/tests/e2e/tree/tree-structure-used-by-file/snapshot.txt +++ b/tests/e2e/tree/tree-structure-used-by-file/snapshot.txt @@ -1,13 +1,13 @@ openapi.yaml ├── /orders │ ├── GET -│ │ └── components/schemas/OrderList.yaml -│ │ └── components/schemas/Order.yaml +│ │ └── schemas/OrderList +│ │ └── schemas/Order │ └── POST -│ └── components/schemas/Order.yaml +│ └── schemas/Order └── /orders/{orderId} └── GET - └── components/schemas/Order.yaml + └── schemas/Order 3 of 4 operations affected · affected paths: /orders, /orders/{orderId} diff --git a/tests/e2e/tree/tree-structure-used-by/snapshot.txt b/tests/e2e/tree/tree-structure-used-by/snapshot.txt index ea0103b5d6..bec97cf1ba 100644 --- a/tests/e2e/tree/tree-structure-used-by/snapshot.txt +++ b/tests/e2e/tree/tree-structure-used-by/snapshot.txt @@ -1,3 +1,13 @@ -No nodes affected. +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} -#/components/schemas/Order does not match any path, operation, or component of openapi.yaml. diff --git a/tests/e2e/tree/tree-structure-uses-wildcard/snapshot.txt b/tests/e2e/tree/tree-structure-uses-wildcard/snapshot.txt index ff7320fe06..29afd451fe 100644 --- a/tests/e2e/tree/tree-structure-uses-wildcard/snapshot.txt +++ b/tests/e2e/tree/tree-structure-uses-wildcard/snapshot.txt @@ -1,3 +1,16 @@ -No nodes affected. +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} -schemas/Order* does not match any path, operation, or component of openapi.yaml. diff --git a/tests/e2e/tree/tree.test.ts b/tests/e2e/tree/tree.test.ts index 7df96b43f7..b31808b258 100644 --- a/tests/e2e/tree/tree.test.ts +++ b/tests/e2e/tree/tree.test.ts @@ -56,7 +56,7 @@ describe('tree', () => { ); }); - test('tree reports no matches for a component pointer split into its own file', async () => { + test('tree shows what a component pointer is used by', async () => { const args = getParams(indexEntryPoint, [ 'tree', 'openapi.yaml', From f5257d1b0626b758ba17137715ff295ec6794712 Mon Sep 17 00:00:00 2001 From: kanoru Date: Mon, 3 Aug 2026 20:54:25 +0300 Subject: [PATCH 74/79] docs: add a guide measuring agent context savings from the tree index --- docs/@v2/guides/tree-agent-index-benchmark.md | 208 ++++++++++++++++++ docs/@v2/v2.sidebars.yaml | 2 + 2 files changed, 210 insertions(+) create mode 100644 docs/@v2/guides/tree-agent-index-benchmark.md 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..d7fe86b7e0 --- /dev/null +++ b/docs/@v2/guides/tree-agent-index-benchmark.md @@ -0,0 +1,208 @@ +# 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 what that saves on a real, production-grade description, and shows what the agent sees at each step. +For the command reference, see [`tree`](../commands/tree.md). + +All numbers are actual counts from real command output, tokenized with a BPE tokenizer (`gpt-tokenizer`, o200k family; other model families tokenize slightly differently, with the same order of magnitude). +The description used here is anonymized as `test.yaml`. + +## The setup + +- **Description:** `test.yaml` — 1.3 MB, OpenAPI 3.x, ~130 tags, hundreds of operations, deep shared schemas. +- **Agent task:** _"Generate a typed client call for `GET /customers/{id}`."_ +- **Agent constraints:** a 200k-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 1% of any 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 | +| ----------------------- | ----------: | +| `test.yaml`, whole file | **267,739** | + +At 267,739 tokens the file does not fit into the 200,000-token window, so reading it whole is not an option. +Searching the file by text instead is unreliable: it does not reveal the structure, does not follow `$ref` chains across files, and gives no bound on how much context the agent ends up reading. + +## 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 | `redocly tree test.yaml --format=json --level 2` | 53 KB | 12,652 | +| 2. Open the branch it picked | `redocly tree test.yaml --node Customers` | 5.5 KB | 1,407 | +| 3. Fetch the target with its full `$ref` closure | `redocly tree test.yaml --node 'GET /customers/{id}' --with-deps` | 43.5 KB | 10,508 | +| **Total** | | | **24,567** | + +Step 3 returns a _self-contained_ slice: the operation's raw source lines plus all 31 schemas it transitively references, in dependency order — everything needed to write the client call, nothing else. +The 64 KB closure cap was not even reached. + +A leaner variant, when the task text already names the area (an agent can guess the `Customers` branch from step 1's section list alone): + +| Step | Command | Tokens | +| ---------------------- | ------------------------------------------ | ---------: | +| 1. Sections only | `--format=json --level 1` | 429 | +| 2. Branch | `--node Customers` | 1,407 | +| 3. Target with closure | `--node 'GET /customers/{id}' --with-deps` | 10,508 | +| **Total** | | **12,344** | + +## What the index actually looks like + +Step 1 on the 1.3 MB spec — the whole map of the API in 429 tokens: + +```json +{ + "docName": "test.yaml", + "spec": "oas3_1", + "docDescription": "Core APIs — The API is built on HTTP and is RESTful. It has predictable resource URLs…", + "structure": [ + { + "id": "Overview", + "title": "Overview", + "pointer": "#/info", + "file": "test.yaml", + "start_line": 3, + "end_line": 162, + "summary": "# Introduction … predictable resource URLs and returns HTTP response codes…" + }, + { + "id": "Servers", + "title": "Servers", + "pointer": "#/servers", + "summary": "https://api-sandbox.example.com/…, https://api.example.com/…" + }, + { + "id": "Operations", + "title": "Operations", + "pointer": "#/paths", + "start_line": 23126, + "end_line": 38861 + }, + { + "id": "Webhooks", + "title": "Webhooks", + "pointer": "#/webhooks", + "start_line": 38863, + "end_line": 40136 + }, + { + "id": "Components", + "title": "Components", + "pointer": "#/components", + "start_line": 192, + "end_line": 21955 + } + ] +} +``` + +Step 2 opens one branch — the tag the agent picked, with its operations: + +```json +{ + "structure": [ + { + "id": "Customers", + "title": "Customers", + "pointer": "#/tags/16", + "file": "test.yaml", + "start_line": 22205, + "end_line": 22224, + "summary": "Use these operations to manage customers. A customer is an entity that purchases goods or services…", + "nodes": [ + { + "id": "GET /customers", + "title": "GET /customers — Retrieve customers", + "operationId": "GetCustomerCollection", + "pointer": "#/paths/~1customers/get", + "file": "test.yaml", + "start_line": 25755, + "end_line": 25845, + "summary": "Retrieve customers" + }, + { + "id": "GET /customers/{id}", + "title": "GET /customers/{id} — Retrieve a customer", + "operationId": "GetCustomer", + "pointer": "#/paths/~1customers~1{id}/get", + "file": "test.yaml", + "start_line": 25990, + "end_line": 26031, + "summary": "Retrieve a customer" + } + ] + } + ] +} +``` + +Step 3 returns the leaf envelope: raw source lines, the `$ref`s found inside them resolved to real files, and the closure under `deps`: + +```json +{ + "id": "GET /customers/{id}", + "pointer": "#/get", + "file": "paths/customers_{id}.yaml", + "start_line": 4, + "end_line": 42, + "content": " tags:\n - Customers\n summary: Retrieve a customer\n operationId: GetCustomer\n parameters:\n - $ref: ../components/parameters/collectionExpand.yaml\n responses:\n '200':\n content:\n application/json:\n schema:\n $ref: ../components/schemas/Customer.yaml\n '404':\n $ref: ../components/responses/NotFound.yaml", + "refs": [ + { + "ref": "../components/schemas/Customer.yaml", + "resolved": true, + "file": "components/schemas/Customer.yaml", + "pointer": "#/" + }, + { + "ref": "../components/responses/NotFound.yaml", + "resolved": true, + "file": "components/responses/NotFound.yaml", + "pointer": "#/" + } + ], + "deps": [ + { "id": "schemas/Customer", "file": "components/schemas/Customer.yaml", "content": "…" }, + { "id": "responses/NotFound", "file": "components/responses/NotFound.yaml", "content": "…" } + ] +} +``` + +## The same task on a split (multi-file) layout + +The same spec was run through `redocly split`, producing **1,002 files** (`paths/`, `components/`, `webhooks/`, `code_samples/`), and the identical chain was repeated against `openapi.yaml` in that directory: + +| Step | Single file | Split (1,002 files) | +| --------------------------------------------- | ----------: | ------------------: | +| 1. `--format=json --level 1` | 429 | 417 | +| 2. `--node Customers` | 1,407 | 1,317 | +| 3. `--node 'GET /customers/{id}' --with-deps` | 10,508 | 8,823 | +| **Chain total** | **12,344** | **10,557** | + +Two things to note. +Node ids are identical in both layouts (`GET /customers/{id}`, `schemas/Customer`), because components split into their own files keep their canonical `section/Name` ids — so the same agent instructions and the same commands work unchanged. +The split chain is slightly _cheaper_, since pointers inside small files are short. +The closure in step 3 pulled content from **34 distinct files** and returned them as one envelope — the case where an agent without an index would have to hand-walk `$ref`s across a thousand-file tree without knowing which ones matter. + +## The difference + +| | Tokens | vs. whole file | +| ------------------------------- | ------------------------: | ---------------: | +| Whole file | 267,739 | — (does not fit) | +| Index chain, standard | 24,567 (+114 instruction) | **~11× less** | +| Index chain, lean | 12,344 (+114 instruction) | **~22× less** | +| Index chain on the split layout | 10,557 (+114 instruction) | **~25× 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 42 KB description the same chain costs about 4,000 tokens: the description grew 31 times (42 KB to 1.3 MB), the chain grew 3 to 6 times. +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. + +## 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 `countTokens()` in `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 (never by truncating values), with names replaced by `test.yaml` and `example.com`. +- 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 3 seconds for 1.3 MB; the split layout adds file reads across 1,002 files). 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 9e3f7a8685..7ccf4923d6 100644 --- a/docs/@v2/v2.sidebars.yaml +++ b/docs/@v2/v2.sidebars.yaml @@ -68,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 From b616f1c6b943d77db83968d48dbd4ae0070f626f Mon Sep 17 00:00:00 2001 From: kanoru Date: Mon, 3 Aug 2026 21:11:00 +0300 Subject: [PATCH 75/79] docs: state that the JSON index requires an OpenAPI description --- docs/@v2/commands/tree.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/@v2/commands/tree.md b/docs/@v2/commands/tree.md index 61c643e37a..fc7249a886 100644 --- a/docs/@v2/commands/tree.md +++ b/docs/@v2/commands/tree.md @@ -37,7 +37,7 @@ Use `--files` for the multi-API file graph. | 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` (the machine-readable index, see _The agent index_ below), `mermaid`, or `dot`. | +| --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 `…`. | @@ -555,6 +555,8 @@ flowchart LR 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, 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. From e248d686323a2a51613452fa94f38e1fa97bafa2 Mon Sep 17 00:00:00 2001 From: kanoru Date: Tue, 4 Aug 2026 16:01:09 +0300 Subject: [PATCH 76/79] docs: add Google Compute and GitHub scaling cases to the tree index benchmark --- docs/@v2/guides/tree-agent-index-benchmark.md | 64 ++++++++++++++++++- 1 file changed, 61 insertions(+), 3 deletions(-) diff --git a/docs/@v2/guides/tree-agent-index-benchmark.md b/docs/@v2/guides/tree-agent-index-benchmark.md index d7fe86b7e0..f6e566c85b 100644 --- a/docs/@v2/guides/tree-agent-index-benchmark.md +++ b/docs/@v2/guides/tree-agent-index-benchmark.md @@ -1,11 +1,11 @@ # 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 what that saves on a real, production-grade description, and shows what the agent sees at each step. +This guide measures what that saves on three real descriptions — from a 1.3 MB production API to the 9.8 MB GitHub REST API — and shows what the agent sees at each step. For the command reference, see [`tree`](../commands/tree.md). All numbers are actual counts from real command output, tokenized with a BPE tokenizer (`gpt-tokenizer`, o200k family; other model families tokenize slightly differently, with the same order of magnitude). -The description used here is anonymized as `test.yaml`. +The main walkthrough uses a production description anonymized as `test.yaml`; the scaling section at the end repeats the experiment on two public descriptions, named openly. ## The setup @@ -199,10 +199,68 @@ The chain's cost is bounded by the _largest branch_ and the _deepest single clos On a 42 KB description the same chain costs about 4,000 tokens: the description grew 31 times (42 KB to 1.3 MB), the chain grew 3 to 6 times. 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. +## Scaling up: Google Compute Engine and the GitHub REST API + +The 1.3 MB description above is large, but public API catalogs go much further. +To see how the chain behaves as descriptions grow, the same experiment ran on two public descriptions, unmodified: + +- **Google Compute Engine API v1** — 3.5 MB, OpenAPI 3.0. + Google publishes its APIs in its own Discovery format rather than OpenAPI, so this is the [APIs.guru](https://apis.guru/) OpenAPI conversion of the official Discovery document — the real Compute Engine API surface, mechanically converted. +- **GitHub REST API** — 9.8 MB, OpenAPI 3.0.3. + The official first-party description from [`github/rest-api-description`](https://github.com/github/rest-api-description), the same file GitHub's own SDKs are generated from, and the largest well-known public OpenAPI description. + +### Google Compute Engine: 3.5 MB, 730k tokens + +The whole file is **730,154 tokens** — three and a half 200k windows. +The full unfiltered index is 157,149 tokens (1,437 nodes), so the agent starts from the level-2 map instead. + +Task: _"Create a VM instance."_ + +| Step | Command | Output size | Tokens | +| ----------------------------------------------- | ---------------------------------------------------------------------- | ----------: | ---------: | +| 1. Map the spec (4 sections, 90 tags) | `--format=json --level 2` | 22.5 KB | 5,635 | +| 2. Open the `instances` branch (~40 operations) | `--node instances` | 28.7 KB | 7,040 | +| 3. The insert operation with its closure | `--node 'POST /projects/{project}/zones/{zone}/instances' --with-deps` | 81.3 KB | 17,695 | +| **Total** | | | **30,370** | + +Step 3 is where the 64 KB closure cap earns its keep: the `Instance` schema fans out across the whole description, and the closure delivered the **31 nearest schemas** (`Instance`, `AttachedDisk`, `Scheduling`, …) in dependency order, filling 61.9 KB of the 64 KB budget. +Anything deeper stays one `--node` call away instead of flooding the response. + +### GitHub REST API: 9.8 MB, 1.9M tokens + +The whole file is **1,946,991 tokens** — nearly ten 200k windows; it does not fit even a 1M-token window. +And this is the scale where the full index stops fitting too: `--format=json` with no filters produces 306,525 tokens (3,038 nodes), more than the window itself. +Hierarchical drill-down is no longer an optimization here — it is the only way an agent can work with this file at all. + +Task: _"Create a repository for the authenticated user."_ + +| Step | Command | Output size | Tokens | +| ---------------------------------------------- | --------------------------------------- | ----------: | ---------: | +| 1. Map the spec (47 tags) | `--format=json --level 2` | 15.1 KB | 3,678 | +| 2. Open the `repos` branch — the API's largest | `--node repos` | 103.4 KB | 27,017 | +| 3. The create operation with its closure | `--node 'POST /user/repos' --with-deps` | 82.4 KB | 18,946 | +| **Total** | | | **49,641** | + +The closure again filled its budget almost exactly — 63.6 KB of 64 KB — but with only **14 schemas** this time: GitHub's schemas are individually much larger than Google's, so fewer of them fit the same bound. +The bound is what matters: the response stays predictable regardless of how heavy the schema graph is. + +### The curve across all three + +| Description | Size | Whole file (tokens) | Chain (+114 instruction) | vs. whole file | +| ------------------------------------ | -----: | ------------------: | -----------------------: | -------------: | +| `test.yaml` (production, anonymized) | 1.3 MB | 267,739 | 10,557–24,567 | 11–25× | +| Google Compute Engine v1 | 3.5 MB | 730,154 | 30,370 | ~24× | +| GitHub REST API | 9.8 MB | 1,946,991 | 49,641 | ~39× | + +The description grew 7.5 times (1.3 MB to 9.8 MB); the chain grew 2 times (24.6k to 49.6k tokens). +The multiplier keeps growing with size because the chain pays for a _path_ through the tree — one map, one branch, one closure — while reading the file pays for everything. +And past a certain size the comparison stops being about savings at all: at 1.3 MB the whole file misses a 200k window by a third, at 9.8 MB it misses it ten times over and even the index alone no longer fits — yet the drill-down chain still lands at a quarter of the window, with room to work. + ## 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 `countTokens()` in `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 (never by truncating values), with names replaced by `test.yaml` and `example.com`. +- The Google and GitHub descriptions are public, so they are named and used unmodified: the Compute Engine file comes from APIs.guru (`googleapis.com/compute/v1`, converted from Google's official Discovery document), the GitHub file from the `main` branch of [`github/rest-api-description`](https://github.com/github/rest-api-description) (`api.github.com.yaml`, version 1.1.4). - 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 3 seconds for 1.3 MB; the split layout adds file reads across 1,002 files). A long-running process that keeps the analysis in memory would pay that cost once per session instead of once per step. +- Each command invocation analyzes the description again (about 3 seconds for 1.3 MB, 42 seconds for 9.8 MB; the split layout adds file reads across 1,002 files). A long-running process that keeps the analysis in memory would pay that cost once per session instead of once per step. From db6927146dc4ee400079bb83729deed8dfd1e63a Mon Sep 17 00:00:00 2001 From: kanoru Date: Tue, 4 Aug 2026 17:50:35 +0300 Subject: [PATCH 77/79] docs: list the tree benchmark guide on the guides index --- docs/@v2/commands/tree.md | 2 +- docs/@v2/guides/index.md | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/@v2/commands/tree.md b/docs/@v2/commands/tree.md index fc7249a886..9282e20e4e 100644 --- a/docs/@v2/commands/tree.md +++ b/docs/@v2/commands/tree.md @@ -556,7 +556,7 @@ 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, see [Agent context savings with tree](../guides/tree-agent-index-benchmark.md). +For a measured comparison of how much context this saves — on descriptions up to the 9.8 MB GitHub REST API, 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. diff --git a/docs/@v2/guides/index.md b/docs/@v2/guides/index.md index dc22d0b87b..841113d1dd 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 an API description with the `tree` index instead of reading the whole file, up to GitHub's 9.8 MB description. +{% /card %} + {% card title="Set up tab completion" to="./autocomplete" %} From 55bd50617d29e17957ac8ed784ef73eb108afc52 Mon Sep 17 00:00:00 2001 From: kanoru Date: Tue, 4 Aug 2026 18:06:30 +0300 Subject: [PATCH 78/79] fix(core): take the tree index Servers section from the root server list --- .../src/api-graph/__tests__/build-index.test.ts | 7 +++++++ .../fixtures/server-overrides/openapi.yaml | 15 +++++++++++++++ packages/core/src/api-graph/build-graph.ts | 2 ++ 3 files changed, 24 insertions(+) create mode 100644 packages/core/src/api-graph/__tests__/fixtures/server-overrides/openapi.yaml diff --git a/packages/core/src/api-graph/__tests__/build-index.test.ts b/packages/core/src/api-graph/__tests__/build-index.test.ts index 62324fe1c2..87f08b84dc 100644 --- a/packages/core/src/api-graph/__tests__/build-index.test.ts +++ b/packages/core/src/api-graph/__tests__/build-index.test.ts @@ -78,6 +78,13 @@ describe('buildApiIndex', () => { 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')); 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/build-graph.ts b/packages/core/src/api-graph/build-graph.ts index a7fde53695..70f72a6dca 100644 --- a/packages/core/src/api-graph/build-graph.ts +++ b/packages/core/src/api-graph/build-graph.ts @@ -249,6 +249,8 @@ function walkStructure(options: { // 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, From ad2690a1ea43e9478d10ba899b8ffadeefc237a3 Mon Sep 17 00:00:00 2001 From: kanoru Date: Tue, 4 Aug 2026 18:06:39 +0300 Subject: [PATCH 79/79] docs: rebuild the tree index benchmark on the GitHub REST API description --- docs/@v2/commands/tree.md | 5 +- docs/@v2/guides/index.md | 2 +- docs/@v2/guides/tree-agent-index-benchmark.md | 295 ++++++++---------- 3 files changed, 126 insertions(+), 176 deletions(-) diff --git a/docs/@v2/commands/tree.md b/docs/@v2/commands/tree.md index 9282e20e4e..8f19d0c16c 100644 --- a/docs/@v2/commands/tree.md +++ b/docs/@v2/commands/tree.md @@ -308,8 +308,9 @@ cafe.yaml - 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 split into their own files keep their canonical `schemas/` ids, +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: @@ -556,7 +557,7 @@ 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 descriptions up to the 9.8 MB GitHub REST API, where the whole file is 1.9 million tokens — see [Agent context savings with tree](../guides/tree-agent-index-benchmark.md). +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. diff --git a/docs/@v2/guides/index.md b/docs/@v2/guides/index.md index 841113d1dd..c7667f8ee5 100644 --- a/docs/@v2/guides/index.md +++ b/docs/@v2/guides/index.md @@ -100,7 +100,7 @@ Pre-configure publisher defaults and write custom client generators. {% card title="Agent context savings with tree" to="./tree-agent-index-benchmark" %} -Measured token counts for exploring an API description with the `tree` index instead of reading the whole file, up to GitHub's 9.8 MB description. +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" diff --git a/docs/@v2/guides/tree-agent-index-benchmark.md b/docs/@v2/guides/tree-agent-index-benchmark.md index f6e566c85b..f656483404 100644 --- a/docs/@v2/guides/tree-agent-index-benchmark.md +++ b/docs/@v2/guides/tree-agent-index-benchmark.md @@ -1,136 +1,138 @@ # 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 what that saves on three real descriptions — from a 1.3 MB production API to the 9.8 MB GitHub REST API — and shows what the agent sees at each step. +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). -All numbers are actual counts from real command output, tokenized with a BPE tokenizer (`gpt-tokenizer`, o200k family; other model families tokenize slightly differently, with the same order of magnitude). -The main walkthrough uses a production description anonymized as `test.yaml`; the scaling section at the end repeats the experiment on two public descriptions, named openly. +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:** `test.yaml` — 1.3 MB, OpenAPI 3.x, ~130 tags, hundreds of operations, deep shared schemas. -- **Agent task:** _"Generate a typed client call for `GET /customers/{id}`."_ -- **Agent constraints:** a 200k-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 1% of any chain below and appears as a separate line in the totals. +- **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 | -| ----------------------- | ----------: | -| `test.yaml`, whole file | **267,739** | +| 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 267,739 tokens the file does not fit into the 200,000-token window, so reading it whole is not an option. -Searching the file by text instead is unreliable: it does not reveal the structure, does not follow `$ref` chains across files, and gives no bound on how much context the agent ends up reading. +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 | `redocly tree test.yaml --format=json --level 2` | 53 KB | 12,652 | -| 2. Open the branch it picked | `redocly tree test.yaml --node Customers` | 5.5 KB | 1,407 | -| 3. Fetch the target with its full `$ref` closure | `redocly tree test.yaml --node 'GET /customers/{id}' --with-deps` | 43.5 KB | 10,508 | -| **Total** | | | **24,567** | +| 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 plus all 31 schemas it transitively references, in dependency order — everything needed to write the client call, nothing else. -The 64 KB closure cap was not even reached. +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. -A leaner variant, when the task text already names the area (an agent can guess the `Customers` branch from step 1's section list alone): +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. -| Step | Command | Tokens | -| ---------------------- | ------------------------------------------ | ---------: | -| 1. Sections only | `--format=json --level 1` | 429 | -| 2. Branch | `--node Customers` | 1,407 | -| 3. Target with closure | `--node 'GET /customers/{id}' --with-deps` | 10,508 | -| **Total** | | **12,344** | +## What the agent actually sees -## What the index actually looks like - -Step 1 on the 1.3 MB spec — the whole map of the API in 429 tokens: +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": "test.yaml", - "spec": "oas3_1", - "docDescription": "Core APIs — The API is built on HTTP and is RESTful. It has predictable resource URLs…", + "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": "test.yaml", - "start_line": 3, - "end_line": 162, - "summary": "# Introduction … predictable resource URLs and returns HTTP response codes…" + "file": "api.github.com.yaml", + "start_line": 4, + "end_line": 14, + "summary": "GitHub's v3 REST API." }, { "id": "Servers", "title": "Servers", "pointer": "#/servers", - "summary": "https://api-sandbox.example.com/…, https://api.example.com/…" + "file": "api.github.com.yaml", + "start_line": 116, + "end_line": 116, + "summary": "https://api.github.com" }, { "id": "Operations", "title": "Operations", "pointer": "#/paths", - "start_line": 23126, - "end_line": 38861 - }, - { - "id": "Webhooks", - "title": "Webhooks", - "pointer": "#/webhooks", - "start_line": 38863, - "end_line": 40136 + "file": "api.github.com.yaml", + "start_line": 121, + "end_line": 67148 }, { "id": "Components", "title": "Components", "pointer": "#/components", - "start_line": 192, - "end_line": 21955 + "file": "api.github.com.yaml", + "start_line": 85076, + "end_line": 261104 } ] } ``` -Step 2 opens one branch — the tag the agent picked, with its operations: +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": "Customers", - "title": "Customers", - "pointer": "#/tags/16", - "file": "test.yaml", - "start_line": 22205, - "end_line": 22224, - "summary": "Use these operations to manage customers. A customer is an entity that purchases goods or services…", + "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": "GET /customers", - "title": "GET /customers — Retrieve customers", - "operationId": "GetCustomerCollection", - "pointer": "#/paths/~1customers/get", - "file": "test.yaml", - "start_line": 25755, - "end_line": 25845, - "summary": "Retrieve customers" - }, - { - "id": "GET /customers/{id}", - "title": "GET /customers/{id} — Retrieve a customer", - "operationId": "GetCustomer", - "pointer": "#/paths/~1customers~1{id}/get", - "file": "test.yaml", - "start_line": 25990, - "end_line": 26031, - "summary": "Retrieve a customer" + "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" } ] } @@ -138,129 +140,76 @@ Step 2 opens one branch — the tag the agent picked, with its operations: } ``` -Step 3 returns the leaf envelope: raw source lines, the `$ref`s found inside them resolved to real files, and the closure under `deps`: +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": "GET /customers/{id}", - "pointer": "#/get", - "file": "paths/customers_{id}.yaml", - "start_line": 4, - "end_line": 42, - "content": " tags:\n - Customers\n summary: Retrieve a customer\n operationId: GetCustomer\n parameters:\n - $ref: ../components/parameters/collectionExpand.yaml\n responses:\n '200':\n content:\n application/json:\n schema:\n $ref: ../components/schemas/Customer.yaml\n '404':\n $ref: ../components/responses/NotFound.yaml", + "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/schemas/Customer.yaml", + "ref": "#/components/responses/bad_request", "resolved": true, - "file": "components/schemas/Customer.yaml", - "pointer": "#/" - }, - { - "ref": "../components/responses/NotFound.yaml", - "resolved": true, - "file": "components/responses/NotFound.yaml", - "pointer": "#/" + "file": "api.github.com.yaml", + "pointer": "#/components/responses/bad_request" } ], "deps": [ - { "id": "schemas/Customer", "file": "components/schemas/Customer.yaml", "content": "…" }, - { "id": "responses/NotFound", "file": "components/responses/NotFound.yaml", "content": "…" } + { "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 spec was run through `redocly split`, producing **1,002 files** (`paths/`, `components/`, `webhooks/`, `code_samples/`), and the identical chain was repeated against `openapi.yaml` in that directory: +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** | -| Step | Single file | Split (1,002 files) | -| --------------------------------------------- | ----------: | ------------------: | -| 1. `--format=json --level 1` | 429 | 417 | -| 2. `--node Customers` | 1,407 | 1,317 | -| 3. `--node 'GET /customers/{id}' --with-deps` | 10,508 | 8,823 | -| **Chain total** | **12,344** | **10,557** | +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. -Two things to note. -Node ids are identical in both layouts (`GET /customers/{id}`, `schemas/Customer`), because components split into their own files keep their canonical `section/Name` ids — so the same agent instructions and the same commands work unchanged. -The split chain is slightly _cheaper_, since pointers inside small files are short. -The closure in step 3 pulled content from **34 distinct files** and returned them as one envelope — the case where an agent without an index would have to hand-walk `$ref`s across a thousand-file tree without knowing which ones matter. +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 | 267,739 | — (does not fit) | -| Index chain, standard | 24,567 (+114 instruction) | **~11× less** | -| Index chain, lean | 12,344 (+114 instruction) | **~22× less** | -| Index chain on the split layout | 10,557 (+114 instruction) | **~25× less** | +| | 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 42 KB description the same chain costs about 4,000 tokens: the description grew 31 times (42 KB to 1.3 MB), the chain grew 3 to 6 times. -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. - -## Scaling up: Google Compute Engine and the GitHub REST API - -The 1.3 MB description above is large, but public API catalogs go much further. -To see how the chain behaves as descriptions grow, the same experiment ran on two public descriptions, unmodified: - -- **Google Compute Engine API v1** — 3.5 MB, OpenAPI 3.0. - Google publishes its APIs in its own Discovery format rather than OpenAPI, so this is the [APIs.guru](https://apis.guru/) OpenAPI conversion of the official Discovery document — the real Compute Engine API surface, mechanically converted. -- **GitHub REST API** — 9.8 MB, OpenAPI 3.0.3. - The official first-party description from [`github/rest-api-description`](https://github.com/github/rest-api-description), the same file GitHub's own SDKs are generated from, and the largest well-known public OpenAPI description. - -### Google Compute Engine: 3.5 MB, 730k tokens - -The whole file is **730,154 tokens** — three and a half 200k windows. -The full unfiltered index is 157,149 tokens (1,437 nodes), so the agent starts from the level-2 map instead. - -Task: _"Create a VM instance."_ - -| Step | Command | Output size | Tokens | -| ----------------------------------------------- | ---------------------------------------------------------------------- | ----------: | ---------: | -| 1. Map the spec (4 sections, 90 tags) | `--format=json --level 2` | 22.5 KB | 5,635 | -| 2. Open the `instances` branch (~40 operations) | `--node instances` | 28.7 KB | 7,040 | -| 3. The insert operation with its closure | `--node 'POST /projects/{project}/zones/{zone}/instances' --with-deps` | 81.3 KB | 17,695 | -| **Total** | | | **30,370** | - -Step 3 is where the 64 KB closure cap earns its keep: the `Instance` schema fans out across the whole description, and the closure delivered the **31 nearest schemas** (`Instance`, `AttachedDisk`, `Scheduling`, …) in dependency order, filling 61.9 KB of the 64 KB budget. -Anything deeper stays one `--node` call away instead of flooding the response. - -### GitHub REST API: 9.8 MB, 1.9M tokens - -The whole file is **1,946,991 tokens** — nearly ten 200k windows; it does not fit even a 1M-token window. -And this is the scale where the full index stops fitting too: `--format=json` with no filters produces 306,525 tokens (3,038 nodes), more than the window itself. -Hierarchical drill-down is no longer an optimization here — it is the only way an agent can work with this file at all. - -Task: _"Create a repository for the authenticated user."_ - -| Step | Command | Output size | Tokens | -| ---------------------------------------------- | --------------------------------------- | ----------: | ---------: | -| 1. Map the spec (47 tags) | `--format=json --level 2` | 15.1 KB | 3,678 | -| 2. Open the `repos` branch — the API's largest | `--node repos` | 103.4 KB | 27,017 | -| 3. The create operation with its closure | `--node 'POST /user/repos' --with-deps` | 82.4 KB | 18,946 | -| **Total** | | | **49,641** | - -The closure again filled its budget almost exactly — 63.6 KB of 64 KB — but with only **14 schemas** this time: GitHub's schemas are individually much larger than Google's, so fewer of them fit the same bound. -The bound is what matters: the response stays predictable regardless of how heavy the schema graph is. - -### The curve across all three - -| Description | Size | Whole file (tokens) | Chain (+114 instruction) | vs. whole file | -| ------------------------------------ | -----: | ------------------: | -----------------------: | -------------: | -| `test.yaml` (production, anonymized) | 1.3 MB | 267,739 | 10,557–24,567 | 11–25× | -| Google Compute Engine v1 | 3.5 MB | 730,154 | 30,370 | ~24× | -| GitHub REST API | 9.8 MB | 1,946,991 | 49,641 | ~39× | +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. -The description grew 7.5 times (1.3 MB to 9.8 MB); the chain grew 2 times (24.6k to 49.6k tokens). -The multiplier keeps growing with size because the chain pays for a _path_ through the tree — one map, one branch, one closure — while reading the file pays for everything. -And past a certain size the comparison stops being about savings at all: at 1.3 MB the whole file misses a 200k window by a third, at 9.8 MB it misses it ten times over and even the index alone no longer fits — yet the drill-down chain still lands at a quarter of the window, with room to work. +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 `countTokens()` in `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 (never by truncating values), with names replaced by `test.yaml` and `example.com`. -- The Google and GitHub descriptions are public, so they are named and used unmodified: the Compute Engine file comes from APIs.guru (`googleapis.com/compute/v1`, converted from Google's official Discovery document), the GitHub file from the `main` branch of [`github/rest-api-description`](https://github.com/github/rest-api-description) (`api.github.com.yaml`, version 1.1.4). +- 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 3 seconds for 1.3 MB, 42 seconds for 9.8 MB; the split layout adds file reads across 1,002 files). A long-running process that keeps the analysis in memory would pay that cost once per session instead of once per step. +- 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.