Skip to content

Commit fe60246

Browse files
heiskrCopilot
andauthored
Derive GraphQL categories for un-annotated types (#62194)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent bf61d4d commit fe60246

2 files changed

Lines changed: 270 additions & 0 deletions

File tree

src/graphql/scripts/utils/process-schemas.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import type {
44
DocumentNode,
55
ObjectTypeDefinitionNode,
66
InputObjectTypeDefinitionNode,
7+
EnumTypeDefinitionNode,
8+
UnionTypeDefinitionNode,
79
FieldDefinitionNode,
810
InputValueDefinitionNode,
911
ConstDirectiveNode,
@@ -394,6 +396,124 @@ export default async function processSchemas(
394396
if (!changed) break
395397
}
396398

399+
// (c) General reference-based inheritance. An un-annotated enum, union, or
400+
// input object inherits the category of the type(s) that reference it, but
401+
// only when every referrer resolves to a single category; ambiguous types
402+
// (referrers disagree, or a referrer is itself ambiguous) stay in `other`.
403+
// This is the derived successor to a static exception list: it catches
404+
// generated/indirect types that github/github never annotates directly while
405+
// still letting the upstream team own the outcome via the parent type's
406+
// `docs_category`.
407+
//
408+
// Examples this resolves today:
409+
// - `IssueTimelineItemsItemType` / `PullRequestTimelineItemsItemType`:
410+
// runtime-generated enums used only as the `itemTypes` argument on
411+
// `Issue.timelineItems` (issues) / `PullRequest.timelineItems` (pulls).
412+
// - `RepositoryRuleType` (enum) and `RuleParameters` (union): referenced
413+
// from the annotated `RepositoryRule` object (repos).
414+
// - `RuleParametersInput` (input): referenced from the annotated
415+
// `RepositoryRuleInput` input object (repos).
416+
//
417+
// A "referrer category" is the category of:
418+
// - the owning object type, for a field's return type or a field argument's
419+
// type (interfaces are intentionally excluded: they are cross-cutting and
420+
// make coincidental single-category matches likely);
421+
// - the Mutation root field, for that field's arguments;
422+
// - the owning input object, for an input field's type. Input objects can
423+
// themselves be uncategorized-but-derivable, so this rule propagates
424+
// transitively through nested inputs.
425+
//
426+
// Implemented as a monotone fixpoint over candidate category *sets* rather
427+
// than committing categories as we go: a type is only assigned once its
428+
// candidate set has stopped growing, so the result is independent of
429+
// definition/derivation order and a later-discovered conflicting referrer
430+
// can never be missed. Explicit annotations and derivations (a)/(b) always
431+
// win: we only compute candidates for ids `lookupCat` still can't resolve.
432+
const derivableTargets = schemaAST.definitions.filter(
433+
(
434+
def,
435+
): def is EnumTypeDefinitionNode | UnionTypeDefinitionNode | InputObjectTypeDefinitionNode =>
436+
def.kind === 'EnumTypeDefinition' ||
437+
def.kind === 'UnionTypeDefinition' ||
438+
def.kind === 'InputObjectTypeDefinition',
439+
)
440+
const inputDefs = schemaAST.definitions.filter(
441+
(def): def is InputObjectTypeDefinitionNode => def.kind === 'InputObjectTypeDefinition',
442+
)
443+
444+
const targetIds = new Set<string>()
445+
for (const def of derivableTargets) {
446+
const id = helpers.getId(def.name.value)
447+
if (!lookupCat(id)) targetIds.add(id)
448+
}
449+
450+
if (targetIds.size > 0) {
451+
const candidates = new Map<string, Set<string>>()
452+
for (const id of targetIds) candidates.set(id, new Set())
453+
454+
// Categories a type contributes when it appears as a referrer. Annotated /
455+
// fallback types contribute their single category; an uncommitted derivable
456+
// referrer (only ever an input object here) contributes its current
457+
// candidate set so ambiguity propagates downstream.
458+
const contribution = (referrerId: string): Iterable<string> => {
459+
const explicit = lookupCat(referrerId)
460+
if (explicit) return [explicit]
461+
return candidates.get(referrerId) ?? []
462+
}
463+
const addRef = (targetName: string | undefined, cats: Iterable<string>): boolean => {
464+
if (!targetName) return false
465+
const id = helpers.getId(targetName)
466+
const set = candidates.get(id)
467+
if (!set) return false
468+
let grew = false
469+
for (const c of cats) {
470+
if (!set.has(c)) {
471+
set.add(c)
472+
grew = true
473+
}
474+
}
475+
return grew
476+
}
477+
478+
// Bounded by the worst-case propagation depth; each pass only adds to sets,
479+
// so this terminates well before the cap.
480+
const maxPasses = targetIds.size + 2
481+
for (let pass = 0; pass < maxPasses; pass++) {
482+
let changed = false
483+
484+
for (const def of objectDefs) {
485+
const name = def.name.value
486+
if (name === 'Query') continue
487+
const isMutation = name === 'Mutation'
488+
for (const field of def.fields || []) {
489+
// Mutation fields carry their own category and their payload return
490+
// type is already annotated, so (like rule (a)) we only walk args.
491+
const fieldCats: Iterable<string> = isMutation
492+
? ((c) => (c ? [c] : []))(getMutationCat(field.name.value))
493+
: contribution(helpers.getId(name))
494+
if (!isMutation && addRef(namedTypeName(field.type), fieldCats)) changed = true
495+
for (const arg of field.arguments || []) {
496+
if (addRef(namedTypeName(arg.type), fieldCats)) changed = true
497+
}
498+
}
499+
}
500+
501+
for (const def of inputDefs) {
502+
const ownerCats = contribution(helpers.getId(def.name.value))
503+
for (const field of def.fields || []) {
504+
if (addRef(namedTypeName(field.type), ownerCats)) changed = true
505+
}
506+
}
507+
508+
if (!changed) break
509+
}
510+
511+
// Assign only the targets whose final candidate set is unambiguous.
512+
for (const [id, cats] of candidates) {
513+
if (cats.size === 1) typeCategoryMap.set(id, [...cats][0])
514+
}
515+
}
516+
397517
// Normalize unknown categories (e.g. `:checks`, `:search`, `:packages`,
398518
// `:security_advisories`) to `other`. The upstream gh/gh allowlist permits
399519
// many categories that docs-internal hasn't yet built per-category landing
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { describe, expect, test } from 'vitest'
2+
3+
import processSchemas from '../scripts/utils/process-schemas'
4+
5+
// Minimal `@docsCategory` directive declaration so `buildASTSchema` can parse
6+
// the fixtures below. Mirrors the real declaration emitted by github/github.
7+
const DIRECTIVE = `
8+
directive @docsCategory(name: String!) on ENUM | FIELD_DEFINITION | INPUT_OBJECT | INTERFACE | OBJECT | UNION
9+
`
10+
11+
// Run processSchemas over an inline IDL and return a flat name -> category map
12+
// across every kind, so tests can assert where a type landed.
13+
async function categoriesFor(idl: string): Promise<Record<string, string>> {
14+
const data = await processSchemas(`${DIRECTIVE}\n${idl}`, [])
15+
const out: Record<string, string> = {}
16+
for (const kind of Object.keys(data) as (keyof typeof data)[]) {
17+
for (const item of data[kind] as Array<{ name?: string; category?: string }>) {
18+
if (item?.name && item.category) out[item.name] = item.category
19+
}
20+
}
21+
return out
22+
}
23+
24+
// Every fixture needs a Query root; a `viewer` field keeps it non-empty.
25+
const QUERY = `
26+
type Query {
27+
viewer: String
28+
}
29+
`
30+
31+
describe('reference-based category derivation (PASS 1.5 rule c)', () => {
32+
test('enum inherits from an annotated object field return type', async () => {
33+
const cats = await categoriesFor(`
34+
${QUERY}
35+
type RepositoryRule @docsCategory(name: "repos") {
36+
type: RepositoryRuleType
37+
}
38+
enum RepositoryRuleType { CREATION DELETION }
39+
`)
40+
expect(cats.RepositoryRuleType).toBe('repos')
41+
})
42+
43+
test('union inherits from an annotated object field', async () => {
44+
const cats = await categoriesFor(`
45+
${QUERY}
46+
type RepositoryRule @docsCategory(name: "repos") {
47+
parameters: RuleParameters
48+
}
49+
type UpdateParameters @docsCategory(name: "repos") { value: String }
50+
union RuleParameters = UpdateParameters
51+
`)
52+
expect(cats.RuleParameters).toBe('repos')
53+
})
54+
55+
test('enum inherits from an annotated object field argument', async () => {
56+
// Mirrors Issue.timelineItems(itemTypes: [IssueTimelineItemsItemType!]).
57+
const cats = await categoriesFor(`
58+
${QUERY}
59+
type Issue @docsCategory(name: "issues") {
60+
timelineItems(itemTypes: [IssueTimelineItemsItemType!]): String
61+
}
62+
enum IssueTimelineItemsItemType { ASSIGNED_EVENT CLOSED_EVENT }
63+
`)
64+
expect(cats.IssueTimelineItemsItemType).toBe('issues')
65+
})
66+
67+
test('input object inherits transitively through nested input fields', async () => {
68+
const cats = await categoriesFor(`
69+
${QUERY}
70+
input RepositoryRuleInput @docsCategory(name: "repos") {
71+
parameters: RuleParametersInput
72+
}
73+
input RuleParametersInput { nested: NestedParametersInput }
74+
input NestedParametersInput { value: String }
75+
`)
76+
expect(cats.RuleParametersInput).toBe('repos')
77+
// Propagates another hop through the still-uncategorized input chain.
78+
expect(cats.NestedParametersInput).toBe('repos')
79+
})
80+
81+
test('ambiguous referrers keep the type on `other`', async () => {
82+
const cats = await categoriesFor(`
83+
${QUERY}
84+
type Issue @docsCategory(name: "issues") {
85+
state: SharedEnum
86+
}
87+
type PullRequest @docsCategory(name: "pulls") {
88+
state: SharedEnum
89+
}
90+
enum SharedEnum { OPEN CLOSED }
91+
`)
92+
expect(cats.SharedEnum).toBe('other')
93+
})
94+
95+
test('an explicit annotation wins over conflicting referrers', async () => {
96+
const cats = await categoriesFor(`
97+
${QUERY}
98+
type Issue @docsCategory(name: "issues") {
99+
state: AnnotatedEnum
100+
}
101+
type PullRequest @docsCategory(name: "pulls") {
102+
state: AnnotatedEnum
103+
}
104+
enum AnnotatedEnum @docsCategory(name: "repos") { OPEN CLOSED }
105+
`)
106+
expect(cats.AnnotatedEnum).toBe('repos')
107+
})
108+
109+
test('a type referenced only from a Query field stays on `other`', async () => {
110+
const cats = await categoriesFor(`
111+
type Query {
112+
search(kind: QueryOnlyEnum): String
113+
}
114+
enum QueryOnlyEnum { REPO USER }
115+
`)
116+
expect(cats.QueryOnlyEnum).toBe('other')
117+
})
118+
119+
test('interfaces are not a referrer source', async () => {
120+
// Interface is annotated and references the enum, but no object does, so
121+
// the enum must not inherit the interface's category.
122+
const cats = await categoriesFor(`
123+
${QUERY}
124+
interface Rulable @docsCategory(name: "repos") {
125+
kind: InterfaceOnlyEnum
126+
}
127+
enum InterfaceOnlyEnum { A B }
128+
`)
129+
expect(cats.InterfaceOnlyEnum).toBe('other')
130+
})
131+
132+
test('an unreferenced type stays on `other`', async () => {
133+
const cats = await categoriesFor(`
134+
${QUERY}
135+
enum OrphanEnum { A B }
136+
`)
137+
expect(cats.OrphanEnum).toBe('other')
138+
})
139+
140+
test('an enum used only as a mutation field argument inherits the mutation category', async () => {
141+
const cats = await categoriesFor(`
142+
${QUERY}
143+
type Mutation {
144+
createRepositoryRule(kind: MutationArgEnum): String @docsCategory(name: "repos")
145+
}
146+
enum MutationArgEnum { A B }
147+
`)
148+
expect(cats.MutationArgEnum).toBe('repos')
149+
})
150+
})

0 commit comments

Comments
 (0)