Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ generators/csharp/playground/**/obj/
next-env.d.ts
.vercel

# python virtual envs (local dev tooling)
.venv/
**/.venv/

# misc
.DS_Store
*.swp
Expand Down
60 changes: 59 additions & 1 deletion generators/go-v2/base/src/cli/AbstractGoGeneratorCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,21 @@ import { AbstractGeneratorCli, File, parseIR } from "@fern-api/base-generator";
import { AbsoluteFilePath } from "@fern-api/fs-utils";
import { BaseGoCustomConfigSchema } from "@fern-api/go-ast";
import { FernIr } from "@fern-fern/ir-sdk";
import { readFile } from "fs/promises";

type IntermediateRepresentation = FernIr.IntermediateRepresentation;

import { serialization as IrSerialization } from "@fern-fern/ir-sdk";
import { AbstractGoGeneratorContext } from "../context/AbstractGoGeneratorContext.js";

/**
* Suffix appended to the IR filepath to locate the sidecar file the native
* (v1) Go generator writes with the type locations it relocated while breaking
* import cycles. Must match `typeRelocationsFileSuffix` in
* `generators/go/internal/generator/generator.go`.
*/
const TYPE_RELOCATIONS_FILE_SUFFIX = ".relocations.json";

export abstract class AbstractGoGeneratorCli<
CustomConfig extends BaseGoCustomConfigSchema,
GoGeneratorContext extends AbstractGoGeneratorContext<CustomConfig>
Expand All @@ -18,10 +27,59 @@ export abstract class AbstractGoGeneratorCli<
* @returns
*/
protected async parseIntermediateRepresentation(irFilepath: string): Promise<IntermediateRepresentation> {
return await parseIR<IntermediateRepresentation>({
const ir = await parseIR<IntermediateRepresentation>({
absolutePathToIR: AbsoluteFilePath.of(irFilepath),
parse: IrSerialization.IntermediateRepresentation.parse
});
await this.applyTypeRelocations(ir, irFilepath);
return ir;
}

/**
* The native (v1) Go generator breaks import cycles by relocating "leaf"
* types into a shared `common` package, but it does this in its own
* in-memory copy of the IR. Because this generator runs as a separate
* subprocess against the original IR file, it would otherwise reference
* those types from their pre-relocation packages and emit undefined
* symbols. v1 records the relocations in a sidecar file next to the IR;
* here we apply them so both generators agree on where each type lives.
*/
private async applyTypeRelocations(ir: IntermediateRepresentation, irFilepath: string): Promise<void> {
let contents: string;
try {
contents = await readFile(irFilepath + TYPE_RELOCATIONS_FILE_SUFFIX, "utf-8");
} catch {
// No sidecar file means no cycle-breaking relocations were applied.
return;
}
const relocations: unknown = JSON.parse(contents);
if (typeof relocations !== "object" || relocations == null) {
return;
}
const parseOptions = {
unrecognizedObjectKeys: "passthrough",
allowUnrecognizedEnumValues: true,
allowUnrecognizedUnionMembers: true,
skipValidation: true
} as const;
for (const [typeId, rawFernFilepath] of Object.entries(relocations)) {
const typeDeclaration = ir.types[typeId];
if (typeDeclaration != null) {
typeDeclaration.name = {
...typeDeclaration.name,
fernFilepath: await IrSerialization.FernFilepath.parseOrThrow(rawFernFilepath, parseOptions)
};
}
// The dynamic IR powers snippet generation and carries its own copy
// of each type's location, so it must be relocated in lockstep.
const dynamicType = ir.dynamic?.types[typeId];
if (dynamicType != null) {
dynamicType.declaration = {
...dynamicType.declaration,
fernFilepath: await IrSerialization.dynamic.FernFilepath.parseOrThrow(rawFernFilepath, parseOptions)
};
}
}
}

protected async generateMetadata(context: GoGeneratorContext): Promise<void> {
Expand Down
3 changes: 3 additions & 0 deletions generators/go/internal/generator/cycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,9 @@ func replaceFilepathForTypeInTypeReference(
typeId common.TypeId,
fernFilepath *common.FernFilepath,
) {
if typeReference == nil {
return
}
if container := typeReference.Container; container != nil {
replaceFilepathForTypeInContainer(container, typeId, fernFilepath)
}
Expand Down
36 changes: 33 additions & 3 deletions generators/go/internal/generator/file_writer.go
Original file line number Diff line number Diff line change
Expand Up @@ -475,11 +475,32 @@ func (f *fileWriter) GenerateGetterSetterTestFile() (*File, error) {
mainAliasToPath[alias] = importPath
}

// Cycle-breaking can relocate types into a sibling subpackage whose alias
// matches the current package name (e.g. a package named "v2" importing the
// relocated "common/v2", also aliased "v2"). In that case a "v2." qualifier
// refers to the imported package, not the local one, so it must be kept and
// imported rather than stripped.
currentImportPath := path.Join(f.baseImportPath, path.Dir(f.filename))
packageNameIsImportedAlias := false
if importPath, ok := mainAliasToPath[f.packageName]; ok && importPath != currentImportPath {
packageNameIsImportedAlias = true
}

validSubpackages := make(map[string]struct{})
if packageNameIsImportedAlias {
// A property's qualifier is only the first package referenced in its
// type string (e.g. "common" in map[common.X]*v2.Y), so the colliding
// alias may not be discovered below. Register it up front.
validSubpackages[f.packageName] = struct{}{}
testWriter.scope.AddImport(mainAliasToPath[f.packageName])
}
for _, td := range f.testData {
for _, propType := range td.propertyTypes {
pkgQualifier := extractPackageQualifier(propType)
if pkgQualifier == "" || pkgQualifier == f.packageName || isStdLibPackage(pkgQualifier) {
if pkgQualifier == "" || isStdLibPackage(pkgQualifier) {
continue
}
if pkgQualifier == f.packageName && !packageNameIsImportedAlias {
continue
}
if _, seen := validSubpackages[pkgQualifier]; seen {
Expand Down Expand Up @@ -510,7 +531,7 @@ func (f *fileWriter) GenerateGetterSetterTestFile() (*File, error) {
// 3. Package qualifier is in validSubpackages (known generated subpackage)
// 4. Package qualifier is a standard library package
shouldInclude := false
if pkgQualifier == "" || pkgQualifier == f.packageName {
if pkgQualifier == "" || (pkgQualifier == f.packageName && !packageNameIsImportedAlias) {
shouldInclude = true
} else if _, isValid := validSubpackages[pkgQualifier]; isValid {
shouldInclude = true
Expand All @@ -523,7 +544,16 @@ func (f *fileWriter) GenerateGetterSetterTestFile() (*File, error) {
}

localPropertyNames = append(localPropertyNames, testData.propertyNames[i])
localPropertyTypes = append(localPropertyTypes, stripPackageQualifier(propType, f.packageName))
// When the current package name is also an imported alias (a type was
// relocated into a sibling subpackage by cycle-breaking), the
// "packageName." qualifier refers to that import, not the local
// package, so it must be kept; stripping it references an undefined
// local type.
localPropertyType := propType
if !packageNameIsImportedAlias {
localPropertyType = stripPackageQualifier(propType, f.packageName)
}
localPropertyTypes = append(localPropertyTypes, localPropertyType)
if i < len(testData.propertySafeNames) {
localPropertySafeNames = append(localPropertySafeNames, testData.propertySafeNames[i])
}
Expand Down
59 changes: 56 additions & 3 deletions generators/go/internal/generator/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,20 @@ const (

// defaultExportedClientName is the default name for the generated client.
defaultExportedClientName = "Client"

// typeRelocationsFileSuffix is appended to the IR filepath to produce the
// path of the sidecar file that records the type locations relocated by
// cycle-breaking. The go-v2 SDK generator reads this file so that it
// references the relocated types from the same package v1 declares them in.
typeRelocationsFileSuffix = ".relocations.json"

// typeRelocationsOutputFilepathEnvVar names an environment variable that,
// when set, points to an additional host-readable path where the
// cycle-breaking relocations are written. The Fern CLI's local generation
// runner sets this so its host-side dynamic snippet test generator can
// apply the same relocations before emitting snippets. It is never set by
// remote (Fiddle) generation, so production output is unaffected.
typeRelocationsOutputFilepathEnvVar = "FERN_TYPE_RELOCATIONS_OUTPUT_FILEPATH"
)

// Mode is an enum for different generator modes (i.e. types, client, etc).
Expand Down Expand Up @@ -258,6 +272,7 @@ func (g *Generator) generate(ir *fernir.IntermediateRepresentation, mode Mode) (
return nil, err
}
if cycleInfo != nil {
relocations := make(map[common.TypeId]*common.FernFilepath, len(cycleInfo.LeafTypes))
for _, leafType := range cycleInfo.LeafTypes {
// Update every leaf type's FernFilepath so that the rest of
// the types reference it from the appropriate location.
Expand All @@ -284,6 +299,15 @@ func (g *Generator) generate(ir *fernir.IntermediateRepresentation, mode Mode) (
newFernFilepath.AllParts = append(newFernFilepath.AllParts, commonPackageElement)

replaceFilepathForTypeInIR(ir, typeDecl.Name.TypeId, newFernFilepath)
relocations[typeDecl.Name.TypeId] = newFernFilepath
}
// The go-v2 SDK generator runs as a subprocess against the same IR file
// but does not perform cycle-breaking itself. Persist the relocated type
// locations so that go-v2 references the moved types from the same
// package that v1 declares them in (otherwise its generated client code
// references undefined symbols).
if err := g.writeTypeRelocations(relocations); err != nil {
return nil, err
}
}
// First determine what types will be generated so that we can determine whether or not there will
Expand Down Expand Up @@ -776,9 +800,6 @@ func (g *Generator) generate(ir *fernir.IntermediateRepresentation, mode Mode) (
}
}

for _, file := range files {
fmt.Printf("v1 output file %s\n", file.Path)
}
return files, nil
}

Expand Down Expand Up @@ -1096,6 +1117,38 @@ func (g *Generator) generateReadme(
)
}

// writeTypeRelocations persists the type locations relocated by cycle-breaking
// to a sidecar file next to the IR. The go-v2 SDK generator runs as a separate
// subprocess against the same IR but does not perform cycle-breaking itself, so
// without this it would reference the relocated types from their original
// (pre-relocation) packages and produce undefined symbols.
func (g *Generator) writeTypeRelocations(relocations map[common.TypeId]*common.FernFilepath) error {
if len(relocations) == 0 {
return nil
}
data, err := json.Marshal(relocations)
if err != nil {
return fmt.Errorf("failed to marshal type relocations: %w", err)
}
// Sidecar next to the IR, read by the go-v2 SDK generator running as a
// subprocess against the same IR file inside this container.
if g.config.IRFilepath != "" {
if err := os.WriteFile(g.config.IRFilepath+typeRelocationsFileSuffix, data, 0644); err != nil {
return fmt.Errorf("failed to write type relocations: %w", err)
}
}
// When the Fern CLI's local generation runner asks for it, also write the
// relocations to a host-readable path so the host-side dynamic snippet test
// generator can apply them before emitting snippets. The CLI deletes this
// file before copying generated output, so it never reaches the SDK.
if outputFilepath := os.Getenv(typeRelocationsOutputFilepathEnvVar); outputFilepath != "" {
if err := os.WriteFile(outputFilepath, data, 0644); err != nil {
return fmt.Errorf("failed to write type relocations to %s: %w", outputFilepath, err)
}
}
return nil
}

// readIR reads the *IntermediateRepresentation from the given filename.
// It extracts the casingsConfig first so that Name.UnmarshalJSON can
// produce the correct casing variants (e.g. with or without initialisms).
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
- summary: |
Fix compile errors in generated Go SDKs where the v2/ TypeScript client,
its generated tests, and the generated dynamic snippet tests referenced
types in their original package after the v1 generator had relocated those
leaf types into the shared common package to break an import cycle. The v1
generator now records the relocations in a sidecar that the v2 generator
reads, and also writes them to a host-readable file (when the Fern CLI asks
for it) so the CLI's dynamic snippet test generator references the relocated
types from the same package too.
type: fix
14 changes: 14 additions & 0 deletions generators/go/sdk/versions.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,18 @@
# yaml-language-server: $schema=../../../fern-versions-yml.schema.json
- version: 1.45.5
changelogEntry:
- summary: |
Fix compile errors in generated Go SDKs where the v2/ TypeScript client,
its generated tests, and the generated dynamic snippet tests referenced
types in their original package after the v1 generator had relocated those
leaf types into the shared common package to break an import cycle. The v1
generator now records the relocations in a sidecar that the v2 generator
reads, and also writes them to a host-readable file (when the Fern CLI asks
for it) so the CLI's dynamic snippet test generator references the relocated
types from the same package too.
type: fix
createdAt: "2026-06-17"
irVersion: 66
- version: 1.45.4
changelogEntry:
- summary: |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
- summary: |
When a generator breaks an import cycle by relocating types into a shared
package, the local generation runner now applies those relocations to the IR
that powers host-side dynamic snippet test generation. This keeps the
generated dynamic snippets referencing each relocated type from the same
package the generator declares it in, fixing undefined-symbol compile errors
in the Go SDK's dynamic snippet tests.
type: fix
12 changes: 12 additions & 0 deletions packages/cli/cli/changes/5.50.0/docs-ledger.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
- summary: |
Add docs deployment ledger. Set `FERN_DOCS_DEPLOY_MODE=ledger` to
publish via the new ledger backend; `legacy` (default) uses the
existing register flow. The ledger path uses content-addressed
storage for incremental deploys and supports multi-locale
translations (including localized API reference content with
per-locale apiManifest blobs and sidebar titles), custom JS
components, favicon resolution, git provenance metadata,
multi-domain forwarding, stable file-path references in page
markdown, MIME-type inference for uploads, and a dedicated preview
endpoint.
type: feat
28 changes: 28 additions & 0 deletions packages/cli/cli/versions.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,32 @@
# yaml-language-server: $schema=../../../fern-versions-yml.schema.json
- version: 5.50.0
changelogEntry:
- summary: |
Add docs deployment ledger. Set `FERN_DOCS_DEPLOY_MODE=ledger` to
publish via the new ledger backend; `legacy` (default) uses the
existing register flow. The ledger path uses content-addressed
storage for incremental deploys and supports multi-locale
translations (including localized API reference content with
per-locale apiManifest blobs and sidebar titles), custom JS
components, favicon resolution, git provenance metadata,
multi-domain forwarding, stable file-path references in page
markdown, MIME-type inference for uploads, and a dedicated preview
endpoint.
type: feat
createdAt: "2026-06-17"
irVersion: 67
- version: 5.49.3
changelogEntry:
- summary: |
When a generator breaks an import cycle by relocating types into a shared
package, the local generation runner now applies those relocations to the IR
that powers host-side dynamic snippet test generation. This keeps the
generated dynamic snippets referencing each relocated type from the same
package the generator declares it in, fixing undefined-symbol compile errors
in the Go SDK's dynamic snippet tests.
type: fix
createdAt: "2026-06-17"
irVersion: 67
- version: 5.49.2
changelogEntry:
- summary: |
Expand Down
1 change: 0 additions & 1 deletion packages/cli/docs-resolver/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@
"@fern-api/project-loader": "workspace:*",
"@fern-api/register": "workspace:*",
"@fern-api/task-context": "workspace:*",
"@fern-api/ui-core-utils": "catalog:",
"@fern-api/workspace-loader": "workspace:*",
"@open-rpc/meta-schema": "catalog:",
"@types/fast-levenshtein": "catalog:",
Expand Down
7 changes: 5 additions & 2 deletions packages/cli/docs-resolver/src/ApiReferenceNodeConverter.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { docsYml } from "@fern-api/configuration-loader";
import { isNonNullish, titleCase } from "@fern-api/core-utils";
import { isNonNullish, titleCase, visitDiscriminatedUnion } from "@fern-api/core-utils";
import { APIV1Read, FdrAPI, FernNavigation } from "@fern-api/fdr-sdk";
import { AbsoluteFilePath } from "@fern-api/fs-utils";
import { CliError, TaskContext } from "@fern-api/task-context";
import { visitDiscriminatedUnion } from "@fern-api/ui-core-utils";
import { DocsWorkspace, FernWorkspace } from "@fern-api/workspace-loader";
import { camelCase, kebabCase } from "lodash-es";
import urlJoin from "url-join";
Expand Down Expand Up @@ -724,6 +723,7 @@ export class ApiReferenceNodeConverter {
collapsed: undefined,
operationType: graphqlOperation.operationType,
graphqlOperationId: APIV1Read.GraphQlOperationId(graphqlOperation.id),
graphqlOperationIds: undefined,
apiDefinitionId: this.apiDefinitionId,
availability: convertDocsAvailability(endpointItem.availability ?? parentAvailability),
title:
Expand Down Expand Up @@ -876,6 +876,7 @@ export class ApiReferenceNodeConverter {
collapsed: undefined,
operationType: graphqlOperation.operationType,
graphqlOperationId: APIV1Read.GraphQlOperationId(graphqlOperation.id),
graphqlOperationIds: undefined,
apiDefinitionId: this.apiDefinitionId,
availability: convertDocsAvailability(operationItem.availability ?? parentAvailability),
title: operationItem.title ?? graphqlOperation.displayName ?? graphqlOperation.name ?? graphqlOperation.id,
Expand Down Expand Up @@ -1355,6 +1356,7 @@ export class ApiReferenceNodeConverter {
collapsed: undefined,
operationType: entry.operation.operationType,
graphqlOperationId: APIV1Read.GraphQlOperationId(entry.operation.id),
graphqlOperationIds: undefined,
apiDefinitionId: this.apiDefinitionId,
availability: convertDocsAvailability(parentAvailability),
title: entry.operation.displayName ?? entry.operation.name ?? entry.operation.id,
Expand Down Expand Up @@ -1384,6 +1386,7 @@ export class ApiReferenceNodeConverter {
collapsed: undefined,
operationType: firstOp.operationType,
graphqlOperationId: APIV1Read.GraphQlOperationId(firstOp.id),
graphqlOperationIds: groupOps.map((op) => APIV1Read.GraphQlOperationId(op.id)),
apiDefinitionId: this.apiDefinitionId,
availability: convertDocsAvailability(parentAvailability),
title: entry.parentField,
Expand Down
Loading
Loading