Skip to content

Commit b923719

Browse files
stephentoubCopilot
andcommitted
Synthesize shared anyOf discriminator aliases and exclude hand-written collisions
Follows up on #1515. Adds two pieces of generator hardening on top of the conditional alias emit that #1515 introduced. 1. `collectGoSharedAnyOfDiscriminatorAliasNames`: detect shared anyOf unions with a string-const discriminator property and synthesize the discriminator enum name + per-variant const names so the public `copilot` alias file re-exports them alongside the union and variant structs. Without this, schema-shared discriminated unions like `Attachment` (in the bumped schema) would expose `copilot.Attachment` and its variant structs but not `copilot.AttachmentType` / `copilot.AttachmentTypeFile` etc. 2. `collectHandWrittenGoPublicNames`: scan hand-written `go/*.go` files for exported type/const names and exclude them from the alias file so schema-shared definitions that happen to share a name with a hand-written declaration (e.g. `ContextTier`) don't double-declare in the `copilot` package and break `go build`. Note from #1515 explicitly flagged `ContextTier` as out-of-scope; this handles it. On the current 1.0.56 schema the only behavioural change is that the public `copilot` package now also re-exports the `UserToolSessionApprovalKind` discriminator enum and its 8 constants (which the `rpc` package was already exporting). On the bumped 1.0.57+ schema this lets the generator succeed (no more `assertNoGoRpcSessionEventConflicts` failure) and produces the expected `Attachment*` alias surface while letting hand-written `ContextTier` win. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 55f9371 commit b923719

1 file changed

Lines changed: 127 additions & 1 deletion

File tree

scripts/codegen/go.ts

Lines changed: 127 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import {
4747
resolveObjectSchema,
4848
resolveRef,
4949
resolveSchema,
50+
REPO_ROOT,
5051
rewriteSharedDefinitionReferences,
5152
writeGeneratedFile,
5253
type ApiSchema,
@@ -3437,6 +3438,20 @@ function collectGoSharedSessionEventAliasNames(
34373438
for (const value of values ?? []) {
34383439
constNames.add(`${typeName}${goEnumConstSuffix(value)}`);
34393440
}
3441+
3442+
// Detect anyOf unions with a string-const discriminator property. The
3443+
// api/rpc generator synthesizes an enum (named `<TypeName><DiscProp>`)
3444+
// and per-variant consts for these (e.g. `Attachment` → `AttachmentType`
3445+
// + `AttachmentTypeFile`, ...). They aren't top-level $defs, so we have
3446+
// to surface them explicitly here so the public `copilot` alias file
3447+
// re-exports them alongside the union and its variant structs.
3448+
const synthesized = collectGoSharedAnyOfDiscriminatorAliasNames(typeName, schema, definitions);
3449+
if (synthesized) {
3450+
typeNames.add(synthesized.enumName);
3451+
for (const constName of synthesized.constNames) {
3452+
constNames.add(constName);
3453+
}
3454+
}
34403455
}
34413456

34423457
return {
@@ -3445,6 +3460,107 @@ function collectGoSharedSessionEventAliasNames(
34453460
};
34463461
}
34473462

3463+
/**
3464+
* For a shared definition that is an `anyOf` discriminated union with a
3465+
* string-const discriminator property (e.g. `Attachment` with `type: "file" |
3466+
* "directory" | ...`), return the synthesized Go discriminator enum name and
3467+
* per-variant const names that the api/rpc generator emits via
3468+
* `emitGoFlatDiscriminatedUnion`. Returns `undefined` when the definition does
3469+
* not match the const-discriminator pattern.
3470+
*/
3471+
function collectGoSharedAnyOfDiscriminatorAliasNames(
3472+
unionTypeName: string,
3473+
schema: JSONSchema7,
3474+
definitions: Record<string, unknown>
3475+
): { enumName: string; constNames: string[] } | undefined {
3476+
const variants = Array.isArray(schema.anyOf) ? schema.anyOf : undefined;
3477+
if (!variants || variants.length === 0) return undefined;
3478+
3479+
const resolvedVariants: JSONSchema7[] = [];
3480+
for (const variant of variants) {
3481+
const resolved = resolveSharedAnyOfVariant(variant, definitions);
3482+
if (!resolved || !resolved.properties) return undefined;
3483+
resolvedVariants.push(resolved);
3484+
}
3485+
3486+
const firstVariant = resolvedVariants[0];
3487+
for (const [propName, propSchemaRaw] of Object.entries(firstVariant.properties!)) {
3488+
if (typeof propSchemaRaw !== "object" || propSchemaRaw === null) continue;
3489+
const firstPropSchema = propSchemaRaw as JSONSchema7;
3490+
if (typeof firstPropSchema.const !== "string") continue;
3491+
3492+
const collectedValues: string[] = [];
3493+
let valid = true;
3494+
for (const variant of resolvedVariants) {
3495+
if (!(variant.required || []).includes(propName)) { valid = false; break; }
3496+
const variantProp = variant.properties?.[propName];
3497+
if (typeof variantProp !== "object" || variantProp === null) { valid = false; break; }
3498+
const variantConst = (variantProp as JSONSchema7).const;
3499+
if (typeof variantConst !== "string") { valid = false; break; }
3500+
collectedValues.push(variantConst);
3501+
}
3502+
if (!valid || collectedValues.length === 0) continue;
3503+
3504+
const enumName = `${unionTypeName}${toGoFieldName(propName)}`;
3505+
const constNames = [...new Set(collectedValues)].map(
3506+
(value) => `${enumName}${goEnumConstSuffix(value)}`
3507+
);
3508+
return { enumName, constNames };
3509+
}
3510+
return undefined;
3511+
}
3512+
3513+
function resolveSharedAnyOfVariant(
3514+
variant: JSONSchema7 | boolean,
3515+
definitions: Record<string, unknown>
3516+
): JSONSchema7 | undefined {
3517+
if (typeof variant !== "object" || variant === null) return undefined;
3518+
if (typeof variant.$ref === "string") {
3519+
// Local $ref like "#/$defs/AttachmentFile" or "#/definitions/AttachmentFile".
3520+
const localMatch = /^#\/(?:\$defs|definitions)\/(.+)$/.exec(variant.$ref);
3521+
if (!localMatch) return undefined;
3522+
const target = definitions[decodeURIComponent(localMatch[1])];
3523+
if (!target || typeof target !== "object" || Array.isArray(target)) return undefined;
3524+
return target as JSONSchema7;
3525+
}
3526+
return variant;
3527+
}
3528+
3529+
/**
3530+
* Scan hand-written `.go` files under `go/` and return every top-level exported
3531+
* type or const name they declare. We use this to exclude those names from the
3532+
* session-events alias file: when a schema-shared definition (e.g. `ContextTier`)
3533+
* collides with a hand-written declaration of the same name in the public
3534+
* `copilot` package, the hand-written declaration must win — emitting an alias
3535+
* would produce a duplicate package-scope identifier and fail `go build`.
3536+
*
3537+
* Generated files use the `z*.go` naming convention; we skip them so that this
3538+
* scanner never reads (or depends on the output of) its own emit. Test files
3539+
* are scanned too because they share the package namespace, so a hand-written
3540+
* test-only declaration would also collide with an alias of the same name.
3541+
*/
3542+
async function collectHandWrittenGoPublicNames(): Promise<Set<string>> {
3543+
const goDir = path.join(REPO_ROOT, "go");
3544+
const names = new Set<string>();
3545+
let entries: string[];
3546+
try {
3547+
entries = await fs.readdir(goDir);
3548+
} catch {
3549+
return names;
3550+
}
3551+
for (const entry of entries) {
3552+
if (!entry.endsWith(".go")) continue;
3553+
if (entry.startsWith("z")) continue;
3554+
const filePath = path.join(goDir, entry);
3555+
const stat = await fs.stat(filePath);
3556+
if (!stat.isFile()) continue;
3557+
const content = await fs.readFile(filePath, "utf-8");
3558+
for (const name of collectGoTopLevelNames(content, "type")) names.add(name);
3559+
for (const name of collectGoTopLevelNames(content, "const")) names.add(name);
3560+
}
3561+
return names;
3562+
}
3563+
34483564
function assertNoGoRpcSessionEventConflicts(rpcGeneratedTypeCode: string): void {
34493565
const duplicateTypes = collectGoTopLevelNames(rpcGeneratedTypeCode, "type")
34503566
.filter((name) => rpcSessionEventTopLevelNames.types.has(name));
@@ -3531,9 +3647,19 @@ async function generateSessionEvents(schemaPath?: string, apiSchema?: ApiSchema)
35313647
}
35323648
}
35333649
}
3650+
// Names of public type/const declarations that already exist in hand-written
3651+
// Go files under `go/`. We must not re-export schema-generated names that
3652+
// collide with these, because Go disallows two top-level identifiers with
3653+
// the same name in a single package (`copilot`). The hand-written
3654+
// declaration always wins. Without this filter, schema-shared definitions
3655+
// like `ContextTier` (defined as a shared schema definition and emitted in
3656+
// the rpc package) would generate `copilot.ContextTier = rpc.ContextTier`
3657+
// aliases that clash with the existing hand-written `copilot.ContextTier`.
3658+
const handWrittenPublicNames = await collectHandWrittenGoPublicNames();
3659+
const aliasExcludes = new Set<string>([...internalTypesInSession, ...handWrittenPublicNames]);
35343660
const aliasOutPath = await writeGeneratedFile(
35353661
"go/zsession_events.go",
3536-
generateGoSessionEventAliasFile(generatedTypeCode, sharedAliasNames.typeNames, sharedAliasNames.constNames, internalTypesInSession)
3662+
generateGoSessionEventAliasFile(generatedTypeCode, sharedAliasNames.typeNames, sharedAliasNames.constNames, aliasExcludes)
35373663
);
35383664
console.log(` ✓ ${aliasOutPath}`);
35393665

0 commit comments

Comments
 (0)