Skip to content

Commit 3ecb0b3

Browse files
fern-supportjsklan
andauthored
feat(seed): add seed inspect command for debugging importer pipeline (#14872)
* feat(seed): add `seed inspect` command for debugging importer pipeline Adds a new `seed inspect` command that runs the Fern CLI pipeline stages (OpenAPI IR, Fern definition, Fern IR) for a given fixture without invoking any generator. Useful for debugging the importer pipeline. Usage: seed inspect --fixture <name> # standard 3-stage pipeline seed inspect --fixture <name> --direct # OpenAPI → IR via @fern-api/openapi-to-ir seed inspect --fixture <name> --output ./debug # custom output dir * fix(seed): remove type assertions and add path traversal guard in seed inspect Address review feedback: - Replace `workspace: unknown` and structural taskContext types with proper AbstractAPIWorkspace<unknown> and TaskContext types, enabling direct method calls without `as` casts - Add bounds check to ensure --fixture argument stays within test-definitions/ --------- Co-authored-by: jsklan <jsklan.development@gmail.com>
1 parent 97a2e87 commit 3ecb0b3

4 files changed

Lines changed: 258 additions & 0 deletions

File tree

packages/seed/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,12 @@
4444
"@fern-api/configuration": "workspace:*",
4545
"@fern-api/core-utils": "workspace:*",
4646
"@fern-api/fs-utils": "workspace:*",
47+
"@fern-api/ir-generator": "workspace:*",
48+
"@fern-api/lazy-fern-workspace": "workspace:*",
4749
"@fern-api/local-workspace-runner": "workspace:*",
4850
"@fern-api/logger": "workspace:*",
4951
"@fern-api/logging-execa": "workspace:*",
52+
"@fern-api/source-resolver": "workspace:*",
5053
"@fern-api/login": "workspace:*",
5154
"@fern-api/task-context": "workspace:*",
5255
"@fern-api/workspace-loader": "workspace:*",

packages/seed/src/cli.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { cleanEmptySeedDirectories, cleanOrphanedSeedFolders } from "./commands/
1818
import { generateCliChangelog } from "./commands/generate/generateCliChangelog.js";
1919
import { generateGeneratorChangelog } from "./commands/generate/generateGeneratorChangelog.js";
2020
import { buildGeneratorImage } from "./commands/img/buildGeneratorImage.js";
21+
import { inspectFixture } from "./commands/inspect/inspectFixture.js";
2122
import { getLatestCli } from "./commands/latest/getLatestCli.js";
2223
import { getLatestGenerator } from "./commands/latest/getLatestGenerator.js";
2324
import { getLatestVersionsYml } from "./commands/latest/getLatestVersionsYml.js";
@@ -81,6 +82,7 @@ export async function tryRunCli(): Promise<void> {
8182
addValidateCommands(cli);
8283
addLatestCommands(cli);
8384
addGenerateCommands(cli);
85+
addInspectCommand(cli);
8486

8587
await cli.parse();
8688
}
@@ -1569,6 +1571,51 @@ function throwIfGeneratorDoesNotExist({
15691571
}
15701572
}
15711573

1574+
function addInspectCommand(cli: Argv) {
1575+
cli.command(
1576+
"inspect",
1577+
"Inspect a fixture by generating intermediary representations (OpenAPI IR, Fern definition, Fern IR) without running a generator",
1578+
(yargs) =>
1579+
yargs
1580+
.option("fixture", {
1581+
type: "string",
1582+
demandOption: true,
1583+
alias: "f",
1584+
description: "The fixture to inspect (e.g. server-sent-events-openapi)"
1585+
})
1586+
.option("output", {
1587+
type: "string",
1588+
demandOption: false,
1589+
alias: "o",
1590+
description: "Output directory for results (defaults to a temp directory)"
1591+
})
1592+
.option("direct", {
1593+
type: "boolean",
1594+
demandOption: false,
1595+
default: false,
1596+
alias: "d",
1597+
description:
1598+
"Use the direct OpenAPI → IR path (via @fern-api/openapi-to-ir), skipping OpenAPI IR and Fern definition stages"
1599+
})
1600+
.option("log-level", {
1601+
default: LogLevel.Info,
1602+
choices: LOG_LEVELS
1603+
}),
1604+
async (argv) => {
1605+
await inspectFixture({
1606+
fixture: argv.fixture,
1607+
outputPath: argv.output
1608+
? argv.output.startsWith("/")
1609+
? AbsoluteFilePath.of(argv.output)
1610+
: join(AbsoluteFilePath.of(process.cwd()), RelativeFilePath.of(argv.output))
1611+
: undefined,
1612+
direct: argv.direct,
1613+
logLevel: argv["log-level"]
1614+
});
1615+
}
1616+
);
1617+
}
1618+
15721619
// Dummy clone of the function from @fern-api/core
15731620
// because we're using different SDKs for these packages
15741621
function createFdrService({
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import { AbstractAPIWorkspace, BaseOpenAPIWorkspace, FernDefinition } from "@fern-api/api-workspace-commons";
2+
import { APIS_DIRECTORY, DEFINITION_DIRECTORY, FERN_DIRECTORY, ROOT_API_FILENAME } from "@fern-api/configuration";
3+
import { AbsoluteFilePath, dirname, join, RelativeFilePath, streamObjectToFile } from "@fern-api/fs-utils";
4+
import { generateIntermediateRepresentation } from "@fern-api/ir-generator";
5+
import { OSSWorkspace } from "@fern-api/lazy-fern-workspace";
6+
import { LogLevel } from "@fern-api/logger";
7+
import { SourceResolver } from "@fern-api/source-resolver";
8+
import { TaskContext } from "@fern-api/task-context";
9+
import { mkdir, writeFile } from "fs/promises";
10+
import yaml from "js-yaml";
11+
import path from "path";
12+
import tmp from "tmp-promise";
13+
import { convertGeneratorWorkspaceToFernWorkspace } from "../../utils/convertSeedWorkspaceToFernWorkspace.js";
14+
import { TaskContextFactory } from "../test/TaskContextFactory.js";
15+
16+
export async function inspectFixture({
17+
fixture,
18+
outputPath,
19+
direct,
20+
logLevel
21+
}: {
22+
fixture: string;
23+
outputPath: AbsoluteFilePath | undefined;
24+
direct: boolean;
25+
logLevel: LogLevel;
26+
}): Promise<void> {
27+
const basePath = path.join(__dirname, "../../../test-definitions", FERN_DIRECTORY, APIS_DIRECTORY);
28+
const joinedPath = path.join(basePath, fixture);
29+
// Prevent path traversal: ensure the resolved path stays within test-definitions
30+
if (!path.resolve(joinedPath).startsWith(path.resolve(basePath) + path.sep)) {
31+
throw new Error(`Invalid fixture name: "${fixture}" resolves outside the test-definitions directory`);
32+
}
33+
const absolutePathToApiDefinition = AbsoluteFilePath.of(joinedPath);
34+
35+
const resolvedOutputPath = outputPath ?? AbsoluteFilePath.of((await tmp.dir()).path);
36+
await mkdir(resolvedOutputPath, { recursive: true });
37+
38+
const taskContextFactory = new TaskContextFactory(logLevel);
39+
const taskContext = taskContextFactory.create(`inspect:${fixture}`);
40+
41+
taskContext.logger.info(`Inspecting fixture: ${fixture}`);
42+
taskContext.logger.info(`Output directory: ${resolvedOutputPath}`);
43+
44+
const apiWorkspace = await convertGeneratorWorkspaceToFernWorkspace({
45+
fixture,
46+
absolutePathToAPIDefinition: absolutePathToApiDefinition,
47+
taskContext
48+
});
49+
50+
if (apiWorkspace == null) {
51+
throw new Error(`Failed to load workspace for fixture: ${fixture}`);
52+
}
53+
54+
if (direct) {
55+
await runDirectPath({ workspace: apiWorkspace, outputPath: resolvedOutputPath, taskContext });
56+
} else {
57+
await runStandardPath({ workspace: apiWorkspace, outputPath: resolvedOutputPath, taskContext });
58+
}
59+
60+
taskContext.logger.info(`Done. Results in ${resolvedOutputPath}`);
61+
}
62+
63+
async function runDirectPath({
64+
workspace,
65+
outputPath,
66+
taskContext
67+
}: {
68+
workspace: AbstractAPIWorkspace<unknown>;
69+
outputPath: AbsoluteFilePath;
70+
taskContext: TaskContext;
71+
}): Promise<void> {
72+
if (!(workspace instanceof OSSWorkspace)) {
73+
throw new Error(
74+
"The --direct flag requires an OpenAPI-backed fixture. This fixture uses Fern definitions directly."
75+
);
76+
}
77+
78+
taskContext.logger.info("Running direct path: OpenAPI → IR (via @fern-api/openapi-to-ir)");
79+
80+
const ir = await workspace.getIntermediateRepresentation({
81+
context: taskContext,
82+
audiences: { type: "all" },
83+
enableUniqueErrorsPerEndpoint: true,
84+
generateV1Examples: false,
85+
logWarnings: true
86+
});
87+
88+
const irPath = join(outputPath, RelativeFilePath.of("ir-direct.json"));
89+
await streamObjectToFile(irPath, ir, { pretty: true });
90+
taskContext.logger.info(`Wrote direct IR to ${irPath}`);
91+
}
92+
93+
async function runStandardPath({
94+
workspace,
95+
outputPath,
96+
taskContext
97+
}: {
98+
workspace: AbstractAPIWorkspace<unknown>;
99+
outputPath: AbsoluteFilePath;
100+
taskContext: TaskContext;
101+
}): Promise<void> {
102+
const isOpenAPIBacked = workspace instanceof BaseOpenAPIWorkspace;
103+
104+
// Stage 1: OpenAPI IR (only for OpenAPI-backed fixtures)
105+
if (isOpenAPIBacked) {
106+
taskContext.logger.info("Stage 1: Generating OpenAPI IR (via @fern-api/openapi-ir-parser)");
107+
const openApiIr = await workspace.getOpenAPIIr({ context: taskContext });
108+
const openApiIrPath = join(outputPath, RelativeFilePath.of("openapi-ir.json"));
109+
await streamObjectToFile(openApiIrPath, openApiIr, { pretty: true });
110+
taskContext.logger.info(`Wrote OpenAPI IR to ${openApiIrPath}`);
111+
} else {
112+
taskContext.logger.info("Stage 1: Skipped (fixture is not OpenAPI-backed)");
113+
}
114+
115+
// Stage 2: Fern definition (only for OpenAPI-backed fixtures)
116+
if (isOpenAPIBacked) {
117+
taskContext.logger.info("Stage 2: Generating Fern definition (via @fern-api/openapi-ir-to-fern)");
118+
const definition = await workspace.getDefinition({ context: taskContext });
119+
const definitionPath = join(outputPath, RelativeFilePath.of(DEFINITION_DIRECTORY));
120+
await writeFernDefinition({ definition, absolutePathToOutputDirectory: definitionPath });
121+
taskContext.logger.info(`Wrote Fern definition to ${definitionPath}`);
122+
} else {
123+
taskContext.logger.info("Stage 2: Skipped (fixture is not OpenAPI-backed)");
124+
}
125+
126+
// Stage 3: Fern IR (always runs)
127+
taskContext.logger.info("Stage 3: Generating Fern IR (via @fern-api/ir-generator)");
128+
const fernWorkspace = await workspace.toFernWorkspace({ context: taskContext });
129+
130+
const noopSourceResolver: SourceResolver = {
131+
resolveSource: () => undefined,
132+
resolveSourceOrThrow: () => undefined
133+
};
134+
135+
const ir = generateIntermediateRepresentation({
136+
workspace: fernWorkspace,
137+
generationLanguage: undefined,
138+
keywords: undefined,
139+
smartCasing: false,
140+
exampleGeneration: { disabled: false },
141+
audiences: { type: "all" },
142+
readme: undefined,
143+
packageName: undefined,
144+
version: undefined,
145+
context: taskContext,
146+
sourceResolver: noopSourceResolver
147+
});
148+
149+
const irPath = join(outputPath, RelativeFilePath.of("ir.json"));
150+
await streamObjectToFile(irPath, ir, { pretty: true });
151+
taskContext.logger.info(`Wrote Fern IR to ${irPath}`);
152+
}
153+
154+
async function writeFernDefinition({
155+
definition,
156+
absolutePathToOutputDirectory
157+
}: {
158+
definition: FernDefinition;
159+
absolutePathToOutputDirectory: AbsoluteFilePath;
160+
}): Promise<void> {
161+
const sortKeys = (a: string, b: string): number => {
162+
const customOrder: Record<string, number> = {
163+
imports: 0,
164+
types: 1,
165+
services: 2
166+
};
167+
168+
const orderA = a in customOrder ? customOrder[a] : Object.keys(customOrder).length;
169+
const orderB = b in customOrder ? customOrder[b] : Object.keys(customOrder).length;
170+
171+
if (orderA !== orderB) {
172+
return (orderA ?? 0) - (orderB ?? 0);
173+
}
174+
175+
return a.localeCompare(b);
176+
};
177+
178+
await mkdir(absolutePathToOutputDirectory, { recursive: true });
179+
180+
// Write api.yml
181+
await writeFile(
182+
join(absolutePathToOutputDirectory, RelativeFilePath.of(ROOT_API_FILENAME)),
183+
yaml.dump(definition.rootApiFile.contents, { sortKeys })
184+
);
185+
186+
// Write __package__.yml files
187+
for (const [relativePath, packageMarker] of Object.entries(definition.packageMarkers)) {
188+
const absoluteFilepath = join(absolutePathToOutputDirectory, RelativeFilePath.of(relativePath));
189+
await mkdir(dirname(absoluteFilepath), { recursive: true });
190+
await writeFile(absoluteFilepath, yaml.dump(packageMarker.contents, { sortKeys }));
191+
}
192+
193+
// Write named definition files
194+
for (const [relativePath, definitionFile] of Object.entries(definition.namedDefinitionFiles)) {
195+
const absoluteFilepath = join(absolutePathToOutputDirectory, RelativeFilePath.of(relativePath));
196+
await mkdir(dirname(absoluteFilepath), { recursive: true });
197+
await writeFile(absoluteFilepath, yaml.dump(definitionFile.contents, { sortKeys }));
198+
}
199+
}

pnpm-lock.yaml

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)