Skip to content

Add @graphql-hive/semantic-introspection (__search / __definitions schema additions) - #2387

Open
zensucht wants to merge 24 commits into
graphql-hive:mainfrom
zensucht:feat/semantic-introspection
Open

zensucht wants to merge 24 commits into
graphql-hive:mainfrom
zensucht:feat/semantic-introspection

Conversation

@zensucht

Copy link
Copy Markdown

A new package, @graphql-hive/semantic-introspection, that adds __search and __definitions fields to any GraphQL schema so AI agents can discover capabilities by intent and fetch precise schema slices, without paying the token cost of full introspection.

TypeScript port of the HotChocolate reference implementation by Pascal Senn and the ChilliCream team — the .NET reference released alongside the Semantic Introspection RFC. Default ranker (BM25), indexing recipe, schema additions, and result types match the .NET version.

What's added

  • applySemanticIntrospection(schema, opts) — non-invasive schema extension; returns a new schema (input not mutated).
  • Default Bm25SearchProvider — pluggable via the SchemaSearchProvider interface.
  • Opt-in excludeDeprecated flag — filters @deprecated content from the agent-facing surface; standard __schema / __type introspection remains unchanged.
  • detectEmptyAfterFilter exported as a public utility — transitive fixed-point classifier for downstream tools that physically rewrite SDL.
  • README section recommending instructions wording for MCP servers exposing these tools over a federated graph.

Notes

  • 86 unit tests across 8 files (RFC conformance + kitchen-sink fixture matrix).
  • Acknowledgment to Pascal Senn and ChilliCream is in the README's "Reference implementation" section.
  • Initial release via the included changeset (minor).

zensucht added 12 commits May 26, 2026 22:58
New @graphql-hive/semantic-introspection workspace package — TypeScript
port of HotChocolate's semantic introspection (ChilliCream). Empty
scaffold only: package.json (peer-dep graphql, dual ESM/CJS via pkgroll),
README crediting the reference implementation, CHANGELOG seed, and an
index.ts stub. Public surface fills in across the next sub-tasks.
Add `applySemanticIntrospection(schema, opts) → schema`, the public entry
point that extends a host schema with the `__SearchResult` type, the
`__SchemaDefinition` union, and the two Query fields `__search` and
`__definitions`. SDL matches HotChocolate's SemanticIntrospectionSchema.cs
verbatim. The host query type does not have to be literally named Query.

Also introduces the leaf-only `SchemaSearchProvider` interface (two
methods: search → {coordinate, score, cursor}[], plus getPathsToRoot
resolved separately) that P3.4 will satisfy with a BM25 default.

Stub resolvers return empty results; P3.3 wires them to a real provider.
Uses `assumeValid: true` on extendSchema to opt into the introspection-
namespace names the RFC requires.

9 tests cover schema additions, original-field preservation, custom query
type names, schema immutability, the no-query-type error path, and
standard `__schema` introspection passing through unmodified.
Port HotChocolate's BM25 search provider into TypeScript:

- `Bm25Document`: schema-coordinate-keyed indexable doc.
- `tokenize`: camelCase / PascalCase / acronym + non-alphanumeric
  splitting; single-character tokens filtered; lowercase output.
  Matches .NET BM25Tokenizer behavior (including digit-letter
  transitions NOT triggering splits).
- `Bm25Index`: inverted index with BM25 scoring (K1=1.2, B=0.75).
  Standard IDF formula `ln((N-df+0.5)/(df+0.5)+1)`; sorted by
  score descending.
- `indexSchema`: walks GraphQLSchema, emits documents for
  types/fields-on-complex-types/enum values/input-object fields
  with text = `name + " " + description`; skips `__*` introspection
  (including our own meta-types) and does NOT index directives.
  Adds `excludeDeprecated` as an additive enhancement over the .NET
  reference, filtering @deprecated content from the agent-facing
  surface only (underlying schema unaffected).
- `Bm25SearchProvider`: implements `SchemaSearchProvider` with
  cursor pagination (little-endian int32 base64, interop with .NET),
  minScore filtering, normalized scores in `[0, 1]`, and BFS
  getPathsToRoot capped at 5 paths shortest-first.
  `SearchQueryTooLargeError` + `InvalidSearchCursorError`.

Public exports added: `Bm25SearchProvider`, `Bm25SearchProviderOptions`,
and the two error classes. 40 new unit tests across tokenizer /
bm25-index / schema-indexer / bm25-search-provider; full package
suite is 49 tests green, typecheck and prettier clean.
Replace P3.2's stub resolvers with real wiring against a
SchemaSearchProvider. By default applySemanticIntrospection now
constructs a Bm25SearchProvider over the extended schema; a custom
provider can be supplied via options.provider. The excludeDeprecated
flag is forwarded to the default provider; if a custom provider is
supplied, the flag is ignored (the provider owns its own filter policy).

New `resolvers.ts` exports:
- `lookupCoordinate(schema, coordinate)` — resolves `TypeName`,
  `TypeName.member`, and `@directiveName` coordinates to the
  corresponding graphql-js runtime object.
- `resolveSchemaDefinitionType(value)` — distinguishes the five
  __SchemaDefinition union members via graphql-js's type guards
  (isNamedType / isDirective) plus duck-typing for Field / EnumValue /
  InputValue (which graphql-js doesn't class-tag).

apply.ts wires:
- Query.__search → provider.search(query, first, after, minScore).
- Query.__definitions → coordinate-by-coordinate lookup (unknown
  coords are silently omitted from the result list).
- __SearchResult.definition → lookupCoordinate(coordinate).
- __SearchResult.pathsToRoot → provider.getPathsToRoot(coordinate).
- __SchemaDefinition.__resolveType → resolveSchemaDefinitionType.

Note: deprecated-member filtering and empty-after-filter type
OMISSION in __definitions are intentionally deferred to P3.5 —
this commit is wiring, P3.5 layers the policy on top.

10 new end-to-end tests cover all five union members, default and
custom providers, pathsToRoot resolution, and unknown-coordinate
skipping. Full suite at 57 tests green, typecheck and prettier clean.
…tions omission

Adds the public utility detectEmptyAfterFilter(schema, opts) that
classifies which types would be left empty by an agent-facing filter
(currently: @deprecated). Transitive fixed-point covering all Kinds:

- Object / Interface: zero non-deprecated fields → empty.
- Input object: zero non-deprecated fields → empty.
- Enum: zero non-deprecated values → empty.
- Union: zero non-empty members → empty (recursive).
- Scalar: never empty.

Wires the detector into __definitions / __SearchResult.definition via
the new `filteredLookup` helper in resolvers.ts:

- @deprecated members (field / enum value / input field) are omitted
  from results when excludeDeprecated is set.
- empty-after-filter types are omitted entirely from __definitions
  (emitting `__Type` with `fields: []` would violate the introspection
  validity contract — buildClientSchema rejects it).

Non-cascade per the locked design: a non-deprecated field whose return
type is empty-after-filter stays visible — the agent sees an opaque
return type rather than the field disappearing. The detection utility
is exported so a downstream ACL package can implement its own
delete-or-cascade policy at SDL-rewrite time.

Standard __schema / __type introspection remains unchanged — it always
returns the full underlying schema (including @deprecated content).

Public exports added: `detectEmptyAfterFilter`,
`DetectEmptyAfterFilterOptions`, `DetectEmptyAfterFilterResult`,
`EmptyReason`. 15 new tests across detect-empty-after-filter spec +
apply spec (omission, deprecated member skip, non-cascade reference
case, standard introspection passthrough). Full suite is 72 tests
green, typecheck and prettier clean.
…matrix

Two new spec files round out the integration coverage:

conformance.spec.ts — runs the EXACT __search and __definitions query
shapes from Pascal Senn's apidays-singapore skill prompt
(case-study/prompt-graphql-skill.md). Verifies our wire shapes
interoperate with what real agents send today: nested type/ofType
expansion for NonNull/List wrappers, args.type.ofType.kind, enumValues
on __Type, field-alias `fieldName: name`, and the full
__SchemaDefinition union typename surface.

fixture-matrix.spec.ts — single kitchen-sink schema exercising
empty-after-filter omission across all Kinds (Object / Input / Enum /
Interface / Union with mixed survivors / Union with all-empty
members), the deprecated-member skip across the same matrix, and the
non-cascade guarantees (a non-deprecated field whose RETURN type or
ARG type is empty-after-filter stays visible — the agent gets an
opaque type rather than the field disappearing). Also reasserts that
standard __schema / __type introspection passes the full underlying
schema through unmodified.

14 new tests; full suite is 86 green, typecheck and prettier clean.
README expanded from the in-development skeleton to a full reference:
quick-start usage, the exact SDL the package adds to a host schema,
the full options surface, deprecated-field handling semantics and
their non-cascade guarantees, the pluggable SchemaSearchProvider API,
and the detectEmptyAfterFilter utility for downstream tools.

Changeset: minor bump for the new package (0.0.0 → 0.1.0). Description
credits ChilliCream / Pascal Senn as the reference implementation and
calls out the additive `excludeDeprecated` flag as the one place we
extend beyond the .NET behavior.

PR-1 close-out — pkgroll dual CJS/ESM build verified, 86 tests green,
typecheck and prettier clean across the whole package.
`lookupCoordinate`, `filteredLookup`, `resolveSchemaDefinitionType`, and
their accompanying types (`LookupFilter`, `SchemaDefinitionValue`) are
useful primitives for downstream callers that want to integrate
semantic-introspection without going through the schema-extension path
— e.g. running it inside an MCP tool handler over a federated
supergraph, where `applySemanticIntrospection`'s `extendSchema` would
clobber `@join__field` directives.

No new tests required: the helpers are unchanged; this commit only
moves them across the package boundary. Existing 172 tests still pass.
…to-root BFS

From an adversarial review pass:

- decodeCursor: `Buffer.from(s,'base64')` is lenient (never throws, silently
  drops invalid chars), so the old try/catch was dead and `InvalidSearchCursorError`
  (public API) almost never fired — garbage/non-canonical cursors decoded to an
  arbitrary offset. Now validate canonically: require exactly 4 bytes AND
  re-encode to confirm the input was the canonical base64 of those bytes.

- findPathsToRoot: the BFS dequeued with `queue.shift()` (O(n) per shift →
  O(V²) overall). Switch to a head cursor for true O(V+E). Invisible at small
  schema sizes but it's library code that may index large supergraphs.

All 172 package tests still pass.
…tream

A standards pass against the hive-gateway monorepo's house conventions
(sampled jwt-auth, mcp, opentelemetry plugins). No behavior changes —
all 172 tests still pass; typecheck and pkgroll build clean.

Highlights:

- Drop project-development meta-commentary from source ("locked Phase 3
  design", "for later phases", "for upstream"; references to internal
  artifacts e.g. Pascal's apidays-singapore case-study path; "Direct
  port of HotChocolate's …" attributions repeated across file headers).
  Port attribution lives in the README's existing acknowledgments
  section and the changeset, not in source.

- Remove ASCII section-divider comments (`// ── … ──`) in apply.ts;
  the single attachResolvers function becomes four focused helpers
  (attachSearchResolver, attachDefinitionsResolver,
  attachSearchResultResolvers, attachDefinitionUnionResolveType),
  mirroring the small-helpers style of registry.ts.

- Drop pervasive `readonly` discipline on interface fields and
  constructor params across the BM25 files; the host repo's
  comparable types (MCPTool, JsonSchema, RegisteredTool) use plain
  fields.

- Collapse custom error classes (SearchQueryTooLargeError,
  InvalidSearchCursorError) to plain Error/RangeError with rich
  messages, matching how registry.ts surfaces errors. Drop the empty
  Bm25SearchProviderOptions interface (forward-compat scaffolding for
  hyperparameters that don't exist) — accept SchemaIndexerOptions
  directly.

- Convert Bm25Index from `static build()` + private constructor to a
  plain public constructor that builds inline (matches ToolRegistry's
  pattern). Call sites updated; tests updated to use `new Bm25Index`.

- Trim JSDoc density on internal helpers across resolvers.ts,
  detect-empty-after-filter.ts, apply.ts; keep one-line JSDoc on
  public symbols only.

- Drop `[@graphql-hive/semantic-introspection]` error-message prefixes
  throughout (registry.ts identifies errors by entity name, not
  package name).

- Test cleanups: strip editorial parentheticals from `it()` titles
  ("(non-cascade)", "(default BM25 provider)", "— for later phases"),
  remove "Locked design choice" inline comments, replace the
  Pascal/apidays describe block with `describe('RFC conformance', …)`,
  trim 3- and 4-line comment blocks down to one line where the assertion
  already documents the behavior.
…federated graphs

Adds a 'Using with MCP' section to the package README. Documents
recommended `instructions` field wording for MCP servers exposing
`__search` / `__definitions` over a federated graph, with empirical
justification from a 12-trial A/B over a federated test deployment:
composition-pushing wording produced tightly clustered behavior on
natural-language prompts (~11 MCP calls, one composed operation,
batched definitions), while a workflow-only baseline averaged fewer
calls but admitted a thrash mode (63 calls / 18 separate query ops
in 1/4 trials).
…vider JSDoc

Attribution belongs in README / changeset per house style, not source
file headers. README's 'Reference implementation' section retains the
ChilliCream + Pascal Senn credit. JSDoc keeps the path-unaware-ranker
design rationale since it's a non-obvious invariant a maintainer might
otherwise break by consolidating the two methods.
@qodo-code-review

qodo-code-review Bot commented May 31, 2026 •

Copy link
Copy Markdown

Review Summary by Qodo

(Agentic_describe updated until commit 6ae18ca)

Add @graphql-hive/semantic-introspection package with semantic schema discovery

✨ Enhancement

Grey Divider

Walkthroughs

Description
• Add @graphql-hive/semantic-introspection package with __search and __definitions fields
• Implement BM25 search provider with tokenization and ranking algorithm
• Support optional excludeDeprecated filter for agent-facing schema surface
• Export detectEmptyAfterFilter utility for downstream schema rewriting tools
• Include 86 comprehensive unit tests covering RFC conformance and edge cases
Diagram
flowchart LR
  Schema["GraphQL Schema"] -- "applySemanticIntrospection" --> Extended["Extended Schema<br/>with __search & __definitions"]
  Extended -- "__search query" --> BM25["BM25SearchProvider<br/>tokenize & rank"]
  BM25 -- "SchemaSearchResult[]" --> Results["Ranked Coordinates<br/>with scores & paths"]
  Extended -- "__definitions query" --> Lookup["filteredLookup<br/>resolve coordinates"]
  Lookup -- "SchemaDefinitionValue" --> Union["__SchemaDefinition Union<br/>__Type | __Field | ..."]
  Schema -- "detectEmptyAfterFilter" --> Empty["Empty Types Set<br/>after @deprecated filter"]

Loading

Grey Divider

File Changes

1. packages/semantic-introspection/src/apply.ts ✨ Enhancement +224/-0

Core schema extension with resolver attachment

packages/semantic-introspection/src/apply.ts


2. packages/semantic-introspection/src/detect-empty-after-filter.ts ✨ Enhancement +116/-0

Fixed-point classifier for empty types after filtering

packages/semantic-introspection/src/detect-empty-after-filter.ts


3. packages/semantic-introspection/src/index.ts ✨ Enhancement +21/-0

Public API exports for semantic introspection

packages/semantic-introspection/src/index.ts


View more (21)
4. packages/semantic-introspection/src/provider.ts ✨ Enhancement +47/-0

SchemaSearchProvider interface and type definitions

packages/semantic-introspection/src/provider.ts


5. packages/semantic-introspection/src/provider/bm25/bm25-index.ts ✨ Enhancement +121/-0

BM25 inverted index with scoring implementation

packages/semantic-introspection/src/provider/bm25/bm25-index.ts


6. packages/semantic-introspection/src/provider/bm25/bm25-search-provider.ts ✨ Enhancement +233/-0

BM25 search provider with path-to-root traversal

packages/semantic-introspection/src/provider/bm25/bm25-search-provider.ts


7. packages/semantic-introspection/src/provider/bm25/document.ts ✨ Enhancement +7/-0

BM25 document interface for indexable schema elements

packages/semantic-introspection/src/provider/bm25/document.ts


8. packages/semantic-introspection/src/provider/bm25/schema-indexer.ts ✨ Enhancement +149/-0

Schema walker producing BM25 documents and reverse adjacency

packages/semantic-introspection/src/provider/bm25/schema-indexer.ts


9. packages/semantic-introspection/src/provider/bm25/tokenizer.ts ✨ Enhancement +76/-0

Tokenizer for camelCase/PascalCase and non-alphanumeric splitting

packages/semantic-introspection/src/provider/bm25/tokenizer.ts


10. packages/semantic-introspection/src/resolvers.ts ✨ Enhancement +126/-0

Coordinate lookup and union type resolution logic

packages/semantic-introspection/src/resolvers.ts


11. packages/semantic-introspection/src/schema-document.ts ✨ Enhancement +43/-0

SDL for semantic introspection types and query extensions

packages/semantic-introspection/src/schema-document.ts


12. packages/semantic-introspection/tests/apply.spec.ts 🧪 Tests +603/-0

Comprehensive tests for schema extension and resolver behavior

packages/semantic-introspection/tests/apply.spec.ts


13. packages/semantic-introspection/tests/bm25/bm25-index.spec.ts 🧪 Tests +81/-0

BM25 index scoring and ranking unit tests

packages/semantic-introspection/tests/bm25/bm25-index.spec.ts


14. packages/semantic-introspection/tests/bm25/bm25-search-provider.spec.ts 🧪 Tests +200/-0

Search provider pagination and path-to-root tests

packages/semantic-introspection/tests/bm25/bm25-search-provider.spec.ts


15. packages/semantic-introspection/tests/bm25/schema-indexer.spec.ts 🧪 Tests +167/-0

Schema indexing and deprecated field filtering tests

packages/semantic-introspection/tests/bm25/schema-indexer.spec.ts


16. packages/semantic-introspection/tests/bm25/tokenizer.spec.ts 🧪 Tests +69/-0

Tokenizer edge cases and camelCase/PascalCase handling

packages/semantic-introspection/tests/bm25/tokenizer.spec.ts


17. packages/semantic-introspection/tests/conformance.spec.ts 🧪 Tests +231/-0

RFC conformance tests with exact query shapes from spec

packages/semantic-introspection/tests/conformance.spec.ts


18. packages/semantic-introspection/tests/detect-empty-after-filter.spec.ts 🧪 Tests +198/-0

Empty-after-filter detection across all GraphQL kinds

packages/semantic-introspection/tests/detect-empty-after-filter.spec.ts


19. packages/semantic-introspection/tests/fixture-matrix.spec.ts 🧪 Tests +180/-0

Integration matrix for deprecated filtering across kinds

packages/semantic-introspection/tests/fixture-matrix.spec.ts


20. .changeset/semantic-introspection-initial.md 📝 Documentation +14/-0

Changeset documenting initial release notes

.changeset/semantic-introspection-initial.md


21. packages/semantic-introspection/CHANGELOG.md 📝 Documentation +1/-0

Changelog placeholder for semantic introspection package

packages/semantic-introspection/CHANGELOG.md


22. packages/semantic-introspection/README.md 📝 Documentation +187/-0

Comprehensive documentation with examples and API reference

packages/semantic-introspection/README.md


23. packages/semantic-introspection/package.json ⚙️ Configuration changes +49/-0

Package configuration with peer dependencies and exports

packages/semantic-introspection/package.json


24. tsconfig.json ⚙️ Configuration changes +3/-0

Add semantic-introspection path mapping to TypeScript config

tsconfig.json


Grey Divider

Qodo Logo

@qodo-code-review

qodo-code-review Bot commented May 31, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (4) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0)

Grey Divider


Action required

1. Unbounded __search page size 🐞 Bug ⛨ Security
Description
__search forwards args.first directly to provider.search (and may do so up to 16 times), so
clients can request extremely large pages and cause excessive CPU/memory usage in the default
provider or any custom provider. This is a DoS vector in deployments that expose __search without
independent query complexity / max-list-size controls.
Code

packages/semantic-introspection/src/apply.ts[R99-115]

Evidence
The new resolver loop uses const target = args.first; and passes it directly into
provider.search(...) without any bounds; the schema definition also provides no max for first.
The default BM25 provider only checks that first is positive, and will push results until it
reaches first (or exhausts raw results), making large first values expensive.

packages/semantic-introspection/src/apply.ts[99-140]
packages/semantic-introspection/src/schema-document.ts[21-30]
packages/semantic-introspection/src/provider/bm25/bm25-search-provider.ts[39-76]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The `__search` resolver forwards unbounded `first` (and other args) to the provider. A malicious client can pass a huge `first` value, forcing large allocations/processing (especially with the default `Bm25SearchProvider` which does not cap `first`).
### Issue Context
- The SDL sets `first: Int! = 10` but provides no maximum.
- The resolver currently does not validate or cap `first` before calling the provider.
### Fix Focus Areas
- packages/semantic-introspection/src/apply.ts[91-143]
### Suggested fix
- Add argument validation in the resolver:
- Ensure `first` is an integer > 0.
- Enforce a hard max (e.g. `MAX_FIRST = 100`), either by throwing a GraphQL error when exceeded or clamping (prefer throwing to avoid surprising partial results).
- (Optional but recommended) validate `minScore` is within `[0,1]` when provided.
- (Optional) validate `query` length (e.g. 1024) to protect custom providers too.
- Ensure the capped/validated value is the one used for `target` and passed to `provider.search`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Non-null definition returns null ✓ Resolved 🐞 Bug ≡ Correctness
Description
__SearchResult.definition is declared non-null, but its resolver returns filteredLookup(...)
which can return null for empty-after-filter named types (and for invalid coordinates from custom
providers). In GraphQL, resolving null for a non-null field triggers execution errors and can
null-bubble the entire __search result.
Code

packages/semantic-introspection/src/apply.ts[R115-124]

Evidence
The SDL declares definition as non-null, but the resolver can return null because
filteredLookup explicitly returns null for empty-after-filter named types, and the BM25 indexer
always indexes the type itself (so __search can return such a coordinate).

packages/semantic-introspection/src/schema-document.ts[4-11]
packages/semantic-introspection/src/apply.ts[110-129]
packages/semantic-introspection/src/resolvers.ts[71-87]
packages/semantic-introspection/src/detect-empty-after-filter.ts[36-109]
packages/semantic-introspection/src/provider/bm25/schema-indexer.ts[34-67]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`__SearchResult.definition` is non-null in the schema SDL, but the resolver can return `null` (via `filteredLookup`). This violates GraphQL’s non-null contract and will surface as runtime execution errors.
### Issue Context
- `__SearchResult.definition` is defined as `__SchemaDefinition!`.
- The resolver uses `filteredLookup(schema, parent.coordinate, filter)`.
- With `excludeDeprecated: true`, `detectEmptyAfterFilter` can classify some types as empty; `filteredLookup` then returns `null` for those type coordinates.
- The default BM25 indexer **always** indexes the type document, even when the type is empty-after-filter, so `__search` can legitimately return those coordinates.
### Fix Focus Areas
- packages/semantic-introspection/src/schema-document.ts[4-11]
- packages/semantic-introspection/src/apply.ts[110-129]
- packages/semantic-introspection/src/resolvers.ts[71-87]
- packages/semantic-introspection/src/detect-empty-after-filter.ts[36-109]
- packages/semantic-introspection/src/provider/bm25/schema-indexer.ts[34-67]
### Concrete fix options (pick one)
1) **Keep `definition` non-null and guarantee resolvability**
- When `excludeDeprecated` is enabled, ensure the default provider does not emit coordinates that `filteredLookup` would drop.
- Practical approach: pass `emptyTypes` into the default provider/indexer and skip indexing those type coordinates (and/or filter them out at search-time before returning results).
- Ensure pagination cursors remain consistent if filtering occurs after scoring.
2) **Make `definition` nullable**
- Change SDL field to `definition: __SchemaDefinition` (nullable) and adjust tests/docs accordingly.
- This avoids runtime errors even with custom providers returning unknown/filtered coordinates.
Also consider aligning behavior/docs regarding `excludeDeprecated` with custom providers (either truly ignore it for custom providers, or document that filtering still applies to `__definitions`/nested `definition`).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Non-null definition can be null ✓ Resolved 🐞 Bug ≡ Correctness
Description
__SearchResult.definition is declared non-null but its resolver returns filteredLookup(...),
which can be null (notably for empty-after-filter types when excludeDeprecated: true). This can
trigger GraphQL non-null propagation errors and null out the entire __search response.
Code

packages/semantic-introspection/src/apply.ts[R119-124]

Evidence
The schema declares definition as non-null, but the runtime resolver returns filteredLookup(...)
which can return null for empty-after-filter types. The default indexer always indexes types (even
empty-after-filter ones), so search can return a coordinate whose definition is filtered out,
violating the non-null contract.

packages/semantic-introspection/src/schema-document.ts[4-11]
packages/semantic-introspection/src/apply.ts[110-129]
packages/semantic-introspection/src/provider/bm25/schema-indexer.ts[52-64]
packages/semantic-introspection/src/detect-empty-after-filter.ts[64-90]
packages/semantic-introspection/src/resolvers.ts[71-87]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`__SearchResult.definition` is `__SchemaDefinition!` (non-null), but the resolver uses `filteredLookup()` which can return `null`. If the search provider returns a coordinate that the filter rejects (e.g. empty-after-filter type), GraphQL execution errors and the whole `__search` field can become null.
## Issue Context
- Default BM25 indexing always indexes the type document even when all its members are filtered out.
- With `excludeDeprecated: true`, `detectEmptyAfterFilter` marks such types as empty and `filteredLookup` returns null for them.
## Fix Focus Areas
- packages/semantic-introspection/src/apply.ts[62-85]
- packages/semantic-introspection/src/apply.ts[110-130]
- packages/semantic-introspection/src/resolvers.ts[71-87]
- packages/semantic-introspection/src/provider/bm25/schema-indexer.ts[52-64]
- packages/semantic-introspection/src/schema-document.ts[4-11]
## Suggested fix
1. Pass the same `LookupFilter` into `attachSearchResolver`.
2. In the `__search` resolver, `await provider.search(...)` and **drop** any results whose coordinate does not survive `filteredLookup(schema, coordinate, filter)`.
- This guarantees `definition` resolver can never produce null.
- It also hardens behavior for custom providers that might return unknown or filtered-out coordinates.
3. Add a unit test covering `excludeDeprecated: true` where a type has all deprecated fields and searching for that type name does not produce execution errors (and the coordinate is omitted).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (4)
4. Non-null definition can be null ✓ Resolved 🐞 Bug ≡ Correctness
Description
__SearchResult.definition is declared non-null but its resolver returns filteredLookup(...),
which can be null (notably for empty-after-filter types when excludeDeprecated: true). This can
trigger GraphQL non-null propagation errors and null out the entire __search response.
Code

packages/semantic-introspection/src/apply.ts[R119-124]

Evidence
The schema declares definition as non-null, but the runtime resolver returns filteredLookup(...)
which can return null for empty-after-filter types. The default indexer always indexes types (even
empty-after-filter ones), so search can return a coordinate whose definition is filtered out,
violating the non-null contract.

packages/semantic-introspection/src/schema-document.ts[4-11]
packages/semantic-introspection/src/apply.ts[110-129]
packages/semantic-introspection/src/provider/bm25/schema-indexer.ts[52-64]
packages/semantic-introspection/src/detect-empty-after-filter.ts[64-90]
packages/semantic-introspection/src/resolvers.ts[71-87]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`__SearchResult.definition` is `__SchemaDefinition!` (non-null), but the resolver uses `filteredLookup()` which can return `null`. If the search provider returns a coordinate that the filter rejects (e.g. empty-after-filter type), GraphQL execution errors and the whole `__search` field can become null.
## Issue Context
- Default BM25 indexing always indexes the type document even when all its members are filtered out.
- With `excludeDeprecated: true`, `detectEmptyAfterFilter` marks such types as empty and `filteredLookup` returns null for them.
## Fix Focus Areas
- packages/semantic-introspection/src/apply.ts[62-85]
- packages/semantic-introspection/src/apply.ts[110-130]
- packages/semantic-introspection/src/resolvers.ts[71-87]
- packages/semantic-introspection/src/provider/bm25/schema-indexer.ts[52-64]
- packages/semantic-introspection/src/schema-document.ts[4-11]
## Suggested fix
1. Pass the same `LookupFilter` into `attachSearchResolver`.
2. In the `__search` resolver, `await provider.search(...)` and **drop** any results whose coordinate does not survive `filteredLookup(schema, coordinate, filter)`.
- This guarantees `definition` resolver can never produce null.
- It also hardens behavior for custom providers that might return unknown or filtered-out coordinates.
3. Add a unit test covering `excludeDeprecated: true` where a type has all deprecated fields and searching for that type name does not produce execution errors (and the coordinate is omitted).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Non-null definition returns null ✓ Resolved 🐞 Bug ≡ Correctness
Description
__SearchResult.definition is declared non-null, but its resolver returns filteredLookup(...)
which can return null for empty-after-filter named types (and for invalid coordinates from custom
providers). In GraphQL, resolving null for a non-null field triggers execution errors and can
null-bubble the entire __search result.
Code

packages/semantic-introspection/src/apply.ts[R115-124]

Evidence
The SDL declares definition as non-null, but the resolver can return null because
filteredLookup explicitly returns null for empty-after-filter named types, and the BM25 indexer
always indexes the type itself (so __search can return such a coordinate).

packages/semantic-introspection/src/schema-document.ts[4-11]
packages/semantic-introspection/src/apply.ts[110-129]
packages/semantic-introspection/src/resolvers.ts[71-87]
packages/semantic-introspection/src/detect-empty-after-filter.ts[36-109]
packages/semantic-introspection/src/provider/bm25/schema-indexer.ts[34-67]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`__SearchResult.definition` is non-null in the schema SDL, but the resolver can return `null` (via `filteredLookup`). This violates GraphQL’s non-null contract and will surface as runtime execution errors.
### Issue Context
- `__SearchResult.definition` is defined as `__SchemaDefinition!`.
- The resolver uses `filteredLookup(schema, parent.coordinate, filter)`.
- With `excludeDeprecated: true`, `detectEmptyAfterFilter` can classify some types as empty; `filteredLookup` then returns `null` for those type coordinates.
- The default BM25 indexer **always** indexes the type document, even when the type is empty-after-filter, so `__search` can legitimately return those coordinates.
### Fix Focus Areas
- packages/semantic-introspection/src/schema-document.ts[4-11]
- packages/semantic-introspection/src/apply.ts[110-129]
- packages/semantic-introspection/src/resolvers.ts[71-87]
- packages/semantic-introspection/src/detect-empty-after-filter.ts[36-109]
- packages/semantic-introspection/src/provider/bm25/schema-indexer.ts[34-67]
### Concrete fix options (pick one)
1) **Keep `definition` non-null and guarantee resolvability**
- When `excludeDeprecated` is enabled, ensure the default provider does not emit coordinates that `filteredLookup` would drop.
- Practical approach: pass `emptyTypes` into the default provider/indexer and skip indexing those type coordinates (and/or filter them out at search-time before returning results).
- Ensure pagination cursors remain consistent if filtering occurs after scoring.
2) **Make `definition` nullable**
- Change SDL field to `definition: __SchemaDefinition` (nullable) and adjust tests/docs accordingly.
- This avoids runtime errors even with custom providers returning unknown/filtered coordinates.
Also consider aligning behavior/docs regarding `excludeDeprecated` with custom providers (either truly ignore it for custom providers, or document that filtering still applies to `__definitions`/nested `definition`).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Non-null definition can be null ✓ Resolved 🐞 Bug ≡ Correctness
Description
__SearchResult.definition is declared non-null but its resolver returns filteredLookup(...),
which can be null (notably for empty-after-filter types when excludeDeprecated: true). This can
trigger GraphQL non-null propagation errors and null out the entire __search response.
Code

packages/semantic-introspection/src/apply.ts[R119-124]

Evidence
The schema declares definition as non-null, but the runtime resolver returns filteredLookup(...)
which can return null for empty-after-filter types. The default indexer always indexes types (even
empty-after-filter ones), so search can return a coordinate whose definition is filtered out,
violating the non-null contract.

packages/semantic-introspection/src/schema-document.ts[4-11]
packages/semantic-introspection/src/apply.ts[110-129]
packages/semantic-introspection/src/provider/bm25/schema-indexer.ts[52-64]
packages/semantic-introspection/src/detect-empty-after-filter.ts[64-90]
packages/semantic-introspection/src/resolvers.ts[71-87]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`__SearchResult.definition` is `__SchemaDefinition!` (non-null), but the resolver uses `filteredLookup()` which can return `null`. If the search provider returns a coordinate that the filter rejects (e.g. empty-after-filter type), GraphQL execution errors and the whole `__search` field can become null.
## Issue Context
- Default BM25 indexing always indexes the type document even when all its members are filtered out.
- With `excludeDeprecated: true`, `detectEmptyAfterFilter` marks such types as empty and `filteredLookup` returns null for them.
## Fix Focus Areas
- packages/semantic-introspection/src/apply.ts[62-85]
- packages/semantic-introspection/src/apply.ts[110-130]
- packages/semantic-introspection/src/resolvers.ts[71-87]
- packages/semantic-introspection/src/provider/bm25/schema-indexer.ts[52-64]
- packages/semantic-introspection/src/schema-document.ts[4-11]
## Suggested fix
1. Pass the same `LookupFilter` into `attachSearchResolver`.
2. In the `__search` resolver, `await provider.search(...)` and **drop** any results whose coordinate does not survive `filteredLookup(schema, coordinate, filter)`.
 - This guarantees `definition` resolver can never produce null.
 - It also hardens behavior for custom providers that might return unknown or filtered-out coordinates.
3. Add a unit test covering `excludeDeprecated: true` where a type has all deprecated fields and searching for that type name does not produce execution errors (and the coordinate is omitted).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


7. Non-null definition returns null ✓ Resolved 🐞 Bug ≡ Correctness
Description
__SearchResult.definition is declared non-null, but its resolver returns filteredLookup(...)
which can return null for empty-after-filter named types (and for invalid coordinates from custom
providers). In GraphQL, resolving null for a non-null field triggers execution errors and can
null-bubble the entire __search result.
Code

packages/semantic-introspection/src/apply.ts[R115-124]

Evidence
The SDL declares definition as non-null, but the resolver can return null because
filteredLookup explicitly returns null for empty-after-filter named types, and the BM25 indexer
always indexes the type itself (so __search can return such a coordinate).

packages/semantic-introspection/src/schema-document.ts[4-11]
packages/semantic-introspection/src/apply.ts[110-129]
packages/semantic-introspection/src/resolvers.ts[71-87]
packages/semantic-introspection/src/detect-empty-after-filter.ts[36-109]
packages/semantic-introspection/src/provider/bm25/schema-indexer.ts[34-67]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`__SearchResult.definition` is non-null in the schema SDL, but the resolver can return `null` (via `filteredLookup`). This violates GraphQL’s non-null contract and will surface as runtime execution errors.
### Issue Context
- `__SearchResult.definition` is defined as `__SchemaDefinition!`.
- The resolver uses `filteredLookup(schema, parent.coordinate, filter)`.
- With `excludeDeprecated: true`, `detectEmptyAfterFilter` can classify some types as empty; `filteredLookup` then returns `null` for those type coordinates.
- The default BM25 indexer **always** indexes the type document, even when the type is empty-after-filter, so `__search` can legitimately return those coordinates.
### Fix Focus Areas
- packages/semantic-introspection/src/schema-document.ts[4-11]
- packages/semantic-introspection/src/apply.ts[110-129]
- packages/semantic-introspection/src/resolvers.ts[71-87]
- packages/semantic-introspection/src/detect-empty-after-filter.ts[36-109]
- packages/semantic-introspection/src/provider/bm25/schema-indexer.ts[34-67]
### Concrete fix options (pick one)
1) **Keep `definition` non-null and guarantee resolvability**
- When `excludeDeprecated` is enabled, ensure the default provider does not emit coordinates that `filteredLookup` would drop.
- Practical approach: pass `emptyTypes` into the default provider/indexer and skip indexing those type coordinates (and/or filter them out at search-time before returning results).
- Ensure pagination cursors remain consistent if filtering occurs after scoring.
2) **Make `definition` nullable**
- Change SDL field to `definition: __SchemaDefinition` (nullable) and adjust tests/docs accordingly.
- This avoids runtime errors even with custom providers returning unknown/filtered coordinates.
Also consider aligning behavior/docs regarding `excludeDeprecated` with custom providers (either truly ignore it for custom providers, or document that filtering still applies to `__definitions`/nested `definition`).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

8. pathsToRoot bypasses filtering 🐞 Bug ≡ Correctness
Description
__SearchResult.pathsToRoot is resolved directly from provider.getPathsToRoot() without applying the
excludeDeprecated/emptyTypes filter, so deprecated coordinates can leak into the agent-facing
surface and paths can include coordinates that __definitions will omit. This creates inconsistent
agent guidance (a path can point at coordinates the same response surface refuses to resolve).
Code

packages/semantic-introspection/src/apply.ts[R210-214]

Evidence
The resolver for pathsToRoot returns provider output unfiltered, while the library documentation
states excludeDeprecated filters deprecated content from the agent-facing surface
(__search/__definitions). This mismatch enables deprecated coordinates to appear in
pathsToRoot even when they are suppressed elsewhere.

packages/semantic-introspection/src/apply.ts[195-215]
packages/semantic-introspection/README.md[91-101]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`__SearchResult.pathsToRoot` currently returns whatever the provider supplies, without applying the same `filteredLookup`-based policy used for `__search` and `__definitions`. With `excludeDeprecated: true` (especially when using a custom provider), this can return deprecated coordinates in `pathsToRoot` even though `__definitions` would omit them.
### Issue Context
- `__search`/`__definitions` are filtered via `filteredLookup(schema, coordinate, filter)`.
- `pathsToRoot` is part of the `__search` agent-facing payload and should be consistent with that filtering policy.
### Fix Focus Areas
- packages/semantic-introspection/src/apply.ts[195-215]
### Suggested fix
In the `pathsToRoot` resolver, post-process `await provider.getPathsToRoot(parent.coordinate)`:
- Drop any path containing a coordinate where `filteredLookup(schema, coord, filter) === null`.
- Optionally also drop empty paths after filtering.
This keeps `pathsToRoot` aligned with what `__definitions` will actually resolve under the same filter.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


9. excludeDeprecated docs mismatch 🐞 Bug ⚙ Maintainability
Description
README states excludeDeprecated is ignored when a custom provider is supplied, but
applySemanticIntrospection still builds a LookupFilter from excludeDeprecated and uses it in
__search/__definitions resolvers, so deprecated/empty coordinates are still dropped. This breaks
documented behavior and can surprise consumers using custom providers who expect full control of
filtering policy.
Code

packages/semantic-introspection/README.md[R91-100]

Evidence
README explicitly says the flag is ignored with a custom provider, but applySemanticIntrospection
constructs and uses an excludeDeprecated-based filter in resolver code regardless of whether the
provider is custom or default.

packages/semantic-introspection/README.md[91-101]
packages/semantic-introspection/src/apply.ts[63-76]
packages/semantic-introspection/src/apply.ts[116-153]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The README claims `excludeDeprecated` is ignored when a custom `provider` is supplied, but the implementation still applies `excludeDeprecated` via `filteredLookup(...)` in the `__search` and `__definitions` resolvers.
### Issue Context
- Code sets `excludeDeprecated = options.excludeDeprecated === true` regardless of whether `options.provider` is provided.
- That value is baked into `filter: LookupFilter` and used to filter resolver outputs.
### Fix Focus Areas
- packages/semantic-introspection/README.md[91-101]
- packages/semantic-introspection/src/apply.ts[63-76]
- packages/semantic-introspection/src/apply.ts[116-153]
### Suggested fix
Pick one and make it consistent:
1) **Docs fix (recommended):** Update README to say:
- `excludeDeprecated` is always enforced at the resolver layer for `__search` and `__definitions`.
- When providing a custom provider, the flag is **not forwarded** to the provider, but filtering still applies to returned coordinates.
2) **Behavior fix:** If the intended contract is “custom providers own filtering,” then when `options.provider` is set, do not apply `excludeDeprecated` in `filteredLookup` (you may still want to filter only invalid/unresolvable coordinates to preserve non-null contracts).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


10. Collision test incomplete 🐞 Bug ≡ Correctness
Description
The new collision test says it covers schemas that already define __SearchResult or
__SchemaDefinition, but it only constructs a schema defining __SearchResult, so a regression for
__SchemaDefinition collisions would not be caught.
Code

packages/semantic-introspection/tests/apply.spec.ts[R58-78]

Evidence
The test title explicitly includes __SchemaDefinition, but the schema SDL in the test only defines
__SearchResult and the expectation only checks for the __SearchResult error message, leaving
__SchemaDefinition collision behavior uncovered.

packages/semantic-introspection/tests/apply.spec.ts[58-78]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The test case claims to validate collisions for both `__SearchResult` and `__SchemaDefinition`, but it only sets up a schema with `type __SearchResult { ... }`. This leaves `__SchemaDefinition` collision behavior untested.
## Issue Context
`applySemanticIntrospection` rejects host schemas that already define any type introduced by the semantic-introspection SDL. The test should cover both reserved type names to prevent regressions.
## Fix Focus Areas
- packages/semantic-introspection/tests/apply.spec.ts[58-79]
## Proposed fix
Add a second assertion (either as a separate `it(...)` or by extending the existing one) that constructs a schema defining `__SchemaDefinition` (with `assumeValid: true`) and asserts `applySemanticIntrospection(schema)` throws an error mentioning `__SchemaDefinition`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (13)
11. Type collisions unchecked ✓ Resolved 🐞 Bug ☼ Reliability
Description
applySemanticIntrospection now checks only for __search/__definitions field collisions, but it
also injects __SearchResult/__SchemaDefinition types while using `extendSchema(..., {
assumeValid: true })`. If a host schema already defines those type names, the extension can
merge/override in confusing ways instead of failing fast with a clear error.
Code

packages/semantic-introspection/src/apply.ts[R39-56]

Evidence
The new collision logic only inspects existing query fields, while the schema extension document
also defines new __* types. Because extendSchema is invoked with assumeValid: true, relying on
GraphQL validation to catch type-name conflicts is explicitly bypassed.

packages/semantic-introspection/src/apply.ts[39-56]
packages/semantic-introspection/src/schema-document.ts[3-19]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`applySemanticIntrospection` uses `extendSchema(..., { assumeValid: true })` but only checks collisions for the query root fields (`__search`, `__definitions`). It should also fail fast if the host schema already contains semantic-introspection type names (`__SearchResult`, `__SchemaDefinition`) to avoid silent merges/overrides or hard-to-debug runtime errors.
### Issue Context
- The extension document defines new types `__SearchResult` and `__SchemaDefinition`.
- `assumeValid: true` bypasses validation that would normally reject duplicate/conflicting definitions.
### Fix Focus Areas
- packages/semantic-introspection/src/apply.ts[39-56]
### Suggested fix
- Before calling `extendSchema`, add checks like:
- `if (schema.getType('__SearchResult') || schema.getType('__SchemaDefinition')) throw new Error(...)`
- Consider also checking for future reserved names you add (if the SDL grows), to keep the failure mode explicit and user-friendly.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


12. PathsToRoot misses alternatives ✓ Resolved 🐞 Bug ≡ Correctness
Description
getPathsToRoot’s BFS uses a visited set keyed only by type name, so multiple distinct references
from the same parent type (e.g., Query.user and Query.adminUser both returning User) are
pruned after the first. This makes pathsToRoot incomplete even when reverseMap contains multiple
valid coordinates.
Code

packages/semantic-introspection/src/provider/bm25/bm25-search-provider.ts[R138-172]

Evidence
The reverse adjacency map can legitimately contain multiple coordinates from the same type, but
findPathsToRoot prunes further exploration after the first because visited is keyed only by
referenceTypeName.

packages/semantic-introspection/src/provider/bm25/bm25-search-provider.ts[119-176]
packages/semantic-introspection/src/provider/bm25/schema-indexer.ts[96-104]
packages/semantic-introspection/src/provider.ts[40-46]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`findPathsToRoot` de-duplicates traversal using `visited: Set<string>` of *type names*. This suppresses distinct valid paths when multiple coordinates share the same parent type name (common in real schemas where multiple root fields return the same type).
### Issue Context
- `reverseMap` stores multiple coordinates per return type (one per field).
- Current BFS marks `Query` visited after the first `Query.*` reference, preventing exploration of additional `Query.*` references that would yield additional paths.
### Fix Focus Areas
- packages/semantic-introspection/src/provider/bm25/bm25-search-provider.ts[119-176]
- packages/semantic-introspection/src/provider/bm25/schema-indexer.ts[96-104]
- packages/semantic-introspection/src/provider.ts[40-46]
### Suggested fix
- Track visited state at a finer granularity than just `typeName` (e.g., by `reference` coordinate, or by `(typeName, parentCoordinate)`), so multiple distinct incoming edges from the same type can still produce multiple paths.
- Keep the `MAX_PATHS` bound to avoid blowups, but allow collecting multiple distinct root-field paths when they exist.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


13. Shared EMPTY_RESULT is mutable ✓ Resolved 🐞 Bug ☼ Reliability
Description
When excludeDeprecated is false, detectEmptyAfterFilter returns a module-level EMPTY_RESULT
that holds mutable Set/Map instances. If any consumer mutates the returned objects (easy from
JS), it pollutes subsequent calls, including applySemanticIntrospection with `excludeDeprecated:
false`.
Code

packages/semantic-introspection/src/detect-empty-after-filter.ts[R40-43]

Evidence
The function returns the same EMPTY_RESULT object for the common excludeDeprecated=false case,
and that object contains mutable Set/Map instances; therefore state can leak across calls.

packages/semantic-introspection/src/detect-empty-after-filter.ts[36-43]
packages/semantic-introspection/src/detect-empty-after-filter.ts[111-114]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`detectEmptyAfterFilter()` returns a shared `EMPTY_RESULT` object when `excludeDeprecated` is false. Even though the types are `Readonly*` in TypeScript, at runtime the returned `Set`/`Map` are mutable and shared across calls, so a consumer can accidentally (or intentionally) mutate global state.
## Issue Context
This function is exported as a public utility. Any mutation of the returned collections affects every later call that hits the `excludeDeprecated=false` early return.
## Fix Focus Areas
- packages/semantic-introspection/src/detect-empty-after-filter.ts[36-43]
- packages/semantic-introspection/src/detect-empty-after-filter.ts[111-114]
## Suggested fix
- Replace the `return EMPTY_RESULT;` fast path with `return { emptyTypes: new Set(), reasons: new Map() };`.
- Optionally remove `EMPTY_RESULT` entirely, or keep it only as a factory function.
- Add a regression test that mutates the returned set in one call (in JS or via TS cast) and verifies a subsequent call still returns empty.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


14. pathsToRoot drops alternatives ✓ Resolved 🐞 Bug ≡ Correctness
Description
findPathsToRoot deduplicates BFS exploration using visited keyed only by type name, so multiple
distinct incoming edges from the same parent type (e.g. Query.user and Query.admin both
returning User) are collapsed and only one path can be returned. This makes pathsToRoot
incomplete and order-dependent.
Code

packages/semantic-introspection/src/provider/bm25/bm25-search-provider.ts[R143-171]

Evidence
The algorithm computes referenceTypeName from the coordinate’s prefix and then uses it as the
visited key, causing different field coordinates with the same parent type to be skipped.

packages/semantic-introspection/src/provider/bm25/bm25-search-provider.ts[119-176]
packages/semantic-introspection/src/provider/bm25/bm25-search-provider.ts[178-181]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The BFS for `pathsToRoot` uses `visited: Set<string>` keyed by `referenceTypeName` (type name), which suppresses exploring additional distinct references that originate from the same type.
## Issue Context
`referenceTypeName` is derived by truncating the coordinate at the first dot, so `Query.user` and `Query.admin` both map to `Query` and only the first encountered reference is explored.
## Fix Focus Areas
- packages/semantic-introspection/src/provider/bm25/bm25-search-provider.ts[119-176]
- packages/semantic-introspection/src/provider/bm25/bm25-search-provider.ts[178-181]
## Suggested fix
- Change cycle prevention to avoid collapsing distinct edges:
- Option A: track visited by full `reference` coordinate (edge-level), and separately prevent cycles by ensuring a type name does not repeat within the current path.
- Option B: track the best (shortest) distance per type (`Map<typeName, depth>`), but still allow exploring multiple references at the same depth.
- Add a unit test where a type is reachable via two different root fields returning the same type; assert both paths are present (up to `MAX_PATHS`).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


15. Empty deprecation reason leaks ✓ Resolved 🐞 Bug ≡ Correctness
Description
Deprecated filtering treats @deprecated(reason: "") as non-deprecated because it checks
deprecationReason truthiness/length instead of nullness. This can leak deprecated members into
__search/__definitions and misclassify empty-after-filter types.
Code

packages/semantic-introspection/src/resolvers.ts[R89-94]

Evidence
isDeprecatedMember requires a non-empty string, and other filtering logic relies on truthiness; an
empty-string deprecation reason will slip through and be treated as non-deprecated.

packages/semantic-introspection/src/resolvers.ts[89-94]
packages/semantic-introspection/src/provider/bm25/schema-indexer.ts[86-88]
packages/semantic-introspection/src/detect-empty-after-filter.ts[64-87]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Several places treat a member as deprecated only when `deprecationReason` is truthy / non-empty. A schema can mark a member deprecated with an empty string reason (`@deprecated(reason: "")`), which is still deprecated but will bypass current checks.
## Issue Context
This affects:
- Filtering members in `filteredLookup`
- Skipping deprecated members during indexing
- Computing empty-after-filter classification
## Fix Focus Areas
- packages/semantic-introspection/src/resolvers.ts[89-94]
- packages/semantic-introspection/src/provider/bm25/schema-indexer.ts[86-88]
- packages/semantic-introspection/src/provider/bm25/schema-indexer.ts[112-115]
- packages/semantic-introspection/src/provider/bm25/schema-indexer.ts[128-133]
- packages/semantic-introspection/src/detect-empty-after-filter.ts[64-87]
## Suggested fix
- Standardize on `deprecationReason != null` (or `typeof deprecationReason === 'string'`) to detect deprecation, not truthiness/length.
- Example: `excludeDeprecated && field.deprecationReason != null`
- Example: `isDeprecatedMember` should return `v.deprecationReason != null` (including empty string).
- Add tests covering `@deprecated(reason: "")` for:
- object/interface fields
- enum values
- input fields
and verify they are excluded when `excludeDeprecated: true`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


16. Shared EMPTY_RESULT is mutable ✓ Resolved 🐞 Bug ☼ Reliability
Description
When excludeDeprecated is false, detectEmptyAfterFilter returns a module-level EMPTY_RESULT
that holds mutable Set/Map instances. If any consumer mutates the returned objects (easy from
JS), it pollutes subsequent calls, including applySemanticIntrospection with `excludeDeprecated:
false`.
Code

packages/semantic-introspection/src/detect-empty-after-filter.ts[R40-43]

Evidence
The function returns the same EMPTY_RESULT object for the common excludeDeprecated=false case,
and that object contains mutable Set/Map instances; therefore state can leak across calls.

packages/semantic-introspection/src/detect-empty-after-filter.ts[36-43]
packages/semantic-introspection/src/detect-empty-after-filter.ts[111-114]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`detectEmptyAfterFilter()` returns a shared `EMPTY_RESULT` object when `excludeDeprecated` is false. Even though the types are `Readonly*` in TypeScript, at runtime the returned `Set`/`Map` are mutable and shared across calls, so a consumer can accidentally (or intentionally) mutate global state.
## Issue Context
This function is exported as a public utility. Any mutation of the returned collections affects every later call that hits the `excludeDeprecated=false` early return.
## Fix Focus Areas
- packages/semantic-introspection/src/detect-empty-after-filter.ts[36-43]
- packages/semantic-introspection/src/detect-empty-after-filter.ts[111-114]
## Suggested fix
- Replace the `return EMPTY_RESULT;` fast path with `return { emptyTypes: new Set(), reasons: new Map() };`.
- Optionally remove `EMPTY_RESULT` entirely, or keep it only as a factory function.
- Add a regression test that mutates the returned set in one call (in JS or via TS cast) and verifies a subsequent call still returns empty.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


17. pathsToRoot drops alternatives ✓ Resolved 🐞 Bug ≡ Correctness
Description
findPathsToRoot deduplicates BFS exploration using visited keyed only by type name, so multiple
distinct incoming edges from the same parent type (e.g. Query.user and Query.admin both
returning User) are collapsed and only one path can be returned. This makes pathsToRoot
incomplete and order-dependent.
Code

packages/semantic-introspection/src/provider/bm25/bm25-search-provider.ts[R143-171]

Evidence
The algorithm computes referenceTypeName from the coordinate’s prefix and then uses it as the
visited key, causing different field coordinates with the same parent type to be skipped.

packages/semantic-introspection/src/provider/bm25/bm25-search-provider.ts[119-176]
packages/semantic-introspection/src/provider/bm25/bm25-search-provider.ts[178-181]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The BFS for `pathsToRoot` uses `visited: Set<string>` keyed by `referenceTypeName` (type name), which suppresses exploring additional distinct references that originate from the same type.
## Issue Context
`referenceTypeName` is derived by truncating the coordinate at the first dot, so `Query.user` and `Query.admin` both map to `Query` and only the first encountered reference is explored.
## Fix Focus Areas
- packages/semantic-introspection/src/provider/bm25/bm25-search-provider.ts[119-176]
- packages/semantic-introspection/src/provider/bm25/bm25-search-provider.ts[178-181]
## Suggested fix
- Change cycle prevention to avoid collapsing distinct edges:
- Option A: track visited by full `reference` coordinate (edge-level), and separately prevent cycles by ensuring a type name does not repeat within the current path.
- Option B: track the best (shortest) distance per type (`Map<typeName, depth>`), but still allow exploring multiple references at the same depth.
- Add a unit test where a type is reachable via two different root fields returning the same type; assert both paths are present (up to `MAX_PATHS`).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


18. Empty deprecation reason leaks ✓ Resolved 🐞 Bug ≡ Correctness
Description
Deprecated filtering treats @deprecated(reason: "") as non-deprecated because it checks
deprecationReason truthiness/length instead of nullness. This can leak deprecated members into
__search/__definitions and misclassify empty-after-filter types.
Code

packages/semantic-introspection/src/resolvers.ts[R89-94]

Evidence
isDeprecatedMember requires a non-empty string, and other filtering logic relies on truthiness; an
empty-string deprecation reason will slip through and be treated as non-deprecated.

packages/semantic-introspection/src/resolvers.ts[89-94]
packages/semantic-introspection/src/provider/bm25/schema-indexer.ts[86-88]
[packages/semantic-introspection/src/detect-empty-after-filter.ts[64-87]](http...

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented May 31, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (6) 📘 Rule violations (0)

Grey Divider


Action required

1. Non-null definition can be null 🐞 Bug ≡ Correctness ⭐ New
Description
__SearchResult.definition is declared non-null but its resolver returns filteredLookup(...),
which can be null (notably for empty-after-filter types when excludeDeprecated: true). This can
trigger GraphQL non-null propagation errors and null out the entire __search response.
Code

packages/semantic-introspection/src/apply.ts[R119-124]

Evidence
The schema declares definition as non-null, but the runtime resolver returns filteredLookup(...)
which can return null for empty-after-filter types. The default indexer always indexes types (even
empty-after-filter ones), so search can return a coordinate whose definition is filtered out,
violating the non-null contract.

packages/semantic-introspection/src/schema-document.ts[4-11]
packages/semantic-introspection/src/apply.ts[110-129]
packages/semantic-introspection/src/provider/bm25/schema-indexer.ts[52-64]
packages/semantic-introspection/src/detect-empty-after-filter.ts[64-90]
packages/semantic-introspection/src/resolvers.ts[71-87]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`__SearchResult.definition` is `__SchemaDefinition!` (non-null), but the resolver uses `filteredLookup()` which can return `null`. If the search provider returns a coordinate that the filter rejects (e.g. empty-after-filter type), GraphQL execution errors and the whole `__search` field can become null.

## Issue Context
- Default BM25 indexing always indexes the type document even when all its members are filtered out.
- With `excludeDeprecated: true`, `detectEmptyAfterFilter` marks such types as empty and `filteredLookup` returns null for them.

## Fix Focus Areas
- packages/semantic-introspection/src/apply.ts[62-85]
- packages/semantic-introspection/src/apply.ts[110-130]
- packages/semantic-introspection/src/resolvers.ts[71-87]
- packages/semantic-introspection/src/provider/bm25/schema-indexer.ts[52-64]
- packages/semantic-introspection/src/schema-document.ts[4-11]

## Suggested fix
1. Pass the same `LookupFilter` into `attachSearchResolver`.
2. In the `__search` resolver, `await provider.search(...)` and **drop** any results whose coordinate does not survive `filteredLookup(schema, coordinate, filter)`.
  - This guarantees `definition` resolver can never produce null.
  - It also hardens behavior for custom providers that might return unknown or filtered-out coordinates.
3. Add a unit test covering `excludeDeprecated: true` where a type has all deprecated fields and searching for that type name does not produce execution errors (and the coordinate is omitted).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Non-null definition returns null 🐞 Bug ≡ Correctness
Description
__SearchResult.definition is declared non-null, but its resolver returns filteredLookup(...)
which can return null for empty-after-filter named types (and for invalid coordinates from custom
providers). In GraphQL, resolving null for a non-null field triggers execution errors and can
null-bubble the entire __search result.
Code

packages/semantic-introspection/src/apply.ts[R115-124]

Evidence
The SDL declares definition as non-null, but the resolver can return null because
filteredLookup explicitly returns null for empty-after-filter named types, and the BM25 indexer
always indexes the type itself (so __search can return such a coordinate).

packages/semantic-introspection/src/schema-document.ts[4-11]
packages/semantic-introspection/src/apply.ts[110-129]
packages/semantic-introspection/src/resolvers.ts[71-87]
packages/semantic-introspection/src/detect-empty-after-filter.ts[36-109]
packages/semantic-introspection/src/provider/bm25/schema-indexer.ts[34-67]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`__SearchResult.definition` is non-null in the schema SDL, but the resolver can return `null` (via `filteredLookup`). This violates GraphQL’s non-null contract and will surface as runtime execution errors.
### Issue Context
- `__SearchResult.definition` is defined as `__SchemaDefinition!`.
- The resolver uses `filteredLookup(schema, parent.coordinate, filter)`.
- With `excludeDeprecated: true`, `detectEmptyAfterFilter` can classify some types as empty; `filteredLookup` then returns `null` for those type coordinates.
- The default BM25 indexer **always** indexes the type document, even when the type is empty-after-filter, so `__search` can legitimately return those coordinates.
### Fix Focus Areas
- packages/semantic-introspection/src/schema-document.ts[4-11]
- packages/semantic-introspection/src/apply.ts[110-129]
- packages/semantic-introspection/src/resolvers.ts[71-87]
- packages/semantic-introspection/src/detect-empty-after-filter.ts[36-109]
- packages/semantic-introspection/src/provider/bm25/schema-indexer.ts[34-67]
### Concrete fix options (pick one)
1) **Keep `definition` non-null and guarantee resolvability**
 - When `excludeDeprecated` is enabled, ensure the default provider does not emit coordinates that `filteredLookup` would drop.
 - Practical approach: pass `emptyTypes` into the default provider/indexer and skip indexing those type coordinates (and/or filter them out at search-time before returning results).
 - Ensure pagination cursors remain consistent if filtering occurs after scoring.
2) **Make `definition` nullable**
 - Change SDL field to `definition: __SchemaDefinition` (nullable) and adjust tests/docs accordingly.
 - This avoids runtime errors even with custom providers returning unknown/filtered coordinates.
Also consider aligning behavior/docs regarding `excludeDeprecated` with custom providers (either truly ignore it for custom providers, or document that filtering still applies to `__definitions`/nested `definition`).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

3. Shared EMPTY_RESULT is mutable 🐞 Bug ☼ Reliability ⭐ New
Description
When excludeDeprecated is false, detectEmptyAfterFilter returns a module-level EMPTY_RESULT
that holds mutable Set/Map instances. If any consumer mutates the returned objects (easy from
JS), it pollutes subsequent calls, including applySemanticIntrospection with `excludeDeprecated:
false`.
Code

packages/semantic-introspection/src/detect-empty-after-filter.ts[R40-43]

Evidence
The function returns the same EMPTY_RESULT object for the common excludeDeprecated=false case,
and that object contains mutable Set/Map instances; therefore state can leak across calls.

packages/semantic-introspection/src/detect-empty-after-filter.ts[36-43]
packages/semantic-introspection/src/detect-empty-after-filter.ts[111-114]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`detectEmptyAfterFilter()` returns a shared `EMPTY_RESULT` object when `excludeDeprecated` is false. Even though the types are `Readonly*` in TypeScript, at runtime the returned `Set`/`Map` are mutable and shared across calls, so a consumer can accidentally (or intentionally) mutate global state.

## Issue Context
This function is exported as a public utility. Any mutation of the returned collections affects every later call that hits the `excludeDeprecated=false` early return.

## Fix Focus Areas
- packages/semantic-introspection/src/detect-empty-after-filter.ts[36-43]
- packages/semantic-introspection/src/detect-empty-after-filter.ts[111-114]

## Suggested fix
- Replace the `return EMPTY_RESULT;` fast path with `return { emptyTypes: new Set(), reasons: new Map() };`.
- Optionally remove `EMPTY_RESULT` entirely, or keep it only as a factory function.
- Add a regression test that mutates the returned set in one call (in JS or via TS cast) and verifies a subsequent call still returns empty.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. pathsToRoot drops alternatives 🐞 Bug ≡ Correctness ⭐ New
Description
findPathsToRoot deduplicates BFS exploration using visited keyed only by type name, so multiple
distinct incoming edges from the same parent type (e.g. Query.user and Query.admin both
returning User) are collapsed and only one path can be returned. This makes pathsToRoot
incomplete and order-dependent.
Code

packages/semantic-introspection/src/provider/bm25/bm25-search-provider.ts[R143-171]

Evidence
The algorithm computes referenceTypeName from the coordinate’s prefix and then uses it as the
visited key, causing different field coordinates with the same parent type to be skipped.

packages/semantic-introspection/src/provider/bm25/bm25-search-provider.ts[119-176]
packages/semantic-introspection/src/provider/bm25/bm25-search-provider.ts[178-181]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The BFS for `pathsToRoot` uses `visited: Set<string>` keyed by `referenceTypeName` (type name), which suppresses exploring additional distinct references that originate from the same type.

## Issue Context
`referenceTypeName` is derived by truncating the coordinate at the first dot, so `Query.user` and `Query.admin` both map to `Query` and only the first encountered reference is explored.

## Fix Focus Areas
- packages/semantic-introspection/src/provider/bm25/bm25-search-provider.ts[119-176]
- packages/semantic-introspection/src/provider/bm25/bm25-search-provider.ts[178-181]

## Suggested fix
- Change cycle prevention to avoid collapsing distinct edges:
 - Option A: track visited by full `reference` coordinate (edge-level), and separately prevent cycles by ensuring a type name does not repeat within the current path.
 - Option B: track the best (shortest) distance per type (`Map<typeName, depth>`), but still allow exploring multiple references at the same depth.
- Add a unit test where a type is reachable via two different root fields returning the same type; assert both paths are present (up to `MAX_PATHS`).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Empty deprecation reason leaks 🐞 Bug ≡ Correctness ⭐ New
Description
Deprecated filtering treats @deprecated(reason: "") as non-deprecated because it checks
deprecationReason truthiness/length instead of nullness. This can leak deprecated members into
__search/__definitions and misclassify empty-after-filter types.
Code

packages/semantic-introspection/src/resolvers.ts[R89-94]

Evidence
isDeprecatedMember requires a non-empty string, and other filtering logic relies on truthiness; an
empty-string deprecation reason will slip through and be treated as non-deprecated.

packages/semantic-introspection/src/resolvers.ts[89-94]
packages/semantic-introspection/src/provider/bm25/schema-indexer.ts[86-88]
packages/semantic-introspection/src/detect-empty-after-filter.ts[64-87]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Several places treat a member as deprecated only when `deprecationReason` is truthy / non-empty. A schema can mark a member deprecated with an empty string reason (`@deprecated(reason: "")`), which is still deprecated but will bypass current checks.

## Issue Context
This affects:
- Filtering members in `filteredLookup`
- Skipping deprecated members during indexing
- Computing empty-after-filter classification

## Fix Focus Areas
- packages/semantic-introspection/src/resolvers.ts[89-94]
- packages/semantic-introspection/src/provider/bm25/schema-indexer.ts[86-88]
- packages/semantic-introspection/src/provider/bm25/schema-indexer.ts[112-115]
- packages/semantic-introspection/src/provider/bm25/schema-indexer.ts[128-133]
- packages/semantic-introspection/src/detect-empty-after-filter.ts[64-87]

## Suggested fix
- Standardize on `deprecationReason != null` (or `typeof deprecationReason === 'string'`) to detect deprecation, not truthiness/length.
 - Example: `excludeDeprecated && field.deprecationReason != null`
 - Example: `isDeprecatedMember` should return `v.deprecationReason != null` (including empty string).
- Add tests covering `@deprecated(reason: "")` for:
 - object/interface fields
 - enum values
 - input fields
 and verify they are excluded when `excludeDeprecated: true`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (1)
6. PathsToRoot misses alternatives 🐞 Bug ≡ Correctness
Description
getPathsToRoot’s BFS uses a visited set keyed only by type name, so multiple distinct references
from the same parent type (e.g., Query.user and Query.adminUser both returning User) are
pruned after the first. This makes pathsToRoot incomplete even when reverseMap contains multiple
valid coordinates.
Code

packages/semantic-introspection/src/provider/bm25/bm25-search-provider.ts[R138-172]

Evidence
The reverse adjacency map can legitimately contain multiple coordinates from the same type, but
findPathsToRoot prunes further exploration after the first because visited is keyed only by
referenceTypeName.

packages/semantic-introspection/src/provider/bm25/bm25-search-provider.ts[119-176]
packages/semantic-introspection/src/provider/bm25/schema-indexer.ts[96-104]
packages/semantic-introspection/src/provider.ts[40-46]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`findPathsToRoot` de-duplicates traversal using `visited: Set<string>` of *type names*. This suppresses distinct valid paths when multiple coordinates share the same parent type name (common in real schemas where multiple root fields return the same type).
### Issue Context
- `reverseMap` stores multiple coordinates per return type (one per field).
- Current BFS marks `Query` visited after the first `Query.*` reference, preventing exploration of additional `Query.*` references that would yield additional paths.
### Fix Focus Areas
- packages/semantic-introspection/src/provider/bm25/bm25-search-provider.ts[119-176]
- packages/semantic-introspection/src/provider/bm25/schema-indexer.ts[96-104]
- packages/semantic-introspection/src/provider.ts[40-46]
### Suggested fix
- Track visited state at a finer granularity than just `typeName` (e.g., by `reference` coordinate, or by `(typeName, parentCoordinate)`), so multiple distinct incoming edges from the same type can still produce multiple paths.
- Keep the `MAX_PATHS` bound to avoid blowups, but allow collecting multiple distinct root-field paths when they exist.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces the @graphql-hive/semantic-introspection package, a TypeScript port of HotChocolate's Semantic Introspection that adds __search and __definitions fields to GraphQL schemas for AI agent discovery. It includes a BM25-based search provider, a BFS path-to-root traversal, and a utility to detect empty types after filtering deprecated members. The review feedback highlights a bug in the BFS path-to-root algorithm where a global visited set limits path discovery, and recommends replacing Node.js-specific Buffer usages with standard Web APIs to improve edge runtime portability.

Comment thread packages/semantic-introspection/src/provider/bm25/bm25-search-provider.ts Outdated
Comment thread packages/semantic-introspection/src/apply.ts
Comment thread packages/semantic-introspection/src/apply.ts
@zensucht
zensucht marked this pull request as draft May 31, 2026 21:00
zensucht added 5 commits May 31, 2026 17:17
… BFS

A single function-scoped `visited` set caused parallel routes to root
to be silently dropped: as soon as one path reached `Query`, `Query`
entered `visited` and every other path through it (or through any
intermediate type seen) was skipped. With the default `maxPaths=5`,
typical schemas got at most one path returned regardless.

Each BFS queue entry now carries its own visited set, allowing
distinct paths to legitimately pass through the same intermediate or
root type while still preventing cycles within a single path.

The existing "finds multiple paths from a deeply reachable type"
test asserted only `>= 1` paths, which the buggy code satisfied;
strengthened to compare exact path lists, and added a second case
where two root fields return the same type.
… deprecated

`@deprecated(reason: "")` is a valid deprecation: graphql-js sets
`deprecationReason` to a string whenever the directive is applied,
including the empty-string form. Truthiness / length checks across
the package mis-classified those members as non-deprecated, leaking
them into `__search` and `__definitions` and miscalculating
empty-after-filter classification.

Standardized on `typeof v.deprecationReason === 'string'` across:
- `isDeprecatedMember` in resolvers.ts
- three filter sites in the BM25 schema indexer
- four surviving-member filters in detectEmptyAfterFilter

Regression tests cover the empty-string form for object / interface /
input / enum surfaces.
…otect non-null `definition`

`__SearchResult.definition` is declared `__SchemaDefinition!` in the
SDL, but the default BM25 indexer always indexes the type document —
including types that become empty-after-filter under
`excludeDeprecated: true`. The resolver then ran `filteredLookup`
on those coordinates, returned `null`, and triggered GraphQL
non-null propagation that nulled the entire `__search` field.

The fix moves the filter to the `__search` resolver itself: after
`provider.search(...)`, results whose coordinate fails
`filteredLookup` are dropped before the page is returned. The
cursor still advances past the dropped raw positions, so pagination
remains correct; a page can come back shorter than `first`.

This also hardens the contract against custom providers that emit
synthetic or stale coordinates — those are now silently dropped
rather than crashing the whole field. The previous test
"uses a custom provider when one is supplied via options" relied
on the bug by returning a coordinate that did not exist in the
schema; updated to a real coordinate, and a dedicated test added
for the misbehaving-provider case.
…terFilter fast path

The `excludeDeprecated: false` branch returned a module-level shared
`EMPTY_RESULT` whose `Set` and `Map` are mutable at runtime despite
the `Readonly*` interface. Any caller mutating the returned
collections would pollute every subsequent call of a public utility.

Each call now constructs and returns a fresh empty result; the
`EMPTY_RESULT` constant is removed. Regression test mutates the
result of one call and verifies the next call is uncontaminated.
… in cursor codec

Cursor encode/decode used `Buffer.alloc`, `writeInt32LE`,
`Buffer.from`, and `readInt32LE` — Node-only globals that aren't
available in Cloudflare Workers, Deno, the browser, or any other
runtime hive-gateway intends to support. Swapped to standard Web
APIs (`Uint8Array` + `DataView` + `btoa` / `atob`), all available
in Node ≥16 and ubiquitous elsewhere.

Validation logic is unchanged: `atob` throws on malformed base64
(narrowly caught and rethrown with the same message); byte-length,
in-range, and canonical-roundtrip checks remain explicit
`if (...) throw` statements rather than being wrapped in a catch-all
`try/catch`. Existing cursor tests cover the behavior.
@zensucht

Copy link
Copy Markdown
Author

/review

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented May 31, 2026 •

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 84ee76a

@qodo-code-review

Copy link
Copy Markdown

PR Reviewer Guide 🔍

Warning

/review is deprecated. Use /agentic_review instead (removal date not yet scheduled).

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 3 🔵🔵🔵⚪⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Runtime Compatibility

Cursor encoding/decoding relies on global btoa/atob Web APIs. This may break in some Node.js versions/environments where they are not available by default, despite the intent to be cross-runtime. Consider either using a small internal base64 helper that works everywhere (e.g., Buffer when available, otherwise fallback) or explicitly documenting/guarding the required runtime support.

/** Little-endian int32 base64. Web APIs only — runs on Node, Bun, Deno, Workers, and the browser. */
function encodeCursor(offset: number): string {
  const bytes = new Uint8Array(4);
  new DataView(bytes.buffer).setInt32(0, offset, true);
  return btoa(String.fromCharCode(...bytes));
}

function decodeCursor(cursor: string, resultCount: number): number {
  let binary: string;
  try {
    binary = atob(cursor);
  } catch {
    // `atob` throws on malformed base64; everything else is checked below.
    throw new Error(`Invalid search cursor: ${JSON.stringify(cursor)}`);
  }
  if (binary.length !== 4) {
    throw new Error(`Invalid search cursor: ${JSON.stringify(cursor)}`);
  }
  const bytes = new Uint8Array(4);
  for (let i = 0; i < 4; i++) {
    bytes[i] = binary.charCodeAt(i);
  }
  const offset = new DataView(bytes.buffer).getInt32(0, true);
  if (offset < 0 || offset > resultCount) {
    throw new Error(`Invalid search cursor: ${JSON.stringify(cursor)}`);
  }
  // Reject non-canonical encodings — only the exact roundtrip is valid.
  if (encodeCursor(offset) !== cursor) {
    throw new Error(`Invalid search cursor: ${JSON.stringify(cursor)}`);
  }
  return offset;
Schema Extension Risk

extendSchema(..., { assumeValid: true }) bypasses SDL validation. This is needed for __* names, but it also skips other checks (e.g., accidental type/field collisions, invalid extensions) and could mask incompatibilities if the host schema already defines __search/__definitions (or other conflicting __* items). It’s worth validating/handling collisions explicitly before extending.

// `assumeValid` lets graphql-js accept the `__`-prefixed names the RFC adds.
const extended = extendSchema(
  schema,
  buildSchemaExtensionDocument(queryType.name),
  { assumeValid: true },
);

const excludeDeprecated = options.excludeDeprecated === true;
const provider =
  options.provider ?? new Bm25SearchProvider(extended, { excludeDeprecated });
const { emptyTypes } = detectEmptyAfterFilter(extended, {
  excludeDeprecated,
});
const filter: LookupFilter = { excludeDeprecated, emptyTypes };

attachSearchResolver(extended, queryType.name, provider, filter);
attachDefinitionsResolver(extended, queryType.name, filter);
attachSearchResultResolvers(extended, provider, filter);
attachDefinitionUnionResolveType(extended);

return extended;
Performance/Memory

findPathsToRoot performs BFS with a per-queue-entry visited set copy, which can become expensive on large schemas or highly connected graphs. The MAX_PATHS cap helps, but worst-case queue growth and repeated Set cloning may still be significant. Consider optimizations (e.g., sharing visited structures, limiting depth, or early exit heuristics) and/or documenting expected complexity.

function findPathsToRoot(
  data: SearchData,
  coordinate: SchemaCoordinate,
  maxPaths: number,
): SchemaCoordinate[][] {
  const { rootTypeNames, reverseMap } = data;
  const startTypeName = getCoordinateTypeName(coordinate);
  const isFieldCoord = coordinate.includes('.');
  const paths: SchemaCoordinate[][] = [];

  // If the start type IS a root, the path is just the coordinate itself
  // (for field coordinates) or empty (for type coordinates).
  if (rootTypeNames.has(startTypeName)) {
    if (isFieldCoord) {
      paths.push([coordinate]);
    }
    return paths;
  }

  // BFS over the reverse adjacency, head-indexed for O(V + E) dequeue.
  // Each queue entry carries its own visited set so distinct paths can
  // legitimately pass through the same intermediate or root type — a
  // single global `visited` would collapse parallel routes (e.g. two
  // root fields returning the same type) into one.
  const queue: {
    typeName: string;
    path: SchemaCoordinate[];
    visited: ReadonlySet<string>;
  }[] = [
    { typeName: startTypeName, path: [], visited: new Set([startTypeName]) },
  ];
  let head = 0;

  while (head < queue.length && paths.length < maxPaths) {
    const {
      typeName: currentType,
      path: currentPath,
      visited,
    } = queue[head++]!;
    const references = reverseMap.get(currentType);
    if (!references) {
      continue;
    }

    for (const reference of references) {
      const referenceTypeName = getCoordinateTypeName(reference);
      if (visited.has(referenceTypeName)) {
        continue;
      }

      const newPath: SchemaCoordinate[] = [reference, ...currentPath];

      if (rootTypeNames.has(referenceTypeName)) {
        if (isFieldCoord) {
          newPath.push(coordinate);
        }
        paths.push(newPath);
        if (paths.length >= maxPaths) {
          break;
        }
      } else {
        const newVisited = new Set(visited);
        newVisited.add(referenceTypeName);
        queue.push({
          typeName: referenceTypeName,
          path: newPath,
          visited: newVisited,
        });
      }
    }
  }

  return paths;
}

Comment thread packages/semantic-introspection/src/apply.ts Outdated
…provider page is filtered

The resolver-layer filter introduced for the non-null `definition`
guarantee could lose pagination entirely: if every coordinate in a
provider page failed `filteredLookup`, the resolver returned `[]`
with no cursor, and the client had no way to advance to the next
provider page — later valid hits became unreachable.

The `__search` resolver now loops, fetching successive provider
pages until it accumulates `first` survivors or the provider
exhausts. A safety guard (`SEARCH_MAX_PROVIDER_PAGES = 16`) plus
a non-advancing-cursor check prevent runaway loops against
misbehaving providers.

Two regression tests: one verifies the loop reaches a real result
on a later provider page after a fully-filtered first page; the
other confirms the safety guard short-circuits a provider that
returns the same cursor forever.
zensucht added 2 commits May 31, 2026 17:49
…as `__search` / `__definitions`

`applySemanticIntrospection` uses `extendSchema(..., { assumeValid:
true })` so graphql-js will accept the `__`-prefixed RFC names.
That same flag skips graphql-js's collision detection — applying
the function twice on a schema (or running it against a host that
constructed `__search` / `__definitions` itself via constructor
APIs) would silently produce a duplicate-extension schema instead
of failing.

Added an explicit precondition that inspects the query type's
fields before extending and throws a clean error on collision.
Regression test exercises the double-application case.
…ToRoot BFS

The multi-path BFS allocated a fresh `visited` Set on every branch
(O(V) copy each), which scales poorly on richly-connected schemas.
Replaced with a small `pathRevisits` helper that walks the
in-flight path linearly — paths are short in practice (≤ schema
depth) and the linear scan is cheaper than the Set construction.

Behavior is identical (same cycle-prevention semantics: a type
name may not repeat within a single path, but distinct paths may
legitimately share intermediate or root types). All existing BFS
tests, including the two regressions added in ca2b871, pass
unchanged.
@zensucht

Copy link
Copy Markdown
Author

/agentic_review

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented May 31, 2026 •

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit d4a93be

1 similar comment
@qodo-code-review

qodo-code-review Bot commented May 31, 2026 •

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit d4a93be

Comment on lines +99 to +115
) => {
const target = args.first;
const minScore = args.minScore ?? null;
const survivors: SchemaSearchResult[] = [];
let after = args.after ?? null;

// Loop until we have `first` survivors or the provider is exhausted.
// A naive single-page fetch would lose pagination entirely when a
// whole page is filtered out: the client gets `[]` with no cursor
// and can't reach later valid hits.
for (let page = 0; page < SEARCH_MAX_PROVIDER_PAGES; page++) {
const rawPage = await provider.search(
args.query,
target,
after,
minScore,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Unbounded __search page size 🐞 Bug ⛨ Security

__search forwards args.first directly to provider.search (and may do so up to 16 times), so
clients can request extremely large pages and cause excessive CPU/memory usage in the default
provider or any custom provider. This is a DoS vector in deployments that expose __search without
independent query complexity / max-list-size controls.
Agent Prompt
### Issue description
The `__search` resolver forwards unbounded `first` (and other args) to the provider. A malicious client can pass a huge `first` value, forcing large allocations/processing (especially with the default `Bm25SearchProvider` which does not cap `first`).

### Issue Context
- The SDL sets `first: Int! = 10` but provides no maximum.
- The resolver currently does not validate or cap `first` before calling the provider.

### Fix Focus Areas
- packages/semantic-introspection/src/apply.ts[91-143]

### Suggested fix
- Add argument validation in the resolver:
  - Ensure `first` is an integer > 0.
  - Enforce a hard max (e.g. `MAX_FIRST = 100`), either by throwing a GraphQL error when exceeded or clamping (prefer throwing to avoid surprising partial results).
  - (Optional but recommended) validate `minScore` is within `[0,1]` when provided.
  - (Optional) validate `query` length (e.g. 1024) to protect custom providers too.
- Ensure the capped/validated value is the one used for `target` and passed to `provider.search`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This decision belongs in the gateway's security config (useMaxDepth etc). Keeping as is so behavior doesn't conflict with server configuration.

Comment thread packages/semantic-introspection/src/apply.ts Outdated
zensucht added 2 commits May 31, 2026 18:36
…dSchema

The earlier collision guard only covered field-name collisions on the
query type, but the extension SDL also defines two named types
(`__SearchResult` object and `__SchemaDefinition` union). A host
schema that already defined either name would, under
`extendSchema({ assumeValid: true })`, silently produce a
duplicate-extension schema rather than failing — the same failure
class the field-collision guard was meant to prevent.

The precondition now derives the type-name list from the actual
extension DocumentNode (via a small `extensionTypeNames` helper
that walks the six `*_TYPE_DEFINITION` kinds), so the guard stays in
sync if the SDL ever adds more type definitions. The thrown error
names the specific conflicting type.

Regression test constructs a host schema with `__SearchResult` via
`buildSchema(..., { assumeValid: true })` — graphql-js rejects the
`__` prefix in user-defined types otherwise — and asserts a clean
throw.
…ncapped at the library layer

Adds a 'Security: client-controlled `first`' section pointing
integrators at the gateway-level controls already documented in
`packages/gateway/src/cli.ts`: `@escape.tech/graphql-armor-max-tokens`
and `@escape.tech/graphql-armor-max-depth`. The package does not
duplicate these controls because that would conflict with — and
potentially over-restrict — gateway configuration that's already
in place.

Distinguishes from `MAX_QUERY_LENGTH` (provider-internal cost guard
on tokenizer input), which is a separate concern.
@zensucht
zensucht marked this pull request as ready for review May 31, 2026 22:57
@qodo-code-review

qodo-code-review Bot commented May 31, 2026 •

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 6ae18ca

1 similar comment
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented May 31, 2026 •

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 6ae18ca

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Introduces a new @graphql-hive/semantic-introspection workspace package that augments any GraphQLSchema with __search and __definitions fields so AI agents can discover schema capabilities by intent. It's a TypeScript port of HotChocolate's .NET reference implementation, with an additive opt-in excludeDeprecated flag that filters the agent-facing surface while leaving standard introspection untouched.

Changes:

  • New package with applySemanticIntrospection(schema, opts) non-invasively extending the schema, a default BM25 search provider (with pluggable SchemaSearchProvider interface), and a detectEmptyAfterFilter utility.
  • Adds __SearchResult, __SchemaDefinition union, and __search / __definitions query fields; collision-checked against the host schema before extendSchema(..., { assumeValid: true }).
  • Wires up workspace integration: tsconfig path alias, yarn lockfile entry, minor changeset, and 86 unit tests across 8 files.

Reviewed changes

Copilot reviewed 23 out of 25 changed files in this pull request and generated no comments.

Show a summary per file
File Description
packages/semantic-introspection/package.json New package manifest (ESM/CJS dual exports, peer `graphql ^15.9
packages/semantic-introspection/src/apply.ts Schema extension entry point; collision checks, resolver wiring, paginated filter loop with safety break.
packages/semantic-introspection/src/schema-document.ts SDL fragments and extend type Query builder.
packages/semantic-introspection/src/resolvers.ts Coordinate lookup, deprecated/empty filtering, __SchemaDefinition union resolver (duck-typed).
packages/semantic-introspection/src/detect-empty-after-filter.ts Fixed-point classifier for types empty under the deprecated filter.
packages/semantic-introspection/src/provider.ts SchemaSearchProvider interface and result types.
packages/semantic-introspection/src/provider/bm25/bm25-search-provider.ts Default BM25 provider; lazy index, capped query length, base64 int32 cursors, BFS path-to-root.
packages/semantic-introspection/src/provider/bm25/bm25-index.ts Inverted index + BM25 scoring.
packages/semantic-introspection/src/provider/bm25/schema-indexer.ts Walks schema producing BM25 docs + reverse adjacency map.
packages/semantic-introspection/src/provider/bm25/tokenizer.ts ASCII-oriented tokenizer with camel/Pascal splitting.
packages/semantic-introspection/src/provider/bm25/document.ts Bm25Document shape.
packages/semantic-introspection/src/index.ts Public re-exports.
packages/semantic-introspection/tests/*.spec.ts 8 test files covering apply, conformance, fixture matrix, empty detection, and BM25.
packages/semantic-introspection/README.md Usage, options, deprecated handling, MCP recommendations, security note.
packages/semantic-introspection/CHANGELOG.md Placeholder header.
.changeset/semantic-introspection-initial.md Minor changeset for the initial release.
tsconfig.json Adds @graphql-hive/semantic-introspection path alias.
yarn.lock Adds workspace entry for the new package.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants