From 5303199163049131aa2d9b59b23b57e179984fc4 Mon Sep 17 00:00:00 2001 From: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> Date: Fri, 6 Nov 2026 20:16:31 +0800 Subject: [PATCH] feat(api-contract): inspect local GraphQL SDL and field references (#9140) * feat(api-contract): inspect local GraphQL SDL and field references * fix(api-contract): retain caller metadata and bound selection work --- docs/api-contracts.md | 80 +- docs/mcp-contracts.md | 21 +- licenses/npm-dependencies.txt | 7 +- licenses/npm-runtime-notices.txt | 32 +- package-lock.json | 10 + package.json | 1 + src/application/EvidenceBundleCommands.ts | 6 +- .../apiContract/ApiContractRead.ts | 22 +- .../apiContract/ApiContractService.ts | 43 +- .../apiContract/ApiContractTraversal.ts | 44 + .../apiContract/GraphqlContractImport.ts | 207 +++ .../GraphqlContractService.test.ts | 304 +++++ .../investigation/EvidenceLedger.test.ts | 46 + .../investigation/EvidenceLedger.ts | 13 +- src/cli/analysisEvidenceDelivery.ts | 14 +- src/cli/apiContractCommand.ts | 41 +- src/cli/cliEvidenceCallerPath.ts | 8 +- src/contracts/analysisViewToolContracts.ts | 2 +- src/contracts/apiContractToolContracts.ts | 19 +- src/contracts/toolEffects.test.ts | 2 + src/domain/analysisErrorBase.ts | 2 + src/domain/analysisView/analysisView.ts | 2 + .../analysisView/analysisViewRequest.ts | 2 + src/domain/analysisView/analysisViewResult.ts | 18 +- src/domain/analysisView/apiContractView.ts | 12 +- .../analysisView/graphqlContractView.ts | 293 +++++ src/domain/apiContract/GraphqlDocuments.ts | 88 ++ src/domain/apiContract/GraphqlInventory.ts | 180 +++ .../apiContract/GraphqlOperationFacts.ts | 210 +++ src/domain/apiContract/GraphqlSchemaFacts.ts | 106 ++ src/domain/apiContract/apiContractQuery.ts | 60 +- src/domain/apiContract/graphqlContract.ts | 142 ++ .../apiContract/graphqlContractQuery.ts | 74 ++ .../apiContract/parseGraphqlContract.test.ts | 340 +++++ .../apiContract/parseGraphqlContract.ts | 105 ++ src/domain/evidence.test.ts | 39 + src/domain/evidence.ts | 30 +- .../registerApplicationTools/apiContract.ts | 6 +- .../applications/graphqlContract.test.ts | 415 ++++++ .../cli/cliEvidenceCallerPath.test.ts | 38 +- .../filesystem/evidenceBundleFiles.test.ts | 8 +- tests/fixtures/cliMcpEvidenceParity.ts | 9 +- tests/fixtures/graphql/LICENSE | 21 + tests/fixtures/graphql/README.md | 15 + tests/fixtures/graphql/swapi.graphql.txt | 1166 +++++++++++++++++ 45 files changed, 4204 insertions(+), 99 deletions(-) create mode 100644 src/application/apiContract/ApiContractTraversal.ts create mode 100644 src/application/apiContract/GraphqlContractImport.ts create mode 100644 src/application/apiContract/GraphqlContractService.test.ts create mode 100644 src/domain/analysisView/graphqlContractView.ts create mode 100644 src/domain/apiContract/GraphqlDocuments.ts create mode 100644 src/domain/apiContract/GraphqlInventory.ts create mode 100644 src/domain/apiContract/GraphqlOperationFacts.ts create mode 100644 src/domain/apiContract/GraphqlSchemaFacts.ts create mode 100644 src/domain/apiContract/graphqlContract.ts create mode 100644 src/domain/apiContract/graphqlContractQuery.ts create mode 100644 src/domain/apiContract/parseGraphqlContract.test.ts create mode 100644 src/domain/apiContract/parseGraphqlContract.ts create mode 100644 tests/acceptance/applications/graphqlContract.test.ts create mode 100644 tests/fixtures/graphql/LICENSE create mode 100644 tests/fixtures/graphql/README.md create mode 100644 tests/fixtures/graphql/swapi.graphql.txt diff --git a/docs/api-contracts.md b/docs/api-contracts.md index 76cf4b9cde..bc9e752db8 100644 --- a/docs/api-contracts.md +++ b/docs/api-contracts.md @@ -1,12 +1,17 @@ # Offline API contract inventory -`inspect_api_contract` / `rea inspect-api-contract` reads one local OpenAPI +`inspect_api_contract` / `rea inspect-api-contract` supports local OpenAPI JSON +and GraphQL SDL (`format: "graphql-sdl"`). Neither profile contacts endpoints. + +## OpenAPI JSON + +The default profile reads one local OpenAPI 3.0, 3.1 or 3.2 JSON document. It inventories inline operation slots under `paths`; the facts describe declarations, not implemented, reachable or authorized runtime behavior. YAML, AsyncAPI, callbacks/webhooks, schema graphs, contract diffs and traffic correlation are outside this version. -The importer never resolves references, opens sibling documents, evaluates +The OpenAPI importer never resolves references, opens sibling documents, evaluates examples or contacts declared servers. OpenAPI 3.2 `query` and `additionalOperations` follow the [Path Item contract](https://spec.openapis.org/oas/v3.2.1.html#path-item-object), including case-sensitive additional HTTP method spelling. @@ -100,3 +105,74 @@ public CLI/MCP tests. These check advertised schemas, complete/selected parity, retained paging, partial references, zero requests to a loopback reference server and unchanged source bytes. They do not establish runtime endpoint behavior or full OpenAPI conformance. + +## GraphQL SDL and operation documents + +Select `format: "graphql-sdl"` for SDL. Optionally supply `operations_path` to +one local document containing operations and fragments. Both MCP paths are +absolute; CLI accepts relative spellings: + +```sh +rea inspect-api-contract ./schema.graphql --contractFormat graphql-sdl \ + --operationsPath ./queries.graphql --detail summary \ + --view '{"kind":"graphql-contract","collection":"references","limit":20}' \ + --evidence_output ./graphql.evidence.json --json +``` + +Collections are `types`, `fields`, `definitions`, `references`, `fragment-spreads` +and `diagnostics`. Each supports `fields`, `offset` and `limit` (default 64). +Exact batch filters include type/field/definition/reference IDs, type names, +`field_keys` such as `Query.viewer`, owning type names, binding/spread states +and diagnostic codes. Filters intersect; an empty array selects nothing. +Identity, source and applicable uncertainty fields always remain. Retained pages +and bundle-based queries use the same `graphql-contract` views without rereading +SDL or rebuilding its schema model. + +Types and fields retain individual declaration occurrences, including extensions, +list/non-null wrappers, implemented interfaces and union member names. Operation +and fragment definitions own field references with aliases and parent-reference +IDs. Each reference links to a supplied SDL field occurrence, remains `model-only` +for a library-provided field such as `__typename`, or records an exact unknown +reason. Rejected schema construction keeps declarations but cannot supply trusted +bindings. Missing or duplicate fragment names remain explicit. Fragments are +visited once in their declared type scope and linked by spread edges; cycles +are never recursively expanded. + +The versioned `graphql-sdl-field-bindings-v1` profile records the GraphQL library +version. It performs syntax parsing and schema construction for field resolution; +it does not establish whole-schema/operation validity, spread applicability, +reachability, authorization or endpoint behavior. Arguments, defaults, +descriptions, directive behavior, enum value inventory, introspection JSON, +federation composition and version comparison remain outside this profile. + +Every fact has a document role, SHA-256, UTF-16 start/end offsets and parser token +line/column. Offsets address decoded UTF-8 text, including an optional BOM, rather +than byte positions. The two files are independently verified snapshots, not an +atomic pair. Fact IDs identify source occurrences; `field_key` separately supports +exact name-based joins and does not assert equivalence across revisions. CLI +retains its auxiliary path spelling in `caller_input_paths.operations_path`, +outside semantic identity, as well as its primary `caller_input_path`. + +Malformed operation text retains the SDL inventory with partial coverage and a +source-located diagnostic. A primary parser refusal or later resource/cancellation +failure retains known source identities and available interpreted Evidence; +syntax diagnostics do not quote source text. Complete Evidence contains facts, +not original document bytes. An exhausted page never upgrades source coverage. + +Each file has the same configurable maximum of 32 MiB. Conservatively charged +facts share a 32 MiB allowance; text/AST allocation is admitted against available +heap. The parser admits at most 262,144 lexical tokens and 65,536 comments per +document; subsequent iterative AST admission limits traversal depth to 128. +These are REA resource policies, not GraphQL language limits. Very deep input can +be refused by the parser before AST admission. Selected replies reduce transport +and retained queries reuse immutable facts; they do not avoid the initial parse. + +The committed SWAPI SDL is an unchanged public application fixture with provenance +in `tests/fixtures/graphql/README.md`. It supplies 53 types and 243 fields; +verification does not execute its API. + +Large complete inventories can exceed the default MCP response budget even when +selected pages fit. Use `export_evidence_bundle` with a caller-selected local +path for complete retained Evidence, or CLI complete JSON/bundle publication. +A transport refusal does not erase a completed retained analysis. Narrow pages +and source coverage remain available on the same continuing connection. diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index bb0e7dc7f6..1d50599359 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -574,15 +574,18 @@ after target content was read. ## Offline API contract declarations -`inspect_api_contract` reads a local OpenAPI 3.0/3.1/3.2 JSON document without -an active target or any requests to declared servers or references. Default -summary delivery retains complete declaration Evidence; an optional -`api-contract` view selects operations, unresolved Path Items or diagnostics in -this call. Exact filters, field selection and paging share the retained -`inspect_analysis_view` projector. Coverage describes inline paths only and -never establishes runtime implementation or full specification conformance. -See [offline API contracts](api-contracts.md) for identities, source pointers, -resource bounds and CLI publication. +`inspect_api_contract` reads local OpenAPI 3.0/3.1/3.2 JSON (default) or +GraphQL SDL (`format: "graphql-sdl"`) without an active target or network requests. +OpenAPI inventories inline path operations and unresolved Path Items. GraphQL +inventories type/field declarations; optional `operations_path` adds operation +owners, aliases, SDL field bindings and explicit fragment-spread edges without +expanding fragments. Unresolved bindings and unavailable models stay explicit. +Default summaries retain complete Evidence; `api-contract` or `graphql-contract` +views select facts immediately with exact filters, fields and paging. Retained +`inspect_analysis_view` uses the same projector without reopening inputs. +Coverage describes examined declarations and does not establish runtime behavior +or full specification/operation validity. See [offline API contracts](api-contracts.md) +for source identities, parser allowances, supported scope and CLI publication. ## Retained application Evidence inputs diff --git a/licenses/npm-dependencies.txt b/licenses/npm-dependencies.txt index 1a32572080..c6ba7868d5 100644 --- a/licenses/npm-dependencies.txt +++ b/licenses/npm-dependencies.txt @@ -2,7 +2,7 @@ REA npm dependency inventory Prepared from the root package-lock.json; refresh this inventory when locked dependencies change. License identifiers are upstream lockfile declarations, not a legal assessment. Runtime includes transitive and optional platform packages; development includes build, test and website tooling. -Locked package entries: 963; runtime: 180. +Locked package entries: 964; runtime: 181. Full runtime copyright/license/NOTICE texts: npm-runtime-notices.txt. @algolia/abtesting@1.25.0 | development | MIT @@ -2825,6 +2825,11 @@ gopd@1.2.0 | runtime | MIT Package: https://www.npmjs.com/package/gopd/v/1.2.0 Archive: https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz Integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== +graphql@17.1.0 | runtime | MIT + Lockfile path: node_modules/graphql + Package: https://www.npmjs.com/package/graphql/v/17.1.0 + Archive: https://registry.npmjs.org/graphql/-/graphql-17.1.0.tgz + Integrity: sha512-JKCOa7rSQNrket/JFhuua86hljUO+IIpyUHfKxlKD6FEfbrLX8jc1FDrA0KPKI+s8OspBCF/5npYccAICHXPpQ== handlebars@4.7.10 | development | MIT Lockfile path: node_modules/handlebars Package: https://www.npmjs.com/package/handlebars/v/4.7.10 diff --git a/licenses/npm-runtime-notices.txt b/licenses/npm-runtime-notices.txt index 2c443c2e9f..a05826ff50 100644 --- a/licenses/npm-runtime-notices.txt +++ b/licenses/npm-runtime-notices.txt @@ -1,7 +1,7 @@ REA third-party npm runtime copyright, license and NOTICE texts Collected from integrity-verified locked npm archives. Release-specific supplements and upstream notice gaps are identified in their entries. External engines are separately obtained; see THIRD_PARTY_NOTICES.md. -Runtime dependency fingerprint (SHA-256): 09f8570cf168ddd3ad7c877dbcdd1ef5013de3af884a37fd27e6da3b99b49664 +Runtime dependency fingerprint (SHA-256): 13e2e43ee348b0cc210699dff53278d47e9e94c65934796c82eabc912f7c3938 ===== @babel/helper-string-parser@8.0.6 ===== Declared license: MIT @@ -4249,6 +4249,36 @@ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +===== graphql@17.1.0 ===== +Declared license: MIT +Archive: https://registry.npmjs.org/graphql/-/graphql-17.1.0.tgz +Integrity: sha512-JKCOa7rSQNrket/JFhuua86hljUO+IIpyUHfKxlKD6FEfbrLX8jc1FDrA0KPKI+s8OspBCF/5npYccAICHXPpQ== +Scope: npm runtime dependency, including optional platform packages. + +--- package/LICENSE --- + +MIT License + +Copyright (c) GraphQL Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + ===== has-property-descriptors@1.0.2 ===== Declared license: MIT Archive: https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz diff --git a/package-lock.json b/package-lock.json index bf410ac47d..e71a1b96d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "cross-spawn": "7.0.6", "diff": "9.0.0", "evmole": "0.9.3", + "graphql": "17.1.0", "har-schema": "2.0.0", "ignore": "7.0.6", "incur": "0.6.1", @@ -8108,6 +8109,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graphql": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-17.1.0.tgz", + "integrity": "sha512-JKCOa7rSQNrket/JFhuua86hljUO+IIpyUHfKxlKD6FEfbrLX8jc1FDrA0KPKI+s8OspBCF/5npYccAICHXPpQ==", + "license": "MIT", + "engines": { + "node": "^22.0.0 || ^24.0.0 || >=26.0.0" + } + }, "node_modules/handlebars": { "version": "4.7.10", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.10.tgz", diff --git a/package.json b/package.json index a2849ebf63..d9ac233a28 100644 --- a/package.json +++ b/package.json @@ -372,6 +372,7 @@ "cross-spawn": "7.0.6", "diff": "9.0.0", "evmole": "0.9.3", + "graphql": "17.1.0", "har-schema": "2.0.0", "ignore": "7.0.6", "incur": "0.6.1", diff --git a/src/application/EvidenceBundleCommands.ts b/src/application/EvidenceBundleCommands.ts index 26bf80e437..37413f48e2 100644 --- a/src/application/EvidenceBundleCommands.ts +++ b/src/application/EvidenceBundleCommands.ts @@ -139,7 +139,11 @@ export const deliverAnalysisEvidence = async ( // inherit the parent's selected input path. const selected = detail === "summary" && evidence.caller_input_path !== undefined - ? withEvidenceCallerInputPath(delivered.value, evidence.caller_input_path) + ? withEvidenceCallerInputPath( + delivered.value, + evidence.caller_input_path, + evidence.caller_input_paths, + ) : delivered.value; const published = await publishEvidenceDelivery({ bundle, diff --git a/src/application/apiContract/ApiContractRead.ts b/src/application/apiContract/ApiContractRead.ts index 1382042a1e..d44cf43193 100644 --- a/src/application/apiContract/ApiContractRead.ts +++ b/src/application/apiContract/ApiContractRead.ts @@ -22,11 +22,20 @@ import { API_CONTRACT_OPERATION as operation, API_CONTRACT_PROVIDER, } from "../../domain/apiContract/apiContract.js"; -import type { InspectApiContractInput } from "../../domain/apiContract/apiContractQuery.js"; import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; import { err, ok } from "../../domain/result.js"; -const inputFailure = (path: string, cause: unknown, message: string) => +interface ContractDocumentInput { + readonly path: string; + readonly max_file_bytes: number; + readonly parameter?: "path" | "operations_path"; +} +const inputFailure = ( + path: string, + cause: unknown, + message: string, + parameter = "path", +) => new AnalysisInputError( operation, { @@ -37,7 +46,7 @@ const inputFailure = (path: string, cause: unknown, message: string) => }, [ { - path: ["path"], + path: [parameter], reason: "invalid_format", message: `${message} (${path})`, }, @@ -46,7 +55,7 @@ const inputFailure = (path: string, cause: unknown, message: string) => const readFailure = ( failure: unknown, - input: InspectApiContractInput, + input: ContractDocumentInput, ): AnalysisError => { const { cause, origin } = artifactFilePrimaryFacts(failure); if ( @@ -78,7 +87,7 @@ const readFailure = ( }, ); if (cause.reason === "path" || cause.reason === "format") - return inputFailure(input.path, cause, cause.message); + return inputFailure(input.path, cause, cause.message, input.parameter); } if (cause instanceof Error && "code" in cause) { if (cause.code === "EACCES" || cause.code === "EPERM") @@ -90,6 +99,7 @@ const readFailure = ( input.path, cause, "Selected API document could not be read", + input.parameter, ); } return new ArtifactOperationError( @@ -103,7 +113,7 @@ const readFailure = ( /** The existing owner verifies exactly the bytes whose SHA-256 the inventory cites. */ export const readApiContract = async ( - input: InspectApiContractInput, + input: ContractDocumentInput, resources: ArtifactResourceScope, signal?: AbortSignal, ) => { diff --git a/src/application/apiContract/ApiContractService.ts b/src/application/apiContract/ApiContractService.ts index 3802f225cc..a7c1f63705 100644 --- a/src/application/apiContract/ApiContractService.ts +++ b/src/application/apiContract/ApiContractService.ts @@ -1,10 +1,8 @@ import { isAbsolute } from "node:path"; -import { setImmediate } from "node:timers/promises"; import { getHeapStatistics } from "node:v8"; import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; import { AnalysisError } from "../../domain/analysisErrorBase.js"; import { - AnalysisCancelledError, AnalysisInputError, AnalysisResourceConstraintError, } from "../../domain/analysisErrorCore.js"; @@ -25,6 +23,7 @@ import { parseAnalysisInput } from "../AnalysisInput.js"; import { nativeJsonParseRequiredBytes } from "../JsonInputFile.js"; import { parseUtf8Json } from "../Utf8JsonInput.js"; import { readApiContract } from "./ApiContractRead.js"; +import { completeApiContractSteps } from "./ApiContractTraversal.js"; import { completeProviderEvidence } from "../ProviderOperationExecution.js"; const inputFailure = (message: string, cause?: unknown) => @@ -32,35 +31,6 @@ const inputFailure = (message: string, cause?: unknown) => { path: ["path"], reason: "invalid_format", message }, ]); -/** Cooperative traversals release their provisional state on cancellation. */ -const completeSteps = async ( - steps: Generator>, - signal?: AbortSignal, -): Promise> => { - let completed = false; - try { - for (;;) { - if (signal?.aborted) - return err( - new AnalysisCancelledError(operation, { cause: signal.reason }), - ); - const next = steps.next(); - if (next.done) { - completed = true; - if (!next.value.ok) return next.value; - // Include cancellation queued during the final synchronous projection. - await setImmediate(); - return signal?.aborted - ? err(new AnalysisCancelledError(operation, { cause: signal.reason })) - : next.value; - } - await setImmediate(); - } - } finally { - if (!completed) steps.return(undefined as never); - } -}; - const decodeDocument = ( bytes: Buffer, path: string, @@ -102,14 +72,14 @@ function* documentSteps(document: unknown, artifact: ApiContractArtifact) { } const importDocument = async ( - input: InspectApiContractInput, + input: Extract, bytes: Buffer, sha256: string, signal?: AbortSignal, ): Promise> => { const document = decodeDocument(bytes, input.path); if (!document.ok) return document; - const parsed = await completeSteps( + const parsed = await completeApiContractSteps( documentSteps(document.value, { path: input.path, sha256, @@ -141,7 +111,7 @@ const importDocument = async ( ); }; -/** Shared CLI/MCP import: one verified file read, no references, network or target execution. */ +/** Shared CLI/MCP entrypoint; format owners read selected files without network or execution. */ export const inspectApiContractEvidence = async ( rawInput: unknown, resources: ArtifactResourceScope, @@ -159,6 +129,11 @@ export const inspectApiContractEvidence = async ( return err( inputFailure("Expected an absolute filesystem path on this host."), ); + if (input.format === "graphql-sdl") { + const { importGraphqlContract } = + await import("./GraphqlContractImport.js"); + return importGraphqlContract(input, resources, options.signal); + } const source = await readApiContract(input, resources, options.signal); if (!source.ok) return source; const result = await importDocument( diff --git a/src/application/apiContract/ApiContractTraversal.ts b/src/application/apiContract/ApiContractTraversal.ts new file mode 100644 index 0000000000..fe984b6ff1 --- /dev/null +++ b/src/application/apiContract/ApiContractTraversal.ts @@ -0,0 +1,44 @@ +import { setImmediate } from "node:timers/promises"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js"; +import { err, type Result } from "../../domain/result.js"; +import { API_CONTRACT_OPERATION as operation } from "../../domain/apiContract/apiContract.js"; + +/** Let a producer retain already interpreted facts before releasing its provisional state. */ +const cancelSteps = ( + steps: Generator>, + reason: unknown, +): Result => { + const cancellation = new AnalysisCancelledError(operation, { cause: reason }); + try { + const stopped = steps.throw(cancellation); + if (stopped.done && !stopped.value.ok) return stopped.value; + } catch (cause) { + if (cause !== cancellation) throw cause; + } + return err(cancellation); +}; + +/** Cooperative traversals release their provisional state on cancellation. */ +export const completeApiContractSteps = async ( + steps: Generator>, + signal?: AbortSignal, +): Promise> => { + let completed = false; + try { + for (;;) { + if (signal?.aborted) return cancelSteps(steps, signal.reason); + const next = steps.next(); + if (next.done) { + completed = true; + if (!next.value.ok) return next.value; + // The Evidence owner classifies queued late cancellation with completed facts. + await setImmediate(); + return next.value; + } + await setImmediate(); + } + } finally { + if (!completed) steps.return(undefined as never); + } +}; diff --git a/src/application/apiContract/GraphqlContractImport.ts b/src/application/apiContract/GraphqlContractImport.ts new file mode 100644 index 0000000000..fe763cfbfd --- /dev/null +++ b/src/application/apiContract/GraphqlContractImport.ts @@ -0,0 +1,207 @@ +import { isAbsolute } from "node:path"; +import { getHeapStatistics } from "node:v8"; +import { version } from "graphql"; +import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; +import { + primaryAnalysisError, + type AnalysisError, +} from "../../domain/analysisErrorBase.js"; +import { + AnalysisInputError, + AnalysisResourceConstraintError, +} from "../../domain/analysisErrorCore.js"; +import { createEvidence } from "../../domain/evidence.js"; +import { fileSnapshotPartialObservationSchema } from "../../domain/fileSnapshotObservation.js"; +import { + API_CONTRACT_OPERATION as operation, + API_CONTRACT_PROVIDER, + type ApiContractArtifact, +} from "../../domain/apiContract/apiContract.js"; +import type { InspectApiContractInput } from "../../domain/apiContract/apiContractQuery.js"; +import { + graphqlContractSchema, + type GraphqlContract, +} from "../../domain/apiContract/graphqlContract.js"; +import { GraphqlInventory } from "../../domain/apiContract/GraphqlInventory.js"; +import { parseGraphqlContractSteps } from "../../domain/apiContract/parseGraphqlContract.js"; +import { err, ok, type Result } from "../../domain/result.js"; +import { nativeJsonParseRequiredBytes } from "../JsonInputFile.js"; +import { completeProviderEvidence } from "../ProviderOperationExecution.js"; +import { completeApiContractSteps } from "./ApiContractTraversal.js"; +import { readApiContract } from "./ApiContractRead.js"; + +type GraphqlInput = Extract; +const evidence = (input: GraphqlInput, report: GraphqlContract) => + createEvidence( + { path: input.path, format: "file", sha256: report.artifact.sha256 }, + API_CONTRACT_PROVIDER, + { + operation, + predicateType: "rea.api-contract-inventory", + parameters: { + path: input.path, + format: input.format, + max_file_bytes: input.max_file_bytes, + ...(input.operations_path === undefined + ? {} + : { operations_path: input.operations_path }), + }, + result: report, + confidence: "observed", + limitations: report.limitations, + locations: [ + { kind: "artifact-path", path: input.path }, + ...(input.operations_path === undefined + ? [] + : [{ kind: "artifact-path" as const, path: input.operations_path }]), + ], + }, + ); +const inputFailure = (parameter: string, message: string, cause?: unknown) => + new AnalysisInputError(operation, { cause }, [ + { path: [parameter], reason: "invalid_format", message }, + ]); + +const decode = ( + bytes: Buffer, + parameter: string, +): Result => { + // Reuse the existing conservative document-expansion admission before text/AST allocation. + const required = nativeJsonParseRequiredBytes(bytes.length); + const available = getHeapStatistics().total_available_size; + if (required > available) + return err( + new AnalysisResourceConstraintError( + operation, + "memory", + "Insufficient parser headroom for GraphQL text and AST expansion.", + { + input_bytes: bytes.length, + required_bytes: required, + available_heap_bytes: available, + }, + { + remediationAction: + "Select a smaller complete document or free parser memory; reply paging does not reduce parsing work.", + }, + ), + ); + try { + return ok( + new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes), + ); + } catch (cause) { + if (!(cause instanceof TypeError)) throw cause; + return err( + inputFailure( + parameter, + "The selected GraphQL document is not valid UTF-8.", + cause, + ), + ); + } +}; + +/** Partial facts and independently verified identities survive parsing and descriptor cleanup. */ +const retainFailure = ( + failure: AnalysisError, + input: GraphqlInput, + artifact: ApiContractArtifact, + operationArtifact: ApiContractArtifact | null, +) => { + const prior = + failure.partialObservation ?? + primaryAnalysisError(failure).partialObservation; + const parsed = graphqlContractSchema.safeParse(prior); + const snapshot = fileSnapshotPartialObservationSchema.safeParse(prior); + const secondary = + snapshot.success && + snapshot.data.kind === "file-snapshot-hash" && + snapshot.data.sha256 !== null && + snapshot.data.path !== null && + snapshot.data.path === input.operations_path + ? { + path: snapshot.data.path, + sha256: snapshot.data.sha256, + bytes: snapshot.data.bytes, + } + : operationArtifact; + const report = parsed.success + ? parsed.data + : new GraphqlInventory( + artifact, + secondary, + version, + input.operations_path !== undefined, + ).finish(true); + const projected = evidence(input, report); + let current: AnalysisError | undefined = failure; + while (current !== undefined) { + if ( + current.partialObservation === prior || + current.partialObservation === undefined + ) + current.partialObservation = projected; + current = current.cleanupPrimary; + } + return err(failure); +}; + +/** Loaded only for SDL; both adapters use this verified local-file interpretation. */ +export const importGraphqlContract = async ( + input: GraphqlInput, + resources: ArtifactResourceScope, + signal?: AbortSignal, +) => { + if (input.operations_path !== undefined && !isAbsolute(input.operations_path)) + return err( + inputFailure( + "operations_path", + "Expected an absolute filesystem path on this host.", + ), + ); + const loaded = await readApiContract(input, resources, signal); + if (!loaded.ok) return loaded; + const artifact = { + path: input.path, + sha256: loaded.value.sha256, + bytes: loaded.value.bytes.length, + }; + const schemaText = decode(loaded.value.bytes, "path"); + if (!schemaText.ok) + return retainFailure(schemaText.error, input, artifact, null); + let operations: { artifact: ApiContractArtifact; text: string } | undefined; + if (input.operations_path !== undefined) { + const second = await readApiContract( + { + path: input.operations_path, + max_file_bytes: input.max_file_bytes, + parameter: "operations_path", + }, + resources, + signal, + ); + if (!second.ok) return retainFailure(second.error, input, artifact, null); + const secondArtifact = { + path: input.operations_path, + sha256: second.value.sha256, + bytes: second.value.bytes.length, + }; + const text = decode(second.value.bytes, "operations_path"); + if (!text.ok) + return retainFailure(text.error, input, artifact, secondArtifact); + operations = { artifact: secondArtifact, text: text.value }; + } + const parsed = await completeApiContractSteps( + parseGraphqlContractSteps({ artifact, text: schemaText.value }, operations), + signal, + ); + if (!parsed.ok) + return retainFailure( + parsed.error, + input, + artifact, + operations?.artifact ?? null, + ); + return completeProviderEvidence(evidence(input, parsed.value), signal); +}; diff --git a/src/application/apiContract/GraphqlContractService.test.ts b/src/application/apiContract/GraphqlContractService.test.ts new file mode 100644 index 0000000000..2ab245c3e9 --- /dev/null +++ b/src/application/apiContract/GraphqlContractService.test.ts @@ -0,0 +1,304 @@ +import * as operationParser from "../../domain/apiContract/GraphqlOperationFacts.js"; +import { createHash } from "node:crypto"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it, vi } from "vitest"; +import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; +import { parseEvidence } from "../../domain/evidence.js"; +import { graphqlContractSchema } from "../../domain/apiContract/graphqlContract.js"; +import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; +import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; +import { inspectApiContractEvidence } from "./ApiContractService.js"; +import * as parser from "../../domain/apiContract/parseGraphqlContract.js"; +import * as reader from "./ApiContractRead.js"; + +const files = async ( + schema: string | Uint8Array, + operations?: string | Uint8Array, +) => { + const root = await createTestTempDirectory("rea-graphql-contract-"); + const path = join(root, "SDL 雪.graphql"), + operations_path = join(root, "operations.graphql"); + await writeFile(path, schema); + if (operations !== undefined) await writeFile(operations_path, operations); + return { + path, + format: "graphql-sdl" as const, + ...(operations === undefined ? {} : { operations_path }), + }; +}; +const withScope = async ( + run: (resources: ArtifactResourceScope) => Promise, +) => { + const resources = new ArtifactResourceScope(); + try { + return await run(resources); + } finally { + await resources.close(); + } +}; +const reportOf = (value: unknown) => + graphqlContractSchema.parse(parseEvidence(value).normalized_result); + +it("binds both independently verified byte snapshots and preserves optional-BOM positions", async () => { + const schema = "\uFEFFtype Query { greeting: String }", + operations = "query Q { renamed: greeting }"; + const input = await files(schema, operations); + await withScope(async (resources) => { + const result = await inspectApiContractEvidence(input, resources); + if (!result.ok) throw result.error; + const report = reportOf(result.value); + expect(result.value.parameters).toEqual({ + ...input, + max_file_bytes: 33554432, + }); + expect(report.artifact.sha256).toBe( + createHash("sha256").update(schema).digest("hex"), + ); + expect(report.operations_artifact?.sha256).toBe( + createHash("sha256").update(operations).digest("hex"), + ); + expect( + schema.slice( + report.fields[0]!.source.start_utf16, + report.fields[0]!.source.end_utf16, + ), + ).toBe("greeting: String"); + expect(report.references[0]).toMatchObject({ + response_key: "renamed", + schema_field_id: report.fields[0]!.field_id, + state: "resolved", + }); + expect(await readFile(input.path, "utf8")).toBe(schema); + }); +}); + +it.each(["path", "operations_path"] as const)( + "preserves known source identities on invalid UTF-8 in %s", + async (parameter) => { + const input = await files( + parameter === "path" + ? Buffer.from([255]) + : "type Query { greeting: String }", + parameter === "operations_path" ? Buffer.from([255]) : "{ greeting }", + ); + await withScope(async (resources) => { + const result = await inspectApiContractEvidence(input, resources); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected invalid UTF-8"); + expect(result.error._tag).toBe("AnalysisInputError"); + expect(projectAnalysisError(result.error)).toMatchObject({ + details: { issues: [{ path: [parameter] }] }, + }); + const partial = reportOf(result.error.partialObservation); + expect(partial.artifact.path).toBe(input.path); + expect(partial.source_coverage).toMatchObject({ + status: "partial", + schema_declarations: "unavailable", + }); + expect(partial.operations_artifact !== null).toBe( + parameter === "operations_path", + ); + }); + }, +); + +it("retains SDL facts when the operation syntax is malformed", async () => { + const input = await files( + "type Query { greeting: String }", + "query Q { greeting(", + ); + await withScope(async (resources) => { + const result = await inspectApiContractEvidence(input, resources); + if (!result.ok) throw result.error; + const report = reportOf(result.value); + expect(report.fields).toHaveLength(1); + expect(report.source_coverage).toMatchObject({ + status: "partial", + schema_declarations: "complete", + operations: "unavailable", + }); + expect(report.diagnostics[0]).toMatchObject({ + code: "operations-parse-refused", + source: { role: "operations" }, + }); + }); +}); + +it("rejects primary SDL syntax with queryable partial Evidence and neutral diagnostics", async () => { + const input = await files( + 'type Query { field: "https://user:credential@host/path" }', + ); + await withScope(async (resources) => { + const result = await inspectApiContractEvidence(input, resources); + if (result.ok) throw new Error("Expected a syntax refusal"); + const report = reportOf(result.error.partialObservation); + expect(report.source_coverage.schema_declarations).toBe("unavailable"); + expect(report.diagnostics[0]?.source?.role).toBe("schema"); + expect(JSON.stringify(projectAnalysisError(result.error))).not.toContain( + "user:credential", + ); + }); +}); + +it("attributes auxiliary file admission failures to operations_path and retains the primary identity", async () => { + const input = await files("type Query { field: String }"); + await withScope(async (resources) => { + const missing = await inspectApiContractEvidence( + { ...input, operations_path: join(input.path, "missing") }, + resources, + ); + expect(missing.ok).toBe(false); + if (missing.ok) throw new Error("Expected missing operation file"); + expect(projectAnalysisError(missing.error)).toMatchObject({ + details: { issues: [{ path: ["operations_path"] }] }, + }); + expect(reportOf(missing.error.partialObservation).artifact.path).toBe( + input.path, + ); + const relative = await inspectApiContractEvidence( + { ...input, operations_path: "relative.graphql" }, + resources, + ); + expect(relative.ok).toBe(false); + if (!relative.ok) expect(relative.error.partialObservation).toBeUndefined(); + const wrongFormat = await inspectApiContractEvidence( + { path: input.path, operations_path: input.path }, + resources, + ); + expect(wrongFormat.ok).toBe(false); + }); +}); + +it("keeps real SDL facts on cooperative cancellation rather than dropping them with provisional AST state", async () => { + const input = await files( + "type Query { " + + Array.from({ length: 1000 }, (_, i) => `f${i}: String`).join(" ") + + " }", + ); + const controller = new AbortController(); + const real = reader.readApiContract; + const scheduled = vi + .spyOn(reader, "readApiContract") + .mockImplementationOnce(async (...args) => { + const result = await real(...args); + setImmediate(() => controller.abort("after-read")); + return result; + }); + try { + await withScope(async (resources) => { + const result = await inspectApiContractEvidence(input, resources, { + signal: controller.signal, + }); + if (result.ok) throw new Error("Expected cancellation"); + expect(result.error._tag).toBe("AnalysisCancelledError"); + expect(result.error.cause).toBe("after-read"); + const report = reportOf(result.error.partialObservation); + expect(report.fields.length).toBeGreaterThan(0); + expect(report.fields.length).toBeLessThan(1000); + expect(report.source_coverage).toMatchObject({ + status: "partial", + schema_declarations: "partial", + }); + }); + } finally { + scheduled.mockRestore(); + } +}); + +it("retains completed facts on cancellation queued after the last projection", async () => { + const input = await files("type Query { field: String }", "{ field }"); + const controller = new AbortController(); + const real = parser.parseGraphqlContractSteps; + const scheduled = vi + .spyOn(parser, "parseGraphqlContractSteps") + .mockImplementationOnce(function* (...args) { + const result = yield* real(...args); + setImmediate(() => controller.abort("after-projection")); + return result; + }); + try { + await withScope(async (resources) => { + const result = await inspectApiContractEvidence(input, resources, { + signal: controller.signal, + }); + if (result.ok) throw new Error("Expected late cancellation"); + expect(result.error._tag).toBe("AnalysisCancelledError"); + expect( + reportOf(result.error.partialObservation).source_coverage.status, + ).toBe("complete-within-scope"); + }); + } finally { + scheduled.mockRestore(); + } +}); + +it("applies the selected file allowance to the auxiliary document and retains its known read coverage", async () => { + const input = await files("type Query { f: String }", "#".repeat(128)); + await withScope(async (resources) => { + const result = await inspectApiContractEvidence( + { ...input, max_file_bytes: 32 }, + resources, + ); + if (result.ok) throw new Error("Expected auxiliary file capacity refusal"); + expect(result.error._tag).toBe("AnalysisResourceConstraintError"); + const partial = reportOf(result.error.partialObservation); + expect(partial.artifact.path).toBe(input.path); + expect(partial.operations_artifact).toBeNull(); + expect(partial.source_coverage.operations).toBe("unavailable"); + }); +}); + +it.each(["query Q", "fragment Q on Query"])( + "cancels within one wide definition after a bounded field-binding chunk: %s", + async (definition) => { + const count = 4096; + const input = await files( + "type Query { value: String }", + definition + + " { " + + Array.from({ length: count }, (_, index) => `a${index}: value`).join( + " ", + ) + + " }", + ); + const controller = new AbortController(); + const real = operationParser.collectGraphqlOperationFacts; + const observed = vi + .spyOn(operationParser, "collectGraphqlOperationFacts") + .mockImplementationOnce(function* (...args) { + const steps = real(...args); + let scheduled = false; + try { + for (;;) { + const next = steps.next(); + if (next.done) return next.value; + if (!scheduled && args[2].references.length > 0) { + scheduled = true; + setImmediate(() => controller.abort("within-wide-operation")); + } + yield; + } + } finally { + steps.return(undefined); + } + }); + try { + await withScope(async (resources) => { + const result = await inspectApiContractEvidence(input, resources, { + signal: controller.signal, + }); + if (result.ok) + throw new Error("Expected cancellation within selections"); + expect(result.error._tag).toBe("AnalysisCancelledError"); + expect(result.error.cause).toBe("within-wide-operation"); + const report = reportOf(result.error.partialObservation); + expect(report.references.length).toBeGreaterThan(0); + expect(report.references.length).toBeLessThan(count); + expect(report.source_coverage.operations).toBe("partial"); + }); + } finally { + observed.mockRestore(); + } + }, +); diff --git a/src/application/investigation/EvidenceLedger.test.ts b/src/application/investigation/EvidenceLedger.test.ts index 85a68e7c49..0678df1aa5 100644 --- a/src/application/investigation/EvidenceLedger.test.ts +++ b/src/application/investigation/EvidenceLedger.test.ts @@ -46,6 +46,52 @@ it("accepts and replaces caller path metadata for the same semantic record", () ); }); +it("replaces auxiliary caller spelling on duplicate import and ignores record key order", () => { + const original = createEvidence(TARGET, PROVIDER, { + operation: "health", + parameters: {}, + result: true, + }); + const first = withEvidenceCallerInputPath(original, "./same-schema", { + operations_path: "./queries", + other: "./unchanged", + }); + const second = withEvidenceCallerInputPath(original, "./same-schema", { + other: "./unchanged", + operations_path: "./alias/../queries", + }); + const ledger = new EvidenceLedger(); + expect(ledger.record(first).ok).toBe(true); + expect(second.evidence_id).toBe(first.evidence_id); + const imported = ledger.import(createEvidenceBundle([second])); + expect(imported).toMatchObject({ + ok: true, + value: { + recordsAdded: 0, + changed: true, + replacements: [{ kind: "metadata" }], + }, + }); + expect(ledger.get(first.evidence_id)?.caller_input_paths).toEqual( + second.caller_input_paths, + ); + expect(first.caller_input_paths?.operations_path).toBe("./queries"); + const reordered = withEvidenceCallerInputPath(original, "./same-schema", { + operations_path: "./alias/../queries", + other: "./unchanged", + }); + expect(ledger.import(createEvidenceBundle([reordered]))).toMatchObject({ + ok: true, + value: { changed: false, replacements: [] }, + }); + const unannotated = withEvidenceCallerInputPath(original, "./same-schema"); + expect(ledger.import(createEvidenceBundle([unannotated]))).toMatchObject({ + ok: true, + value: { changed: true }, + }); + expect(ledger.get(first.evidence_id)?.caller_input_paths).toBeUndefined(); +}); + describe("evidence ledger recording", () => { it("deduplicates and atomically imports bundles", () => { const evidence = createEvidence(TARGET, PROVIDER, { diff --git a/src/application/investigation/EvidenceLedger.ts b/src/application/investigation/EvidenceLedger.ts index 403f346a4f..7d9372284d 100644 --- a/src/application/investigation/EvidenceLedger.ts +++ b/src/application/investigation/EvidenceLedger.ts @@ -12,7 +12,11 @@ import { import { AnalysisInputError } from "../../domain/analysisErrorCore.js"; import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js"; import { UnknownRegistryError } from "../../domain/unknownRegistryError.js"; -import { isImmutableEvidence, type Evidence } from "../../domain/evidence.js"; +import { + isImmutableEvidence, + evidenceDisplayMetadataAgree, + type Evidence, +} from "../../domain/evidence.js"; import { safeParseImmutableEvidence } from "../../domain/evidenceValidation.js"; import { createResidualUnknown, @@ -154,7 +158,7 @@ export class EvidenceLedger { continue; } if (existing === evidence) continue; - if (!displayMetadataAgree(existing, evidence)) { + if (!evidenceDisplayMetadataAgree(existing, evidence)) { recordChanges.set(evidence.evidence_id, evidence); replacements.push({ kind: "metadata", @@ -693,8 +697,3 @@ const readLedgerEvidence = ( const unknownRevisionKey = (unknown: ResidualUnknown): string => `${unknown.unknown_id}:${String(unknown.revision)}`; - -const displayMetadataAgree = (left: Evidence, right: Evidence): boolean => - left.subject?.name === right.subject?.name && - left.subject?.local_path === right.subject?.local_path && - left.caller_input_path === right.caller_input_path; diff --git a/src/cli/analysisEvidenceDelivery.ts b/src/cli/analysisEvidenceDelivery.ts index 0517c8acbc..bf89dcde49 100644 --- a/src/cli/analysisEvidenceDelivery.ts +++ b/src/cli/analysisEvidenceDelivery.ts @@ -61,6 +61,8 @@ interface CliEvidenceDelivery { readonly evidenceOutput: string | undefined; /** Exact one-shot CLI target spelling, when this invocation selects a file. */ readonly callerInputPath?: string; + /** Verbatim named auxiliary path choices for the same direct invocation. */ + readonly callerInputPaths?: Readonly>; /** Derived workflows publish their selected source closure with the result. */ readonly sourceEvidence?: readonly Evidence[]; /** Invocation exclusions cover final cleanup and publication paths/diagnostics. */ @@ -197,7 +199,11 @@ export const deliverCliAnalysisEvidence = async ( const complete = await finishAnalysisCommand( input.callerInputPath === undefined ? run - : bindCliEvidenceCallerPath(input.callerInputPath, run), + : bindCliEvidenceCallerPath( + input.callerInputPath, + run, + input.callerInputPaths, + ), service, providerId, operation, @@ -290,7 +296,11 @@ export const publishCliAnalysisEvidence = async ( // inspect_analysis_view calls own their own input and do not inherit it. const selected = detail === "summary" && complete.caller_input_path !== undefined - ? withEvidenceCallerInputPath(delivered.value, complete.caller_input_path) + ? withEvidenceCallerInputPath( + delivered.value, + complete.caller_input_path, + complete.caller_input_paths, + ) : delivered.value; const inspectionSources = analysisEvidenceSources(complete); if (!inspectionSources.ok) return undelivered(inspectionSources.error); diff --git a/src/cli/apiContractCommand.ts b/src/cli/apiContractCommand.ts index 46628a3014..56465d2e89 100644 --- a/src/cli/apiContractCommand.ts +++ b/src/cli/apiContractCommand.ts @@ -13,8 +13,9 @@ import { API_CONTRACT_PROVIDER, } from "../domain/apiContract/apiContract.js"; import { - apiContractViewRequestSchema, - inspectApiContractInputSchema, + apiContractViewSelectionSchema, + apiContractFormatSchema, + apiContractFileBytesSchema, } from "../domain/apiContract/apiContractQuery.js"; import { withCommandCancellation } from "./commandCancellation.js"; import { @@ -26,7 +27,7 @@ import { const deliverySchema = z .object({ detail: analysisEvidenceDeliveryOptions.detail, - view: apiContractViewRequestSchema.optional(), + view: apiContractViewSelectionSchema.optional(), }) .superRefine((value, context) => { if (value.view !== undefined && value.detail !== "summary") @@ -45,18 +46,28 @@ export const registerApiContractCommand = ( ): void => { cli.command(CLI_COMMANDS.inspectApiContract, { description: - "Inventory local OpenAPI 3.0/3.1/3.2 JSON declarations offline without resolving references", + "Inspect local OpenAPI JSON or GraphQL SDL declarations and field bindings offline", args: z.object({ - path: localPathStringSchema.describe("Local OpenAPI JSON path"), + path: localPathStringSchema.describe( + "Local OpenAPI JSON or GraphQL SDL path", + ), }), options: z.object({ ...analysisEvidenceDeliveryOptions, - maxFileBytes: inspectApiContractInputSchema.shape.max_file_bytes, + maxFileBytes: apiContractFileBytesSchema, + contractFormat: apiContractFormatSchema + .default("openapi-json") + .describe("Input API contract syntax: OpenAPI JSON or GraphQL SDL"), + operationsPath: localPathStringSchema + .optional() + .describe( + "Local operation/fragment document, only with --contractFormat graphql-sdl", + ), view: z .string() .optional() .describe( - "JSON api-contract page; requires --detail summary and --evidence_output", + "JSON api-contract or graphql-contract page; requires --detail summary and --evidence_output", ), }), run: ({ args, options }) => @@ -73,12 +84,28 @@ export const registerApiContractCommand = ( await import("../application/apiContract/ApiContractService.js"); return deliverCliAnalysisEvidence({ callerInputPath: args.path, + ...(options.operationsPath === undefined + ? {} + : { + callerInputPaths: { operations_path: options.operationsPath }, + }), run: () => inspectApiContractEvidence( { path: absoluteCallerPath(args.path), + format: options.contractFormat, + ...(options.operationsPath === undefined + ? {} + : { + operations_path: absoluteCallerPath( + options.operationsPath, + ), + }), max_file_bytes: options.maxFileBytes, detail: "complete", + ...(selected.value.view === undefined + ? {} + : { view: selected.value.view }), }, resources, { signal }, diff --git a/src/cli/cliEvidenceCallerPath.ts b/src/cli/cliEvidenceCallerPath.ts index 800459d9fc..01d7516c8e 100644 --- a/src/cli/cliEvidenceCallerPath.ts +++ b/src/cli/cliEvidenceCallerPath.ts @@ -13,19 +13,25 @@ export const bindCliEvidenceCallerPath = ( callerPath: string, run: () => Promise>, + additionalPaths?: Readonly>, ): (() => Promise>) => async () => { const result = await run(); if (result.ok) return { ok: true, - value: withEvidenceCallerInputPath(result.value, callerPath), + value: withEvidenceCallerInputPath( + result.value, + callerPath, + additionalPaths, + ), }; if (result.error.partialObservation !== undefined) { try { result.error.partialObservation = withEvidenceCallerInputPath( parseEvidence(result.error.partialObservation), callerPath, + additionalPaths, ); } catch (cause) { // Raw or invalid partial observations must retain their original form. diff --git a/src/contracts/analysisViewToolContracts.ts b/src/contracts/analysisViewToolContracts.ts index 18a5edb9d9..46baf75bfc 100644 --- a/src/contracts/analysisViewToolContracts.ts +++ b/src/contracts/analysisViewToolContracts.ts @@ -17,7 +17,7 @@ export const ANALYSIS_VIEW_TOOL_CONTRACTS = [ ...toolContractMetadata("inspect_analysis_view"), kind: "application", description: - 'Use javascript-routes collections to query route patterns, source-linked occurrences, source status, unknowns or summary counts from retained JavaScript application or web-bundle Evidence. Use web-bundle collections endpoints or inferences to page endpoint observations and inferences from retained analyze_web_bundle Evidence; summary returns target URL, origin, and counts. For retained trace_feature Evidence, kind native-feature-trace pages matches, references or unknowns; matches filter exact address, references filter exact source_address and target_address, source_index identifies each original row, and list_unknowns pages durable question heads. Inspect selected views of supported completed Evidence without repeating analysis. For procedure_pseudo_code Evidence, summary reports text availability and the original selection; kind native with facet pseudocode pages the original UTF-16 text. Other native facets require analyze_function. Choose a view compatible with the parent operation described below. Command summary views preserve ordered GDB records or opaque objdump stdout/stderr once, source parameters, completion facts and parent identity. They do not establish current target state. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. Native export tables use kind native-exports, collection exports/issues, exact names or original indices, selected fields and matching-row pagination; parent Evidence plus source_index identifies each row. Source coverage stays separate from page coverage. BEAM views use kind beam, collections atoms/exports/imports/lambdas/chunks, exact names/indices/fields and matching-row pages; import modules intersects function names. source_index identifies original parent-and-collection rows, preserving every arity and raw atom index. beam.counts preserves null missing tables versus decoded empty tables; runtime absence remains unknown. PYC views use kind python-pyc, collection code_objects, names/indices/fields and paging; source_index scopes every same-name alternative to the parent, nested_code keeps code-constant references, and pyc.coverage preserves source coverage. Go metadata uses kind go, collection dependencies/settings, exact paths/keys or indices, fields and pagination. Row index refers to the original collection; empty selections return no rows, and dependency coverage remains unknown. Rust metadata uses kind rust, collection packages/edges, exact names/versions or original indices, selected fields, edge from_indices/to_indices and matching-row pagination. Embedded-record coverage is separate from unknown runtime dependencies. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. A capture_browser_scenario parent also supports summary and browser-scenario steps/events/transactions/text pages, preserving step_indices, event_kinds, transaction_ids and selected artifacts. A capture_browser_scenario parent that selected response bodies or header values projects the same way, one record per network transaction in request order; it relies on capture-time redaction of declared secrets instead of that refusal, and a transaction whose URL was redacted is left unrepresented. The environment-state view extracts, from the same parents, the initial state the recorded reads observed: view {kind: "environment-state", template_rules: [...]} with the rules of the manifest it accompanies. It reads response bodies recorded before the first related write and reports entity types with an identity field (proved by a path parameter or inferred from a field name), entities in collection order with per-view values where views differ, documents for non-parameterized reads that are not entities and have one initial request line (item-shaped paths stay recorded), and a render plan per read tool: item and list views reproduce their latest recordings from the state (a list also learns the filters, search, sort and paging its recordings verify, and answers new values of them from the state), and every other read tool is a recorded view with a reason. It also reports a write plan per state-changing tool (create, update, delete or document, built from its first successful call) and write_clock; a plan is served only when replaying the capture shows a later read confirming its effect and none contradicting it. Types and identity are inferences; values are recorded data, so it refuses parents inspected with sensitive values, and a scenario parent must have selected response bodies; its values keep the capture redaction markers, and a write whose URL it redacted ends the state of every type. The environment-coverage view, view {kind: "environment-coverage", template_rules: [...]}, summarizes that state without its entities: reads.state and reads.recorded count read tools answered from the state and replayed (recorded_reasons per view reason), writes.served lists the served write tools, and writes.withheld lists each other write tool with its reason, plan record, contradicting read and a hint about what to record next; it refuses what the environment-state view refuses. For a retained record use source: {kind: "retained-evidence", evidence_id: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. A completed Evidence JSON file on this host uses {kind: "evidence-file", path: ""}: REA reads and authenticates it, then projects it like retained Evidence, so a large capture is bounded by the file reader rather than by inline-input accounting. The file may also be an Evidence bundle written by CLI --evidence_output or export_evidence_bundle; select evidence_id when it contains more than one record. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Typed module references use view {kind: "javascript", collection: "modules", projection: "references", where: {path: "main.js", kind: "javascript-module"}, offset: 0, limit: 64}; where matches exact retained strings (null selects unavailable paths). Rows include canonical identity, retained source-range count and observation support states. Source coverage and selection remain explicit; exhausting matching rows does not prove runtime loading or complete analysis. For exact file/native facts and processing gaps, use view {kind: "javascript-facts", where: {paths: ["package.json"]}, property_keys: ["name", "operation", "analysis_status"]}. Rows page original observations with canonical identities and Evidence; missing properties remain omitted. Paths match recorded artifact-path/source-range locations; optional kinds filter nodes. To discover field names without values, opt into include_available_property_keys: true with property_keys: []. Canonical identity and Evidence are separate from properties; keep each node_id/observation_id pair together when composing results. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions. Occurrences accept exact logical_paths and occurrence_ids; nodes accept artifact_ids. Batch filters intersect (omitted means all available, [] means none); fields selects columns while retaining identity links. source_complete describes retained graph membership; source_coverage describes producer coverage, including unknown encrypted PCK directories. Page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. Mach-O views use kind macho and facet entrypoints/architectures/build_metadata/load_commands/dependencies/imports/exports/segments/provenance; source_exhaustive false means unreturned rows may exist. Interface Builder views use kind interface-builder and collection documents/nodes/edges/coverage; nodes and edges accept an exact document relative_path. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Managed member summaries keep identity, selection with unmatched names, coverage and per-collection counts; managed-members pages use collection types/exported_types/fields/methods/member_refs/call_edges/field_accesses/user_strings. Exact names and declaring_types filters intersect before paging where supported; fields selects columns (distinct from collection: "fields"). Identity/source_index, module MVID, source coverage and unresolved filter counts remain; omit body to avoid IL anchors. Omitted fields preserves full rows. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Dart AOT summary preserves target/ABI/library identity, snapshot headers and pool lower bounds. String pages use kind dart-aot, collection package_uris/dart_uris/class_like_tokens; source coverage remains partial when the producer says partial. Protobuf views use kind protobuf, collection bodies/frames/fields, with record_ordinals, body_pointers and fields; field pages also select frame_indices, field_ids, field_numbers or schema_names. Byte identity, offset basis and body status remain present. Text and schema_value are opt-in page columns. APK signing views use kind apk-signature and collection signers/digests/lineage, with ids, signer_ids, schemes, fields and paging. Identity links remain when fields is empty. Declared identities and digest matches do not establish signature verification or authenticity. Model views use kind model-artifact and collection metadata (keys), tensors (names/types) or types (derived totals), with indices, fields and paging. Header SHA-256 scopes declarations; full-file identity remains unknown unless full hashing was selected. Arrays retain typed eight-element previews with explicit omissions. IL2CPP metadata views use kind il2cpp-metadata and collection sections/images/types/methods/fields/properties/events/parameters/string_literals/code_gen_modules/method_addresses/return_types/field_types/parameter_types/generic_method_instances/generic_method_issues. Select indices, fields and pages; sections/images/types/methods/fields/properties/events/parameters/return_types/field_types/parameter_types/generic_method_instances accept exact names (native type names match retained rendered values, preserving same-name rows; null names never match), types also accept image_indices/namespaces/declaring_types, and methods/members accept declaring_types. Parameters and parameter_types accept method_indices; code_gen_modules accept image_indices; method_addresses and generic_method_instances accept addresses/states (generic_method_instances also method_indices), preserving every folded alternative; generic_method_issues accept rows. Row index is scoped to the metadata digest; native pages add binary_source. Uncollected native tables have null counts and remain unavailable. Desktop directory views use kind desktop-application and collection files/omissions/candidates/components/next_probes, with fields and pagination; files and omissions accept exact paths or their stable IDs, and candidates accept families. Source coverage and stable row IDs remain available. Native load-image Evidence uses kind native-load-image with collection source_files/mappings/relocations/entry_points/entry_context/checks/sections, original indices, collection-specific fields and offset/limit paging. Null collections remain unrecorded or inapplicable; pages preserve unsupported verification. Native inventory Evidence from list_names, list/search_procedures or list/search_strings uses view: {kind: "native-inventory", offset: 0, limit: 64, fields: ["value"]}. Rows keep source_index and address even with selected fields. Summary counts describe retained rows; provider discovery coverage remains unknown. Native function Evidence accepts kind summary for identity, availability and facet counts, or kind native for selected facts: view: {kind: "native", facet: "procedure"}. Facets select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native_api and value-flow observations. For parameter type paths when value flow is recorded, use facet: "value_flow_parameters"; its decompiler-observed path may be passed to inspect_native_data_type, but exact database lookup can be unavailable and does not by itself establish type identity. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Retained batch_decompile Evidence accepts kind summary for counts and sizes, or view: {kind: "batch-decompile", collection: "items", where: {statuses: ["unavailable", "error"]}} for body-free rows with exact indices, addresses, procedure_addresses or statuses (include_pseudocode adds bodies), or collection "pseudocode" with an index to page one body. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.', + 'For inspect_api_contract parents, graphql-contract pages retained SDL types/fields, operation definitions, field references, fragment-spreads or diagnostics with exact filters and selected columns. Use javascript-routes collections to query route patterns, source-linked occurrences, source status, unknowns or summary counts from retained JavaScript application or web-bundle Evidence. Use web-bundle collections endpoints or inferences to page endpoint observations and inferences from retained analyze_web_bundle Evidence; summary returns target URL, origin, and counts. For retained trace_feature Evidence, kind native-feature-trace pages matches, references or unknowns; matches filter exact address, references filter exact source_address and target_address, source_index identifies each original row, and list_unknowns pages durable question heads. Inspect selected views of supported completed Evidence without repeating analysis. For procedure_pseudo_code Evidence, summary reports text availability and the original selection; kind native with facet pseudocode pages the original UTF-16 text. Other native facets require analyze_function. Choose a view compatible with the parent operation described below. Command summary views preserve ordered GDB records or opaque objdump stdout/stderr once, source parameters, completion facts and parent identity. They do not establish current target state. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. Native export tables use kind native-exports, collection exports/issues, exact names or original indices, selected fields and matching-row pagination; parent Evidence plus source_index identifies each row. Source coverage stays separate from page coverage. BEAM views use kind beam, collections atoms/exports/imports/lambdas/chunks, exact names/indices/fields and matching-row pages; import modules intersects function names. source_index identifies original parent-and-collection rows, preserving every arity and raw atom index. beam.counts preserves null missing tables versus decoded empty tables; runtime absence remains unknown. PYC views use kind python-pyc, collection code_objects, names/indices/fields and paging; source_index scopes every same-name alternative to the parent, nested_code keeps code-constant references, and pyc.coverage preserves source coverage. Go metadata uses kind go, collection dependencies/settings, exact paths/keys or indices, fields and pagination. Row index refers to the original collection; empty selections return no rows, and dependency coverage remains unknown. Rust metadata uses kind rust, collection packages/edges, exact names/versions or original indices, selected fields, edge from_indices/to_indices and matching-row pagination. Embedded-record coverage is separate from unknown runtime dependencies. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. A capture_browser_scenario parent also supports summary and browser-scenario steps/events/transactions/text pages, preserving step_indices, event_kinds, transaction_ids and selected artifacts. A capture_browser_scenario parent that selected response bodies or header values projects the same way, one record per network transaction in request order; it relies on capture-time redaction of declared secrets instead of that refusal, and a transaction whose URL was redacted is left unrepresented. The environment-state view extracts, from the same parents, the initial state the recorded reads observed: view {kind: "environment-state", template_rules: [...]} with the rules of the manifest it accompanies. It reads response bodies recorded before the first related write and reports entity types with an identity field (proved by a path parameter or inferred from a field name), entities in collection order with per-view values where views differ, documents for non-parameterized reads that are not entities and have one initial request line (item-shaped paths stay recorded), and a render plan per read tool: item and list views reproduce their latest recordings from the state (a list also learns the filters, search, sort and paging its recordings verify, and answers new values of them from the state), and every other read tool is a recorded view with a reason. It also reports a write plan per state-changing tool (create, update, delete or document, built from its first successful call) and write_clock; a plan is served only when replaying the capture shows a later read confirming its effect and none contradicting it. Types and identity are inferences; values are recorded data, so it refuses parents inspected with sensitive values, and a scenario parent must have selected response bodies; its values keep the capture redaction markers, and a write whose URL it redacted ends the state of every type. The environment-coverage view, view {kind: "environment-coverage", template_rules: [...]}, summarizes that state without its entities: reads.state and reads.recorded count read tools answered from the state and replayed (recorded_reasons per view reason), writes.served lists the served write tools, and writes.withheld lists each other write tool with its reason, plan record, contradicting read and a hint about what to record next; it refuses what the environment-state view refuses. For a retained record use source: {kind: "retained-evidence", evidence_id: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. A completed Evidence JSON file on this host uses {kind: "evidence-file", path: ""}: REA reads and authenticates it, then projects it like retained Evidence, so a large capture is bounded by the file reader rather than by inline-input accounting. The file may also be an Evidence bundle written by CLI --evidence_output or export_evidence_bundle; select evidence_id when it contains more than one record. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Typed module references use view {kind: "javascript", collection: "modules", projection: "references", where: {path: "main.js", kind: "javascript-module"}, offset: 0, limit: 64}; where matches exact retained strings (null selects unavailable paths). Rows include canonical identity, retained source-range count and observation support states. Source coverage and selection remain explicit; exhausting matching rows does not prove runtime loading or complete analysis. For exact file/native facts and processing gaps, use view {kind: "javascript-facts", where: {paths: ["package.json"]}, property_keys: ["name", "operation", "analysis_status"]}. Rows page original observations with canonical identities and Evidence; missing properties remain omitted. Paths match recorded artifact-path/source-range locations; optional kinds filter nodes. To discover field names without values, opt into include_available_property_keys: true with property_keys: []. Canonical identity and Evidence are separate from properties; keep each node_id/observation_id pair together when composing results. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions. Occurrences accept exact logical_paths and occurrence_ids; nodes accept artifact_ids. Batch filters intersect (omitted means all available, [] means none); fields selects columns while retaining identity links. source_complete describes retained graph membership; source_coverage describes producer coverage, including unknown encrypted PCK directories. Page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. Mach-O views use kind macho and facet entrypoints/architectures/build_metadata/load_commands/dependencies/imports/exports/segments/provenance; source_exhaustive false means unreturned rows may exist. Interface Builder views use kind interface-builder and collection documents/nodes/edges/coverage; nodes and edges accept an exact document relative_path. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Managed member summaries keep identity, selection with unmatched names, coverage and per-collection counts; managed-members pages use collection types/exported_types/fields/methods/member_refs/call_edges/field_accesses/user_strings. Exact names and declaring_types filters intersect before paging where supported; fields selects columns (distinct from collection: "fields"). Identity/source_index, module MVID, source coverage and unresolved filter counts remain; omit body to avoid IL anchors. Omitted fields preserves full rows. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Dart AOT summary preserves target/ABI/library identity, snapshot headers and pool lower bounds. String pages use kind dart-aot, collection package_uris/dart_uris/class_like_tokens; source coverage remains partial when the producer says partial. Protobuf views use kind protobuf, collection bodies/frames/fields, with record_ordinals, body_pointers and fields; field pages also select frame_indices, field_ids, field_numbers or schema_names. Byte identity, offset basis and body status remain present. Text and schema_value are opt-in page columns. APK signing views use kind apk-signature and collection signers/digests/lineage, with ids, signer_ids, schemes, fields and paging. Identity links remain when fields is empty. Declared identities and digest matches do not establish signature verification or authenticity. Model views use kind model-artifact and collection metadata (keys), tensors (names/types) or types (derived totals), with indices, fields and paging. Header SHA-256 scopes declarations; full-file identity remains unknown unless full hashing was selected. Arrays retain typed eight-element previews with explicit omissions. IL2CPP metadata views use kind il2cpp-metadata and collection sections/images/types/methods/fields/properties/events/parameters/string_literals/code_gen_modules/method_addresses/return_types/field_types/parameter_types/generic_method_instances/generic_method_issues. Select indices, fields and pages; sections/images/types/methods/fields/properties/events/parameters/return_types/field_types/parameter_types/generic_method_instances accept exact names (native type names match retained rendered values, preserving same-name rows; null names never match), types also accept image_indices/namespaces/declaring_types, and methods/members accept declaring_types. Parameters and parameter_types accept method_indices; code_gen_modules accept image_indices; method_addresses and generic_method_instances accept addresses/states (generic_method_instances also method_indices), preserving every folded alternative; generic_method_issues accept rows. Row index is scoped to the metadata digest; native pages add binary_source. Uncollected native tables have null counts and remain unavailable. Desktop directory views use kind desktop-application and collection files/omissions/candidates/components/next_probes, with fields and pagination; files and omissions accept exact paths or their stable IDs, and candidates accept families. Source coverage and stable row IDs remain available. Native load-image Evidence uses kind native-load-image with collection source_files/mappings/relocations/entry_points/entry_context/checks/sections, original indices, collection-specific fields and offset/limit paging. Null collections remain unrecorded or inapplicable; pages preserve unsupported verification. Native inventory Evidence from list_names, list/search_procedures or list/search_strings uses view: {kind: "native-inventory", offset: 0, limit: 64, fields: ["value"]}. Rows keep source_index and address even with selected fields. Summary counts describe retained rows; provider discovery coverage remains unknown. Native function Evidence accepts kind summary for identity, availability and facet counts, or kind native for selected facts: view: {kind: "native", facet: "procedure"}. Facets select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native_api and value-flow observations. For parameter type paths when value flow is recorded, use facet: "value_flow_parameters"; its decompiler-observed path may be passed to inspect_native_data_type, but exact database lookup can be unavailable and does not by itself establish type identity. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Retained batch_decompile Evidence accepts kind summary for counts and sizes, or view: {kind: "batch-decompile", collection: "items", where: {statuses: ["unavailable", "error"]}} for body-free rows with exact indices, addresses, procedure_addresses or statuses (include_pseudocode adds bodies), or collection "pseudocode" with an index to page one body. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.', inputSchema: inspectAnalysisViewInputSchema, outputSchema: evidenceResultOf(analysisViewResultSchema), examples: [ diff --git a/src/contracts/apiContractToolContracts.ts b/src/contracts/apiContractToolContracts.ts index 833eab1cc1..60e94729b5 100644 --- a/src/contracts/apiContractToolContracts.ts +++ b/src/contracts/apiContractToolContracts.ts @@ -1,9 +1,11 @@ +import { graphqlContractSchema } from "../domain/apiContract/graphqlContract.js"; import { z } from "zod"; import { apiContractSchema } from "../domain/apiContract/apiContract.js"; import { inspectApiContractInputSchema } from "../domain/apiContract/apiContractQuery.js"; import { apiContractSummaryViewResultSchema, apiContractPageViewResultSchema, + graphqlContractPageViewResultSchema, } from "../domain/analysisView/analysisView.js"; import { evidenceResultOf } from "./toolOutputSchemaPrimitives.js"; import { toolContractMetadata } from "./toolEffects.js"; @@ -16,16 +18,31 @@ export const API_CONTRACT_TOOL_CONTRACTS = [ ...toolContractMetadata("inspect_api_contract"), kind: "application", description: - "Inventory declared operations in a local OpenAPI 3.0/3.1/3.2 JSON document offline. No target session, network, endpoint requests or reference resolution. Returns exact method/path, source JSON Pointers, source-scoped IDs and separate exact method/path keys. Candidate runtime behavior is never inferred from a contract. Default summary retains complete Evidence; optional view returns selected operations, unresolved Path Items or diagnostics immediately and takes precedence over detail. Retained inspect_analysis_view kind api-contract supports exact batch ID/method/path selection, fields and paging. Coverage describes inline paths only; callbacks/webhooks, schemas, servers, parameters and security remain unanalyzed. Malformed siblings and unresolved references remain explicit; this is not full OpenAPI validation. File and expanded-facts allowances are each 32 MiB, with JSON parsing additionally admitted against available heap and the shared nesting limit.", + "Inspect local OpenAPI JSON (default) or GraphQL SDL offline; no session, network, endpoint requests or target execution. OpenAPI 3.0/3.1/3.2 inventories inline path operations with exact method/path keys, source JSON Pointers and source-scoped IDs; references stay unresolved and malformed siblings explicit. GraphQL format graphql-sdl inventories type/field declarations and optional operations_path field bindings, operation/fragment owners and explicit spread edges without expanding fragments. It preserves aliases, wrappers, source digests/UTF-16 ranges and unknown/model-only bindings; field resolution is not full operation validation. Introspection, contract diff and runtime behavior are outside scope. Default summary retains complete Evidence; view selects facts immediately, overriding detail. Retained inspect_analysis_view supports kind api-contract or graphql-contract with exact batch filters, fields and paging. Coverage describes examined declarations, independently of page exhaustion. Each file and expanded facts have 32 MiB allowances plus heap admission. GraphQL additionally bounds lexical tokens (262144), comments (65536) and subsequent AST traversal depth (128). Reply paging does not increase parser allowances.", inputSchema: inspectApiContractInputSchema, outputSchema: evidenceResultOf( z.union([ apiContractSchema, + graphqlContractSchema, apiContractSummaryViewResultSchema, apiContractPageViewResultSchema, + graphqlContractPageViewResultSchema, ]), ), examples: [ + { + title: "Bind local GraphQL selections to supplied SDL fields", + input: { + path: "/artifacts/schema.graphql", + format: "graphql-sdl", + operations_path: "/artifacts/queries.graphql", + view: { + kind: "graphql-contract", + collection: "references", + limit: 20, + }, + }, + }, { title: "Read selected declared HTTP operations without contacting servers", diff --git a/src/contracts/toolEffects.test.ts b/src/contracts/toolEffects.test.ts index 480044292b..aba192568e 100644 --- a/src/contracts/toolEffects.test.ts +++ b/src/contracts/toolEffects.test.ts @@ -88,6 +88,8 @@ const EXECUTION_WORDING_EXCEPTIONS: Readonly> = { inspect_adb_client: "inspects the operator-selected adb client; never starts its server", list_adb_devices: "may start the local adb server, which REA does not own", + inspect_api_contract: + "states there is no target execution during offline contract parsing", inspect_firmware_regions: "states there is no target execution", inspect_gpu_code_objects: "reads static declarations without executing GPU code", diff --git a/src/domain/analysisErrorBase.ts b/src/domain/analysisErrorBase.ts index b504c09fc8..1b609680b8 100644 --- a/src/domain/analysisErrorBase.ts +++ b/src/domain/analysisErrorBase.ts @@ -1,4 +1,5 @@ import type { PlaywrightTrace } from "./playwrightTrace/playwrightTrace.js"; +import type { GraphqlContract } from "./apiContract/graphqlContract.js"; import type { SavedAnalysisSnapshotPartialObservation } from "./savedAnalysisSnapshot.js"; import type { AnalysisSettingLimit } from "./analysisSettings.js"; import type { JsonValue } from "./jsonValue.js"; @@ -401,6 +402,7 @@ import type { WebArtifactAcquisition } from "./webArtifactAcquisition.js"; /** Provider-neutral evidence collected before a typed analysis failure. */ export type AnalysisPartialObservation = + | GraphqlContract | PlaywrightTrace | SavedAnalysisSnapshotPartialObservation | WebArtifactAcquisition diff --git a/src/domain/analysisView/analysisView.ts b/src/domain/analysisView/analysisView.ts index f8e59610e7..acda9f113b 100644 --- a/src/domain/analysisView/analysisView.ts +++ b/src/domain/analysisView/analysisView.ts @@ -163,6 +163,7 @@ import { playwrightTraceSummaryViewResultSchema, playwrightTracePageViewResultSchema, apiContractPageViewResultSchema, + graphqlContractPageViewResultSchema, endpointComparisonPageViewResultSchema, featureTraceSummaryViewResultSchema, batchDecompileSummaryViewResultSchema, @@ -238,6 +239,7 @@ export { playwrightTraceSummaryViewResultSchema, playwrightTracePageViewResultSchema, apiContractPageViewResultSchema, + graphqlContractPageViewResultSchema, endpointComparisonPageViewResultSchema, featureTraceSummaryViewResultSchema, batchDecompileSummaryViewResultSchema, diff --git a/src/domain/analysisView/analysisViewRequest.ts b/src/domain/analysisView/analysisViewRequest.ts index b9ef686368..a15c12f1d1 100644 --- a/src/domain/analysisView/analysisViewRequest.ts +++ b/src/domain/analysisView/analysisViewRequest.ts @@ -1,3 +1,4 @@ +import { graphqlContractViewRequestSchema } from "../apiContract/graphqlContractQuery.js"; import { playwrightTraceViewRequestSchema } from "../playwrightTrace/playwrightTraceQuery.js"; import { apiContractViewRequestSchema } from "../apiContract/apiContractQuery.js"; import { @@ -77,6 +78,7 @@ export const analysisViewRequestSchema = z.discriminatedUnion("kind", [ sourceToBundleViewRequestSchema, endpointComparisonViewRequestSchema, apiContractViewRequestSchema, + graphqlContractViewRequestSchema, playwrightTraceViewRequestSchema, featureTraceViewRequestSchema, nativeFeatureTraceViewRequestSchema, diff --git a/src/domain/analysisView/analysisViewResult.ts b/src/domain/analysisView/analysisViewResult.ts index eadb67db30..301e68cbeb 100644 --- a/src/domain/analysisView/analysisViewResult.ts +++ b/src/domain/analysisView/analysisViewResult.ts @@ -1,3 +1,8 @@ +import { graphqlContractViewRequestSchema } from "../apiContract/graphqlContractQuery.js"; +import { + graphqlContractViewContextSchema, + graphqlContractViewItemsSchema, +} from "./graphqlContractView.js"; import { playwrightTraceViewRequestSchema } from "../playwrightTrace/playwrightTraceQuery.js"; import { playwrightTraceViewContextSchema, @@ -1049,7 +1054,10 @@ export const playwrightTracePageViewResultSchema = z.strictObject({ export const apiContractSummaryViewResultSchema = z.strictObject({ ...summaryViewResultBase, parent_operation: z.literal("inspect_api_contract"), - summary: apiContractViewContextSchema, + summary: z.union([ + apiContractViewContextSchema, + graphqlContractViewContextSchema, + ]), }); export const apiContractPageViewResultSchema = z.strictObject({ ...viewResultBase, @@ -1060,6 +1068,13 @@ export const apiContractPageViewResultSchema = z.strictObject({ view: apiContractViewRequestSchema, items: apiContractViewItemsSchema, }); +export const graphqlContractPageViewResultSchema = z.strictObject({ + ...viewResultBase, + ...graphqlContractViewContextSchema.shape, + parent_operation: z.literal("inspect_api_contract"), + view: graphqlContractViewRequestSchema, + items: graphqlContractViewItemsSchema, +}); export const endpointComparisonSummaryViewResultSchema = z.strictObject({ ...summaryViewResultBase, artifact: z.null(), @@ -1207,6 +1222,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ sourceToBundlePageViewResultSchema, endpointComparisonPageViewResultSchema, apiContractPageViewResultSchema, + graphqlContractPageViewResultSchema, featureTraceViewResultSchema, batchDecompileViewResultSchema, z.strictObject({ diff --git a/src/domain/analysisView/apiContractView.ts b/src/domain/analysisView/apiContractView.ts index 6040ac5caf..957a149771 100644 --- a/src/domain/analysisView/apiContractView.ts +++ b/src/domain/analysisView/apiContractView.ts @@ -1,3 +1,5 @@ +import { graphqlContractSchema } from "../apiContract/graphqlContract.js"; +import { projectGraphqlContractView } from "./graphqlContractView.js"; import { z } from "zod"; import { apiContractSchema, @@ -57,7 +59,9 @@ export const apiContractViewItemsSchema = z.union([ .required({ diagnostic_id: true, source_pointer: true, code: true }), ), ]); -const readReport = createAnalysisViewResultReader(apiContractSchema); +const readReport = createAnalysisViewResultReader( + z.union([apiContractSchema, graphqlContractSchema]), +); const values = (selected: readonly string[] | undefined) => selected === undefined ? undefined : new Set(selected); const admits = (selection: ReadonlySet | undefined, value: string) => @@ -144,6 +148,10 @@ export const projectApiContractView = ( parent: AnalysisViewParent, admitted: AdmittedView, ) => { + const parsed = readReport(parent); + if (!parsed.ok) return parsed; + if (parsed.value.format === "graphql-sdl") + return projectGraphqlContractView(parent, admitted, parsed.value); const view = ownedView< SummaryAnalysisView | z.output >(admitted, "summary", "api-contract"); @@ -158,8 +166,6 @@ export const projectApiContractView = ( }, ]), ); - const parsed = readReport(parent); - if (!parsed.ok) return parsed; const { operations: _operations, path_item_references: _references, diff --git a/src/domain/analysisView/graphqlContractView.ts b/src/domain/analysisView/graphqlContractView.ts new file mode 100644 index 0000000000..e2f1e7d4a8 --- /dev/null +++ b/src/domain/analysisView/graphqlContractView.ts @@ -0,0 +1,293 @@ +import { z } from "zod"; +import { + graphqlContractSchema, + graphqlTypeSchema, + graphqlFieldSchema, + graphqlDefinitionSchema, + graphqlReferenceSchema, + graphqlSpreadSchema, + graphqlDiagnosticSchema, + type GraphqlContract, +} from "../apiContract/graphqlContract.js"; +import { graphqlContractViewRequestSchema } from "../apiContract/graphqlContractQuery.js"; +import type { JsonValue } from "../jsonValue.js"; +import { err, ok } from "../result.js"; +import type { AnalysisViewParent } from "./analysisViewParent.js"; +import { + ownedView, + type AdmittedView, + type SummaryAnalysisView, +} from "./analysisViewShape.js"; +import { + boundedViewMessages, + completeWithinViewCoverage, + pageViewCoverage, +} from "./analysisViewCoverage.js"; +import { analysisViewInputError } from "./analysisViewErrors.js"; +import { selectViewRows } from "./selectViewRows.js"; + +export const graphqlContractViewContextSchema = graphqlContractSchema.omit({ + types: true, + fields: true, + definitions: true, + references: true, + fragment_spreads: true, + diagnostics: true, + limitations: true, +}); +export const graphqlContractViewItemsSchema = z.union([ + z.array( + graphqlTypeSchema.partial().required({ type_id: true, source: true }), + ), + z.array( + graphqlFieldSchema + .partial() + .required({ field_id: true, owner_type_id: true, source: true }), + ), + z.array( + graphqlDefinitionSchema + .partial() + .required({ definition_id: true, source: true }), + ), + z.array( + graphqlReferenceSchema.partial().required({ + reference_id: true, + definition_id: true, + source: true, + state: true, + reason: true, + }), + ), + z.array( + graphqlSpreadSchema.partial().required({ + spread_id: true, + definition_id: true, + source: true, + state: true, + }), + ), + z.array( + graphqlDiagnosticSchema.partial().required({ + diagnostic_id: true, + source: true, + source_role: true, + code: true, + }), + ), +]); +type View = z.output; +const values = (selected: readonly string[] | undefined) => + selected === undefined ? undefined : new Set(selected); + +/** Scan retained facts once; allocate projections only for the selected page. */ +const select = ( + rows: readonly Row[], + view: View, + filters: Partial | undefined>>, + columns: { + readonly defaults: readonly string[]; + readonly required: readonly Key[]; + }, +) => { + const keys = [ + ...new Set([ + ...(view.fields ?? columns.defaults), + ...columns.required, + ]), + ]; + return selectViewRows( + rows, + view, + (row) => { + for (const key in filters) { + const selected = filters[key]; + if ( + selected !== undefined && + (typeof row[key] !== "string" || !selected.has(row[key] as string)) + ) + return false; + } + return true; + }, + (row) => { + const source = row as Record; + return Object.fromEntries( + keys.map((key) => [key, source[key as keyof Row]]), + ) as Partial & Pick; + }, + ); +}; +const referenceColumns = { + defaults: [ + "field_name", + "response_key", + "parent_type_name", + "schema_field_id", + "result_type", + "parent_reference_id", + ], + required: ["reference_id", "definition_id", "source", "state", "reason"], +} as const; + +const selectRows = (report: GraphqlContract, view: View) => { + switch (view.collection) { + case "types": + return select( + report.types, + view, + { + type_id: values(view.type_ids), + name: values(view.names), + }, + { + defaults: ["name", "kind", "extension"], + required: ["type_id", "source"], + }, + ); + case "fields": + return select( + report.fields, + view, + { + field_id: values(view.field_ids), + field_key: values(view.field_keys), + owner_type_name: values(view.owner_type_names), + }, + { + defaults: ["field_key", "kind", "type_ref"], + required: ["field_id", "owner_type_id", "source"], + }, + ); + case "definitions": + return select( + report.definitions, + view, + { + definition_id: values(view.definition_ids), + name: values(view.names), + kind: values(view.kinds), + }, + { + defaults: ["name", "kind", "type_condition"], + required: ["definition_id", "source"], + }, + ); + case "references": + return select( + report.references, + view, + { + reference_id: values(view.reference_ids), + definition_id: values(view.definition_ids), + schema_field_id: values(view.schema_field_ids), + state: values(view.states), + }, + referenceColumns, + ); + case "fragment-spreads": + return select( + report.fragment_spreads, + view, + { + definition_id: values(view.definition_ids), + name: values(view.names), + state: values(view.states), + }, + { + defaults: [ + "name", + "target_definition_id", + "matching_definitions", + "parent_reference_id", + ], + required: ["spread_id", "definition_id", "source", "state"], + }, + ); + case "diagnostics": + return select( + report.diagnostics, + view, + { + code: values(view.codes), + }, + { + defaults: ["message"], + required: ["diagnostic_id", "source", "source_role", "code"], + }, + ); + } +}; + +/** Source coverage and unknown bindings remain independent of page exhaustion. */ +export const projectGraphqlContractView = ( + parent: AnalysisViewParent, + admitted: AdmittedView, + report: GraphqlContract, +) => { + const view = ownedView( + admitted, + "summary", + "graphql-contract", + ); + if (view === undefined) + return err( + analysisViewInputError([ + { + path: ["view"], + reason: "invalid_value", + message: + "GraphQL inventories support summary and graphql-contract pages.", + }, + ]), + ); + const { + types: _types, + fields: _fields, + definitions: _definitions, + references: _references, + fragment_spreads: _spreads, + diagnostics: _diagnostics, + limitations, + ...context + } = report; + const shared = { + parent_evidence_id: parent.evidenceId, + parent_operation: "inspect_api_contract" as const, + parent_digest: parent.evidenceId.slice(3), + limitations: boundedViewMessages( + [...parent.limitations, ...limitations], + parent.evidenceId, + "limitations", + ), + unknowns: + context.source_coverage.status === "partial" + ? [ + "Unresolved bindings, fragment edges or unavailable parsing/model stages limit this inventory; inspect references, fragment-spreads and diagnostics.", + ] + : [], + }; + if (view.kind === "summary") + return ok({ + ...shared, + kind: "summary" as const, + view, + artifact: { + path: context.artifact.path, + sha256: context.artifact.sha256, + }, + summary: context, + coverage: completeWithinViewCoverage(1, 1), + }); + const selected = selectRows(report, view); + return ok({ + ...shared, + ...context, + view, + items: selected.rows, + coverage: pageViewCoverage( + view.offset, + selected.rows.length, + selected.total, + ), + }); +}; diff --git a/src/domain/apiContract/GraphqlDocuments.ts b/src/domain/apiContract/GraphqlDocuments.ts new file mode 100644 index 0000000000..564123b622 --- /dev/null +++ b/src/domain/apiContract/GraphqlDocuments.ts @@ -0,0 +1,88 @@ +import { GraphQLError, Source, parse, visit, type DocumentNode } from "graphql"; +import { AnalysisResourceConstraintError } from "../analysisErrorCore.js"; +import { + API_CONTRACT_OPERATION, + API_CONTRACT_MAX_FILE_BYTES, + type ApiContractArtifact, +} from "./apiContract.js"; +import { + GRAPHQL_CONTRACT_LIMITS, + type GraphqlSource, +} from "./graphqlContract.js"; + +export interface GraphqlSourceDocument { + readonly artifact: ApiContractArtifact; + readonly text: string; +} + +/** The library does not distinguish syntax and parser-policy errors with a stable code. */ +export class GraphqlDocumentRefusal extends Error { + readonly source: GraphqlSource | null; + constructor( + cause: GraphQLError, + document: GraphqlSourceDocument, + role: GraphqlSource["role"], + ) { + super("The GraphQL parser refused this document.", { cause }); + const offset = cause.positions?.[0], + location = cause.locations?.[0]; + this.source = + offset === undefined || location === undefined + ? null + : { + role, + sha256: document.artifact.sha256, + start_utf16: offset, + end_utf16: offset, + line: location.line, + column: location.column, + }; + } +} + +/** Public parser allowances bound allocation; iterative AST admission bounds subsequent traversal. */ +export const parseGraphqlDocument = ( + document: GraphqlSourceDocument, + role: GraphqlSource["role"], +): DocumentNode => { + try { + const parsed = parse(new Source(document.text, document.artifact.path), { + maxLexicalTokens: GRAPHQL_CONTRACT_LIMITS.lexicalTokens, + maxComments: GRAPHQL_CONTRACT_LIMITS.comments, + maxSourceLength: API_CONTRACT_MAX_FILE_BYTES, + }); + let depth = 0; + visit(parsed, { + enter() { + depth++; + if (depth > GRAPHQL_CONTRACT_LIMITS.astDepth) + throw new AnalysisResourceConstraintError( + API_CONTRACT_OPERATION, + "memory", + "GraphQL AST nesting exceeds the 128-level traversal allowance.", + { max_ast_depth: GRAPHQL_CONTRACT_LIMITS.astDepth }, + ); + }, + leave() { + depth--; + }, + }); + return parsed; + } catch (cause) { + if (cause instanceof GraphQLError) + throw new GraphqlDocumentRefusal(cause, document, role); + if (cause instanceof RangeError) + throw new AnalysisResourceConstraintError( + API_CONTRACT_OPERATION, + "memory", + "GraphQL parsing exceeded a runtime representation allowance.", + { parser: "graphql-js" }, + { + cause, + remediationAction: + "Use a smaller, less deeply nested complete document.", + }, + ); + throw cause; + } +}; diff --git a/src/domain/apiContract/GraphqlInventory.ts b/src/domain/apiContract/GraphqlInventory.ts new file mode 100644 index 0000000000..3279986929 --- /dev/null +++ b/src/domain/apiContract/GraphqlInventory.ts @@ -0,0 +1,180 @@ +import type { ASTNode } from "graphql"; +import { digestCanonicalValue } from "../canonicalDigest.js"; +import { AnalysisResourceConstraintError } from "../analysisErrorCore.js"; +import { + API_CONTRACT_FACT_BYTES, + API_CONTRACT_OPERATION, + type ApiContractArtifact, +} from "./apiContract.js"; +import { + GRAPHQL_CONTRACT_PROFILE, + type GraphqlContract, + type GraphqlSource, +} from "./graphqlContract.js"; + +export const graphqlIdentity = (prefix: string, value: unknown): string => + prefix + "_" + digestCanonicalValue(value); + +/** Positions come from the parser's tokens; do not rescan the source per fact. */ +export const graphqlSource = ( + artifact: ApiContractArtifact, + role: GraphqlSource["role"], + node: ASTNode, +): GraphqlSource => { + const location = node.loc; + if (location === undefined) + throw new Error("The GraphQL parser omitted a required source location."); + return { + role, + sha256: artifact.sha256, + start_utf16: location.start, + end_utf16: location.end, + line: location.startToken.line, + column: location.startToken.column, + }; +}; + +/** One read owns compact declarations, bindings, indices and their resource charge. */ +export class GraphqlInventory { + readonly types: GraphqlContract["types"] = []; + readonly fields: GraphqlContract["fields"] = []; + readonly definitions: GraphqlContract["definitions"] = []; + readonly references: GraphqlContract["references"] = []; + readonly spreads: GraphqlContract["fragment_spreads"] = []; + readonly diagnostics: GraphqlContract["diagnostics"] = []; + readonly fieldIdsByStart = new Map(); + schemaDeclarations: GraphqlContract["source_coverage"]["schema_declarations"] = + "unavailable"; + schemaModel: GraphqlContract["source_coverage"]["schema_model"] = + "unavailable"; + operations: GraphqlContract["source_coverage"]["operations"]; + roots: GraphqlContract["model_roots"] = { + query: null, + mutation: null, + subscription: null, + }; + #bytes = 0; + constructor( + readonly artifact: ApiContractArtifact, + readonly operationArtifact: ApiContractArtifact | null, + readonly interpreterVersion: string, + requestedOperations = operationArtifact !== null, + ) { + this.operations = requestedOperations ? "unavailable" : "not-supplied"; + } + + retain(value: unknown): void { + this.#bytes += 512 + Buffer.byteLength(JSON.stringify(value)) * 4; + if (this.#bytes > API_CONTRACT_FACT_BYTES) + throw new AnalysisResourceConstraintError( + API_CONTRACT_OPERATION, + "memory", + "GraphQL facts exceed the 32 MiB retained representation allowance.", + { retained_fact_allowance: API_CONTRACT_FACT_BYTES }, + { + remediationAction: + "Select smaller complete schema/operation documents; reply paging does not raise parser allowances.", + }, + ); + } + + diagnostic( + code: string, + message: string, + role: GraphqlSource["role"], + source: GraphqlSource | null = null, + ): void { + const row = { + diagnostic_id: graphqlIdentity("graphql_diagnostic", [ + this.artifact.sha256, + this.operationArtifact?.sha256 ?? null, + source, + role, + code, + this.diagnostics.length, + ]), + code, + message, + source_role: role, + source, + }; + this.retain(row); + this.diagnostics.push(row); + } + + finish(incomplete = false): GraphqlContract { + let unknownReferences = 0, + modelOnly = 0, + unresolvedSpreads = 0; + for (const row of this.references) { + if (row.state === "unknown") unknownReferences++; + if (row.state === "model-only") modelOnly++; + } + for (const row of this.spreads) + if (row.state !== "resolved") unresolvedSpreads++; + const partial = + incomplete || + this.schemaDeclarations !== "complete" || + this.schemaModel !== "constructed" || + (this.operations !== "complete" && this.operations !== "not-supplied") || + unknownReferences > 0 || + unresolvedSpreads > 0 || + this.diagnostics.length > 0; + return { + kind: "graphql-contract", + format: "graphql-sdl", + contract_id: graphqlIdentity("graphql_contract", [ + this.artifact.sha256, + this.operationArtifact?.sha256 ?? null, + GRAPHQL_CONTRACT_PROFILE, + ]), + decoder_profile: GRAPHQL_CONTRACT_PROFILE, + interpreter: { id: "graphql-js", version: this.interpreterVersion }, + artifact: this.artifact, + operations_artifact: this.operationArtifact, + offset_units: "utf16-code-units", + model_roots: this.roots, + types: this.types, + fields: this.fields, + definitions: this.definitions, + references: this.references, + fragment_spreads: this.spreads, + diagnostics: this.diagnostics, + summary: { + types: this.types.length, + fields: this.fields.length, + definitions: this.definitions.length, + references: this.references.length, + unknown_references: unknownReferences, + model_only_references: modelOnly, + fragment_spreads: this.spreads.length, + unresolved_spreads: unresolvedSpreads, + diagnostics: this.diagnostics.length, + }, + source_coverage: { + status: partial ? "partial" : "complete-within-scope", + schema_declarations: this.schemaDeclarations, + schema_model: this.schemaModel, + operations: + incomplete && this.operations === "complete" + ? "partial" + : this.operations, + operation_validation: "field-resolution-only", + endpoint_behavior: "unknown", + }, + limitations: [ + ...(incomplete + ? [ + "Import stopped before the requested interpretation completed; retained rows are partial observations.", + ] + : []), + "Facts describe supplied SDL and operation text. Schema field bindings are derivations from that SDL and the identified GraphQL library model; reachability, execution and authorization remain unknown.", + "Only type/field declarations, operation/fragment owners, field bindings and explicit fragment-spread edges are interpreted. Arguments, defaults, descriptions, directive behavior, enum values, full operation validation, introspection JSON and version comparison are outside this profile.", + "Fragment definitions are visited once in their declared type scope; spreads are retained as edges without expansion. Resolved field bindings do not establish that a whole operation is valid or that a fragment can execute in every spread context.", + "Model-only bindings have no matching declaration in the supplied SDL. Missing or ambiguous declarations and unavailable models remain explicit.", + "Each supplied file is an independently verified byte snapshot; the pair is not an atomic filesystem observation. UTF-16 offsets and parser token line/column values address decoded UTF-8 text, including an optional BOM.", + "Original document bytes are identified by SHA-256 rather than embedded. Complete interpreted Evidence remains available separately from selected replies.", + ], + }; + } +} diff --git a/src/domain/apiContract/GraphqlOperationFacts.ts b/src/domain/apiContract/GraphqlOperationFacts.ts new file mode 100644 index 0000000000..90caa21d1c --- /dev/null +++ b/src/domain/apiContract/GraphqlOperationFacts.ts @@ -0,0 +1,210 @@ +import { + Kind, + TypeInfo, + isExecutableDefinitionNode, + type DocumentNode, + type GraphQLSchema, + type FieldNode, + type FragmentSpreadNode, + type SelectionSetNode, +} from "graphql"; +import type { + GraphqlDefinition, + GraphqlReference, + GraphqlSpread, +} from "./graphqlContract.js"; +import type { GraphqlSourceDocument } from "./GraphqlDocuments.js"; +import { + GraphqlInventory, + graphqlIdentity, + graphqlSource, +} from "./GraphqlInventory.js"; + +interface DefinitionContext { + readonly progress: { examined: number }; + readonly definitionId: string; + readonly input: GraphqlSourceDocument; + readonly inventory: GraphqlInventory; + readonly info: TypeInfo | undefined; + readonly fragments: ReadonlyMap; +} + +const reference = ( + node: FieldNode, + parentId: string | null, + { definitionId, input, inventory, info }: DefinitionContext, +): GraphqlReference => { + const field = info?.getFieldDef(), + parent = info?.getParentType(); + const position = field?.astNode?.loc?.start; + const fieldId = + position === undefined + ? undefined + : inventory.fieldIdsByStart.get(position); + const source = graphqlSource(input.artifact, "operations", node); + const state = + fieldId !== undefined + ? "resolved" + : field != null + ? "model-only" + : "unknown"; + const reason = + fieldId !== undefined + ? null + : field != null + ? "no-sdl-source" + : info === undefined + ? "schema-model-unavailable" + : parent == null + ? "parent-type-unavailable" + : "unknown-field"; + return { + reference_id: graphqlIdentity("graphql_reference", source), + definition_id: definitionId, + parent_reference_id: parentId, + field_name: node.name.value, + response_key: node.alias?.value ?? node.name.value, + parent_type_name: parent?.name ?? null, + schema_field_id: fieldId ?? null, + result_type: field == null ? null : String(field.type), + state, + reason, + source, + }; +}; + +const spread = ( + node: FragmentSpreadNode, + parentId: string | null, + { definitionId, input, inventory, fragments }: DefinitionContext, +) => { + const candidates = fragments.get(node.name.value) ?? []; + const source = graphqlSource(input.artifact, "operations", node); + const row: GraphqlSpread = { + spread_id: graphqlIdentity("graphql_spread", source), + definition_id: definitionId, + parent_reference_id: parentId, + name: node.name.value, + target_definition_id: + candidates.length === 1 ? candidates[0]!.definition_id : null, + matching_definitions: candidates.length, + state: + candidates.length === 1 + ? "resolved" + : candidates.length === 0 + ? "missing" + : "ambiguous", + source, + }; + inventory.retain(row); + inventory.spreads.push(row); +}; + +/** Only output selections affect field binding; input values/directives remain outside this profile. */ +function* collectSelections( + set: SelectionSetNode, + context: DefinitionContext, + parentId: string | null, +): Generator { + const { info, inventory } = context; + info?.enter(set); + try { + for (const node of set.selections) { + info?.enter(node); + try { + let childParent = parentId; + if (node.kind === Kind.FIELD) { + const row = reference(node, parentId, context); + inventory.retain(row); + inventory.references.push(row); + childParent = row.reference_id; + } else if (node.kind === Kind.FRAGMENT_SPREAD) { + spread(node, parentId, context); + } + // Bound hashing/binding work within one wide query or fragment, too. + if (++context.progress.examined % 128 === 0) yield; + if ( + node.kind !== Kind.FRAGMENT_SPREAD && + node.selectionSet !== undefined + ) + yield* collectSelections(node.selectionSet, context, childParent); + } finally { + info?.leave(node); + } + } + } finally { + info?.leave(set); + } +} + +/** Record each definition once; fragment edges never recursively expand its body. */ +export function* collectGraphqlOperationFacts( + document: DocumentNode, + input: GraphqlSourceDocument, + inventory: GraphqlInventory, + model: GraphQLSchema | undefined, +): Generator { + inventory.operations = "partial"; + const fragments = new Map(); + const owners = new Map(); + for (const definition of document.definitions) { + if (!isExecutableDefinitionNode(definition)) { + inventory.diagnostic( + "non-operation-definition", + "This operation input contains a definition outside operation/fragment interpretation.", + "operations", + graphqlSource(input.artifact, "operations", definition), + ); + continue; + } + const source = graphqlSource(input.artifact, "operations", definition); + const row: GraphqlDefinition = { + definition_id: graphqlIdentity("graphql_definition", source), + kind: + definition.kind === Kind.FRAGMENT_DEFINITION + ? "fragment" + : definition.operation, + name: definition.name?.value ?? null, + type_condition: + definition.kind === Kind.FRAGMENT_DEFINITION + ? definition.typeCondition.name.value + : null, + source, + }; + inventory.retain(row); + inventory.definitions.push(row); + owners.set(source.start_utf16, row); + if (row.kind === "fragment" && row.name !== null) { + const candidates = fragments.get(row.name) ?? []; + candidates.push(row); + fragments.set(row.name, candidates); + } + yield; + } + const progress = { examined: 0 }; + for (const definition of document.definitions) { + if (!isExecutableDefinitionNode(definition)) continue; + const owner = + definition.loc === undefined + ? undefined + : owners.get(definition.loc.start); + if (owner === undefined) continue; + const info = model === undefined ? undefined : new TypeInfo(model); + const context = { + progress, + definitionId: owner.definition_id, + input, + inventory, + info, + fragments, + }; + info?.enter(definition); + try { + yield* collectSelections(definition.selectionSet, context, null); + } finally { + info?.leave(definition); + } + yield; + } + inventory.operations = "complete"; +} diff --git a/src/domain/apiContract/GraphqlSchemaFacts.ts b/src/domain/apiContract/GraphqlSchemaFacts.ts new file mode 100644 index 0000000000..21415cd28d --- /dev/null +++ b/src/domain/apiContract/GraphqlSchemaFacts.ts @@ -0,0 +1,106 @@ +import { + Kind, + isTypeDefinitionNode, + isTypeExtensionNode, + print, + type DocumentNode, + type TypeNode, +} from "graphql"; +import type { GraphqlType, GraphqlField } from "./graphqlContract.js"; +import type { GraphqlSourceDocument } from "./GraphqlDocuments.js"; +import { + GraphqlInventory, + graphqlIdentity, + graphqlSource, +} from "./GraphqlInventory.js"; + +const typeKinds: Partial> = { + [Kind.OBJECT_TYPE_DEFINITION]: "object", + [Kind.OBJECT_TYPE_EXTENSION]: "object", + [Kind.INTERFACE_TYPE_DEFINITION]: "interface", + [Kind.INTERFACE_TYPE_EXTENSION]: "interface", + [Kind.INPUT_OBJECT_TYPE_DEFINITION]: "input-object", + [Kind.INPUT_OBJECT_TYPE_EXTENSION]: "input-object", + [Kind.SCALAR_TYPE_DEFINITION]: "scalar", + [Kind.SCALAR_TYPE_EXTENSION]: "scalar", + [Kind.ENUM_TYPE_DEFINITION]: "enum", + [Kind.ENUM_TYPE_EXTENSION]: "enum", + [Kind.UNION_TYPE_DEFINITION]: "union", + [Kind.UNION_TYPE_EXTENSION]: "union", +}; +const nonTypeSDL = new Set([ + Kind.SCHEMA_DEFINITION, + Kind.SCHEMA_EXTENSION, + Kind.DIRECTIVE_DEFINITION, + Kind.DIRECTIVE_EXTENSION, +]); +const namedType = (input: TypeNode): string => { + let node = input; + while (node.kind !== Kind.NAMED_TYPE) node = node.type; + return node.name.value; +}; + +/** Declaration occurrences remain separate even if SDL model construction rejects duplicates. */ +export function* collectGraphqlSchemaFacts( + document: DocumentNode, + input: GraphqlSourceDocument, + inventory: GraphqlInventory, +): Generator { + inventory.schemaDeclarations = "partial"; + let examined = 0; + for (const definition of document.definitions) { + if (!isTypeDefinitionNode(definition) && !isTypeExtensionNode(definition)) { + if (!nonTypeSDL.has(definition.kind)) + inventory.diagnostic( + "non-schema-definition", + "This schema input contains a definition outside SDL type/field interpretation.", + "schema", + graphqlSource(input.artifact, "schema", definition), + ); + continue; + } + const kind = typeKinds[definition.kind]; + if (kind === undefined) + throw new Error("Missing GraphQL declaration kind."); + const source = graphqlSource(input.artifact, "schema", definition); + const row: GraphqlType = { + type_id: graphqlIdentity("graphql_type", source), + name: definition.name.value, + kind, + extension: isTypeExtensionNode(definition), + interfaces: + "interfaces" in definition + ? (definition.interfaces ?? []).map((node) => node.name.value) + : [], + members: + "types" in definition + ? (definition.types ?? []).map((node) => node.name.value) + : [], + source, + }; + inventory.retain(row); + inventory.types.push(row); + if ("fields" in definition) { + for (const field of definition.fields ?? []) { + const location = graphqlSource(input.artifact, "schema", field); + const fact: GraphqlField = { + field_id: graphqlIdentity("graphql_field", location), + field_key: row.name + "." + field.name.value, + owner_type_id: row.type_id, + owner_type_name: row.name, + kind: kind === "input-object" ? "input" : "output", + name: field.name.value, + type_ref: print(field.type), + named_type: namedType(field.type), + source: location, + }; + inventory.retain(fact); + inventory.fields.push(fact); + inventory.fieldIdsByStart.set(location.start_utf16, fact.field_id); + if (++examined % 128 === 0) yield; + } + } + if (++examined % 128 === 0) yield; + } + inventory.schemaDeclarations = "complete"; +} diff --git a/src/domain/apiContract/apiContractQuery.ts b/src/domain/apiContract/apiContractQuery.ts index 07af27e9db..08a3942946 100644 --- a/src/domain/apiContract/apiContractQuery.ts +++ b/src/domain/apiContract/apiContractQuery.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { localPathStringSchema } from "../localPath.js"; +import { graphqlContractViewRequestSchema } from "./graphqlContractQuery.js"; import { API_CONTRACT_MAX_FILE_BYTES, apiContractDiagnosticSchema, @@ -44,26 +45,51 @@ export const apiContractViewRequestSchema = z.discriminatedUnion("collection", [ }), ]); -export const inspectApiContractInputSchema = z.strictObject({ +export const apiContractFormatSchema = z.enum(["openapi-json", "graphql-sdl"]); +export const apiContractFileBytesSchema = z + .int() + .positive() + .max(API_CONTRACT_MAX_FILE_BYTES) + .default(API_CONTRACT_MAX_FILE_BYTES) + .describe( + "Maximum bytes per input document (up to 32 MiB); reply paging does not increase parser allowances", + ); +export const apiContractViewSelectionSchema = z.discriminatedUnion("kind", [ + apiContractViewRequestSchema, + graphqlContractViewRequestSchema, +]); +const commonInput = { path: localPathStringSchema.describe( - "Absolute path to one local OpenAPI JSON document", + "Absolute path to a local API contract document", ), - format: z.literal("openapi-json").default("openapi-json"), - max_file_bytes: z - .int() - .positive() - .max(API_CONTRACT_MAX_FILE_BYTES) - .default(API_CONTRACT_MAX_FILE_BYTES) - .describe( - "Maximum input document bytes (up to 32 MiB); reply paging does not increase parser allowances", - ), + max_file_bytes: apiContractFileBytesSchema, detail: z.enum(["summary", "complete"]).default("summary"), - view: apiContractViewRequestSchema - .optional() - .describe( - "Return this selected page immediately; complete Evidence is retained and view takes precedence over detail", - ), -}); +}; +const selectionDescription = + "Return this selected page immediately; complete Evidence is retained and view takes precedence over detail"; +export const inspectApiContractInputSchema = z + .union([ + z.strictObject({ + ...commonInput, + format: z.literal("openapi-json").default("openapi-json"), + view: apiContractViewRequestSchema + .optional() + .describe(selectionDescription), + }), + z.strictObject({ + ...commonInput, + format: z.literal("graphql-sdl"), + operations_path: localPathStringSchema + .optional() + .describe( + "Optional absolute path to one local GraphQL operation/fragment document; parsed without execution", + ), + view: graphqlContractViewRequestSchema + .optional() + .describe(selectionDescription), + }), + ]) + .meta({ "x-rea-root-discriminator": "format" }); export type InspectApiContractInput = z.output< typeof inspectApiContractInputSchema >; diff --git a/src/domain/apiContract/graphqlContract.ts b/src/domain/apiContract/graphqlContract.ts new file mode 100644 index 0000000000..22af042689 --- /dev/null +++ b/src/domain/apiContract/graphqlContract.ts @@ -0,0 +1,142 @@ +import { z } from "zod"; +import { digestSchema, prefixedDigestSchema } from "../digests.js"; +import { apiContractArtifactSchema } from "./apiContract.js"; + +/** Parser representation policies, not GraphQL language limits. */ +export const GRAPHQL_CONTRACT_LIMITS = { + lexicalTokens: 262144, + comments: 65536, + astDepth: 128, +} as const; +export const GRAPHQL_CONTRACT_PROFILE = "graphql-sdl-field-bindings-v1"; + +export const graphqlSourceSchema = z.strictObject({ + role: z.enum(["schema", "operations"]), + sha256: digestSchema, + start_utf16: z.int().nonnegative(), + end_utf16: z.int().nonnegative(), + line: z.int().positive(), + column: z.int().positive(), +}); +export const graphqlTypeSchema = z.strictObject({ + type_id: prefixedDigestSchema("graphql_type"), + name: z.string(), + kind: z.enum([ + "object", + "interface", + "input-object", + "scalar", + "enum", + "union", + ]), + extension: z.boolean(), + interfaces: z.array(z.string()), + members: z.array(z.string()), + source: graphqlSourceSchema, +}); +export const graphqlFieldSchema = z.strictObject({ + field_id: prefixedDigestSchema("graphql_field"), + field_key: z.string(), + owner_type_id: graphqlTypeSchema.shape.type_id, + owner_type_name: z.string(), + kind: z.enum(["output", "input"]), + name: z.string(), + type_ref: z.string(), + named_type: z.string(), + source: graphqlSourceSchema, +}); +export const graphqlDefinitionSchema = z.strictObject({ + definition_id: prefixedDigestSchema("graphql_definition"), + kind: z.enum(["query", "mutation", "subscription", "fragment"]), + name: z.string().nullable(), + type_condition: z.string().nullable(), + source: graphqlSourceSchema, +}); +export const graphqlReferenceSchema = z.strictObject({ + reference_id: prefixedDigestSchema("graphql_reference"), + definition_id: graphqlDefinitionSchema.shape.definition_id, + parent_reference_id: prefixedDigestSchema("graphql_reference").nullable(), + field_name: z.string(), + response_key: z.string(), + parent_type_name: z.string().nullable(), + schema_field_id: graphqlFieldSchema.shape.field_id.nullable(), + result_type: z.string().nullable(), + state: z.enum(["resolved", "model-only", "unknown"]), + reason: z + .enum([ + "schema-model-unavailable", + "parent-type-unavailable", + "unknown-field", + "no-sdl-source", + ]) + .nullable(), + source: graphqlSourceSchema, +}); +export const graphqlSpreadSchema = z.strictObject({ + spread_id: prefixedDigestSchema("graphql_spread"), + definition_id: graphqlDefinitionSchema.shape.definition_id, + parent_reference_id: graphqlReferenceSchema.shape.reference_id.nullable(), + name: z.string(), + target_definition_id: graphqlDefinitionSchema.shape.definition_id.nullable(), + matching_definitions: z.int().nonnegative(), + state: z.enum(["resolved", "missing", "ambiguous"]), + source: graphqlSourceSchema, +}); +export const graphqlDiagnosticSchema = z.strictObject({ + diagnostic_id: prefixedDigestSchema("graphql_diagnostic"), + code: z.string(), + message: z.string(), + source_role: graphqlSourceSchema.shape.role, + source: graphqlSourceSchema.nullable(), +}); +export const graphqlContractSchema = z.strictObject({ + kind: z.literal("graphql-contract"), + format: z.literal("graphql-sdl"), + contract_id: prefixedDigestSchema("graphql_contract"), + decoder_profile: z.literal(GRAPHQL_CONTRACT_PROFILE), + interpreter: z.strictObject({ + id: z.literal("graphql-js"), + version: z.string(), + }), + artifact: apiContractArtifactSchema, + operations_artifact: apiContractArtifactSchema.nullable(), + offset_units: z.literal("utf16-code-units"), + model_roots: z.strictObject({ + query: z.string().nullable(), + mutation: z.string().nullable(), + subscription: z.string().nullable(), + }), + types: z.array(graphqlTypeSchema), + fields: z.array(graphqlFieldSchema), + definitions: z.array(graphqlDefinitionSchema), + references: z.array(graphqlReferenceSchema), + fragment_spreads: z.array(graphqlSpreadSchema), + diagnostics: z.array(graphqlDiagnosticSchema), + summary: z.strictObject({ + types: z.int().nonnegative(), + fields: z.int().nonnegative(), + definitions: z.int().nonnegative(), + references: z.int().nonnegative(), + unknown_references: z.int().nonnegative(), + model_only_references: z.int().nonnegative(), + fragment_spreads: z.int().nonnegative(), + unresolved_spreads: z.int().nonnegative(), + diagnostics: z.int().nonnegative(), + }), + source_coverage: z.strictObject({ + status: z.enum(["complete-within-scope", "partial"]), + schema_declarations: z.enum(["complete", "partial", "unavailable"]), + schema_model: z.enum(["constructed", "unavailable"]), + operations: z.enum(["complete", "partial", "unavailable", "not-supplied"]), + operation_validation: z.literal("field-resolution-only"), + endpoint_behavior: z.literal("unknown"), + }), + limitations: z.array(z.string()), +}); +export type GraphqlContract = z.output; +export type GraphqlSource = z.output; +export type GraphqlType = z.output; +export type GraphqlField = z.output; +export type GraphqlDefinition = z.output; +export type GraphqlReference = z.output; +export type GraphqlSpread = z.output; diff --git a/src/domain/apiContract/graphqlContractQuery.ts b/src/domain/apiContract/graphqlContractQuery.ts new file mode 100644 index 0000000000..55f2b17b5b --- /dev/null +++ b/src/domain/apiContract/graphqlContractQuery.ts @@ -0,0 +1,74 @@ +import { z } from "zod"; +import { + graphqlTypeSchema, + graphqlFieldSchema, + graphqlDefinitionSchema, + graphqlReferenceSchema, + graphqlSpreadSchema, + graphqlDiagnosticSchema, +} from "./graphqlContract.js"; + +const page = { + kind: z.literal("graphql-contract"), + offset: z.int().nonnegative().default(0), + limit: z.int().positive().default(64), +}; +export const graphqlContractViewRequestSchema = z.discriminatedUnion( + "collection", + [ + z.strictObject({ + ...page, + collection: z.literal("types"), + type_ids: z.array(graphqlTypeSchema.shape.type_id).optional(), + names: z.array(z.string()).optional(), + fields: z.array(graphqlTypeSchema.keyof()).optional(), + }), + z.strictObject({ + ...page, + collection: z.literal("fields"), + field_ids: z.array(graphqlFieldSchema.shape.field_id).optional(), + field_keys: z.array(z.string()).optional(), + owner_type_names: z.array(z.string()).optional(), + fields: z.array(graphqlFieldSchema.keyof()).optional(), + }), + z.strictObject({ + ...page, + collection: z.literal("definitions"), + definition_ids: z + .array(graphqlDefinitionSchema.shape.definition_id) + .optional(), + names: z.array(z.string()).optional(), + kinds: z.array(graphqlDefinitionSchema.shape.kind).optional(), + fields: z.array(graphqlDefinitionSchema.keyof()).optional(), + }), + z.strictObject({ + ...page, + collection: z.literal("references"), + reference_ids: z + .array(graphqlReferenceSchema.shape.reference_id) + .optional(), + definition_ids: z + .array(graphqlDefinitionSchema.shape.definition_id) + .optional(), + schema_field_ids: z.array(graphqlFieldSchema.shape.field_id).optional(), + states: z.array(graphqlReferenceSchema.shape.state).optional(), + fields: z.array(graphqlReferenceSchema.keyof()).optional(), + }), + z.strictObject({ + ...page, + collection: z.literal("fragment-spreads"), + definition_ids: z + .array(graphqlDefinitionSchema.shape.definition_id) + .optional(), + names: z.array(z.string()).optional(), + states: z.array(graphqlSpreadSchema.shape.state).optional(), + fields: z.array(graphqlSpreadSchema.keyof()).optional(), + }), + z.strictObject({ + ...page, + collection: z.literal("diagnostics"), + codes: z.array(z.string()).optional(), + fields: z.array(graphqlDiagnosticSchema.keyof()).optional(), + }), + ], +); diff --git a/src/domain/apiContract/parseGraphqlContract.test.ts b/src/domain/apiContract/parseGraphqlContract.test.ts new file mode 100644 index 0000000000..b4c997e7d5 --- /dev/null +++ b/src/domain/apiContract/parseGraphqlContract.test.ts @@ -0,0 +1,340 @@ +import { + buildASTSchema, + parse as parseNative, + TypeInfo, + visit, + visitWithTypeInfo, +} from "graphql"; +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { expect, it } from "vitest"; +import { parseGraphqlContractSteps } from "./parseGraphqlContract.js"; +import { graphqlContractSchema } from "./graphqlContract.js"; +import { projectAnalysisError } from "../analysisErrorProjection.js"; +import type { GraphqlSourceDocument } from "./GraphqlDocuments.js"; + +const source = (text: string, name: string): GraphqlSourceDocument => ({ + text, + artifact: { + path: "/fixtures/" + name + ".graphql", + sha256: createHash("sha256").update(text).digest("hex"), + bytes: Buffer.byteLength(text), + }, +}); +const parse = (schema: string, operations?: string) => { + const steps = parseGraphqlContractSteps( + source(schema, "schema"), + operations === undefined ? undefined : source(operations, "operations"), + ); + for (;;) { + const step = steps.next(); + if (step.done) return step.value; + } +}; +const successful = (schema: string, operations?: string) => { + const result = parse(schema, operations); + if (!result.ok) throw result.error; + return graphqlContractSchema.parse(result.value); +}; + +it("binds aliases and extension fields to exact SDL occurrences with list/null wrappers", () => { + const schema = + "type Query { people: [Person!]! } type Person { id: ID! } extend type Person { name: String }"; + const operations = + "query People { rows: people { ...Identity name } } fragment Identity on Person { id }"; + const report = successful(schema, operations); + expect(report.types.map((row) => [row.name, row.extension])).toEqual([ + ["Query", false], + ["Person", false], + ["Person", true], + ]); + expect( + report.fields.map((row) => [row.field_key, row.type_ref, row.named_type]), + ).toEqual([ + ["Query.people", "[Person!]!", "Person"], + ["Person.id", "ID!", "ID"], + ["Person.name", "String", "String"], + ]); + expect( + report.references.map((row) => [ + row.field_name, + row.response_key, + row.parent_type_name, + row.state, + ]), + ).toEqual([ + ["people", "rows", "Query", "resolved"], + ["name", "name", "Person", "resolved"], + ["id", "id", "Person", "resolved"], + ]); + for (const reference of report.references) { + const field = report.fields.find( + (row) => row.field_id === reference.schema_field_id, + )!; + expect(field.field_key).toBe( + reference.parent_type_name + "." + reference.field_name, + ); + expect( + schema.slice(field.source.start_utf16, field.source.end_utf16), + ).toContain(field.name); + expect( + operations.slice( + reference.source.start_utf16, + reference.source.end_utf16, + ), + ).toContain(reference.field_name); + } + expect(report.references[1]?.parent_reference_id).toBe( + report.references[0]?.reference_id, + ); + expect(report.references[2]?.parent_reference_id).toBeNull(); + expect(report.fragment_spreads[0]?.target_definition_id).toBe( + report.definitions[1]?.definition_id, + ); + expect(report.source_coverage).toMatchObject({ + status: "complete-within-scope", + endpoint_behavior: "unknown", + }); +}); + +it("uses declared roots and interface/inline-fragment type scopes", () => { + const report = successful( + "schema { query: Root } interface Node { id: ID! } type User implements Node { id: ID! name: String } type Root { node: Node }", + "{ node { id ... on User { name } } }", + ); + expect(report.model_roots.query).toBe("Root"); + expect(report.references.map((row) => row.parent_type_name)).toEqual([ + "Root", + "Node", + "User", + ]); + expect(report.types.find((row) => row.name === "User")?.interfaces).toEqual([ + "Node", + ]); + expect(report.references.every((row) => row.state === "resolved")).toBe(true); +}); + +it("retains fragment cycles as finite edges without expanding selections", () => { + const report = successful( + "type Query { value: String }", + "query Q { ...A } fragment A on Query { value ...A }", + ); + expect(report.definitions).toHaveLength(2); + expect(report.references).toHaveLength(1); + expect(report.fragment_spreads).toHaveLength(2); + expect(report.fragment_spreads.every((row) => row.state === "resolved")).toBe( + true, + ); + expect(report.source_coverage.operation_validation).toBe( + "field-resolution-only", + ); +}); + +it("keeps duplicate fragment names ambiguous and missing fragments explicit", () => { + const report = successful( + "type Query { value: String }", + "query Q { ...A ...Missing } fragment A on Query { value } fragment A on Query { value }", + ); + expect( + report.fragment_spreads.map((row) => [ + row.state, + row.matching_definitions, + row.target_definition_id, + ]), + ).toEqual([ + ["ambiguous", 2, null], + ["missing", 0, null], + ]); + expect(report.summary.unresolved_spreads).toBe(2); + expect(report.source_coverage.status).toBe("partial"); +}); + +it("preserves duplicate SDL field occurrences without selecting a model winner", () => { + const report = successful( + "type Query { value: String value: Int }", + "{ value }", + ); + expect(report.fields.map((row) => row.field_key)).toEqual([ + "Query.value", + "Query.value", + ]); + expect(new Set(report.fields.map((row) => row.field_id)).size).toBe(2); + expect(report.source_coverage.schema_model).toBe("unavailable"); + expect(report.references[0]).toMatchObject({ + state: "unknown", + reason: "schema-model-unavailable", + schema_field_id: null, + }); +}); + +it("retains unknown field and unavailable parent scopes instead of assuming absence", () => { + const report = successful( + "type Query { value: String }", + "{ missing { child } }", + ); + expect(report.references.map((row) => row.reason)).toEqual([ + "unknown-field", + "parent-type-unavailable", + ]); + expect(report.summary.unknown_references).toBe(2); + expect(report.source_coverage.status).toBe("partial"); +}); + +it("distinguishes library model fields from supplied SDL declarations", () => { + const report = successful("type Query { value: String }", "{ __typename }"); + expect(report.references[0]).toMatchObject({ + state: "model-only", + reason: "no-sdl-source", + schema_field_id: null, + result_type: "String!", + }); + expect(report.summary.model_only_references).toBe(1); +}); + +it("preserves input fields, union members and ordinary credential-like names", () => { + const report = successful( + "input Input { access_token: String } type User { authorization: String } union Result = User type Query { result(input: Input): Result }", + "{ result { ... on User { authorization } } }", + ); + expect( + report.fields.find((row) => row.field_key === "Input.access_token")?.kind, + ).toBe("input"); + expect(report.types.find((row) => row.name === "Result")?.members).toEqual([ + "User", + ]); + expect(report.references.at(-1)?.field_name).toBe("authorization"); +}); + +it("keeps verified SDL facts when the operation document cannot be parsed", () => { + const report = successful( + "type Query { value: String }", + '{ value( url: "https://name:FAKE_GQL_SECRET@example.invalid" ) ', + ); + expect(report.fields).toHaveLength(1); + expect(report.operations_artifact).not.toBeNull(); + expect(report.source_coverage.operations).toBe("unavailable"); + expect(report.diagnostics[0]).toMatchObject({ + code: "operations-parse-refused", + source_role: "operations", + source: { line: 1 }, + }); + expect(JSON.stringify(report)).not.toContain("FAKE_GQL_SECRET"); +}); + +it("retains partial source metadata and source-located neutral diagnostics on schema refusal", () => { + const result = parse( + 'type Query { field: "https://name:FAKE_GQL_SECRET@example.invalid" }', + ); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected syntax refusal"); + expect(result.error._tag).toBe("AnalysisInputError"); + const partial = graphqlContractSchema.parse(result.error.partialObservation); + expect(partial.source_coverage.schema_declarations).toBe("unavailable"); + expect(partial.diagnostics[0]).toMatchObject({ + code: "schema-parse-refused", + source: { line: 1 }, + }); + expect(JSON.stringify(projectAnalysisError(result.error))).not.toContain( + "FAKE_GQL_SECRET", + ); +}); + +it("preserves UTF-16/BOM positions and stable content identities across repeated imports", () => { + const schema = "\uFEFF\n# 雪🦊\ntype Query { value: String }"; + const one = successful(schema, "{ value }"), + two = successful(schema, "{ value }"); + expect(one).toEqual(two); + const field = one.fields[0]!; + expect(field.source).toMatchObject({ role: "schema", line: 3, column: 14 }); + expect(schema.slice(field.source.start_utf16, field.source.end_utf16)).toBe( + "value: String", + ); +}); + +it("refuses deep ASTs through a typed resource result with retained schema facts", () => { + const result = parse( + "type Query { child: Query }", + "{ child ".repeat(90) + "}".repeat(90), + ); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected nesting admission refusal"); + expect(result.error._tag).toBe("AnalysisResourceConstraintError"); + expect( + graphqlContractSchema.parse(result.error.partialObservation).fields, + ).toHaveLength(1); +}); + +it("imports the unmodified published SWAPI SDL with independently counted declarations", async () => { + const bytes = await readFile( + new URL( + "../../../tests/fixtures/graphql/swapi.graphql.txt", + import.meta.url, + ), + ); + expect(bytes.length).toBe(35868); + expect(createHash("sha256").update(bytes).digest("hex")).toBe( + "ebc4d09b21e2aec2735d6f0d3c812fc017cd49a44900dd3ae34f49f6f8c25133", + ); + const report = successful( + bytes.toString("utf8"), + "query Films { allFilms(first: 2) { films { ...FilmIdentity } } } fragment FilmIdentity on Film { id title }", + ); + expect(report.summary).toMatchObject({ + types: 53, + fields: 243, + definitions: 2, + references: 4, + unknown_references: 0, + }); + expect(report.model_roots.query).toBe("Root"); + expect(report.source_coverage.status).toBe("complete-within-scope"); +}); + +it("retains a bounded prefix when expanded field facts exceed the shared allowance", () => { + const result = parse( + "type Query { " + + Array.from({ length: 18000 }, (_, i) => `field${i}: String`).join(" ") + + " }", + ); + if (result.ok) throw new Error("Expected fact capacity refusal"); + expect(result.error._tag).toBe("AnalysisResourceConstraintError"); + const report = graphqlContractSchema.parse(result.error.partialObservation); + expect(report.fields.length).toBeGreaterThan(1000); + expect(report.fields.length).toBeLessThan(18000); + expect(report.source_coverage).toMatchObject({ + status: "partial", + schema_declarations: "partial", + }); +}); + +it("matches full SDK field scope while pruning variable defaults, arguments and directive payloads", () => { + const schema = + "directive @choice(values: [Int!]) on FIELD | QUERY input Filter { name: String } type Query { user(filter: Filter): User } type User { id: ID! name: String }"; + const operations = + 'query Q($filter: Filter = {name:"default"}) @choice(values:[1,2]) { first: user(filter:$filter) @choice(values:[3]) { id } user(filter:{name:"x"}) { id ... on User { name } ...Names } } fragment Names on User { name }'; + const info = new TypeInfo(buildASTSchema(parseNative(schema))); + const expected: unknown[] = []; + visit( + parseNative(operations), + visitWithTypeInfo(info, { + Field(node) { + expected.push([ + node.name.value, + node.alias?.value ?? node.name.value, + info.getParentType()?.name ?? null, + String(info.getType()), + ]); + }, + }), + ); + const report = successful(schema, operations); + expect( + report.references.map((row) => [ + row.field_name, + row.response_key, + row.parent_type_name, + row.result_type, + ]), + ).toEqual(expected); + expect(report.references.every((row) => row.state === "resolved")).toBe(true); +}); diff --git a/src/domain/apiContract/parseGraphqlContract.ts b/src/domain/apiContract/parseGraphqlContract.ts new file mode 100644 index 0000000000..3e6af663ff --- /dev/null +++ b/src/domain/apiContract/parseGraphqlContract.ts @@ -0,0 +1,105 @@ +import { buildASTSchema, version, type GraphQLSchema } from "graphql"; +import { AnalysisError } from "../analysisErrorBase.js"; +import { + AnalysisInputError, + AnalysisResourceConstraintError, +} from "../analysisErrorCore.js"; +import { err, ok, type Result } from "../result.js"; +import type { GraphqlContract } from "./graphqlContract.js"; +import { API_CONTRACT_OPERATION } from "./apiContract.js"; +import { GraphqlInventory } from "./GraphqlInventory.js"; +import { + GraphqlDocumentRefusal, + parseGraphqlDocument, + type GraphqlSourceDocument, +} from "./GraphqlDocuments.js"; +import { collectGraphqlSchemaFacts } from "./GraphqlSchemaFacts.js"; +import { collectGraphqlOperationFacts } from "./GraphqlOperationFacts.js"; + +/** One syntax/model interpretation owns all declarations and field bindings. */ +export function* parseGraphqlContractSteps( + schema: GraphqlSourceDocument, + operations?: GraphqlSourceDocument, +): Generator> { + const inventory = new GraphqlInventory( + schema.artifact, + operations?.artifact ?? null, + version, + ); + try { + const document = parseGraphqlDocument(schema, "schema"); + yield* collectGraphqlSchemaFacts(document, schema, inventory); + let model: GraphQLSchema | undefined; + try { + const constructed = buildASTSchema(document); + const roots = { + query: constructed.getQueryType()?.name ?? null, + mutation: constructed.getMutationType()?.name ?? null, + subscription: constructed.getSubscriptionType()?.name ?? null, + }; + inventory.schemaModel = "constructed"; + inventory.roots = roots; + model = constructed; + } catch (cause) { + if (cause instanceof RangeError) + throw new AnalysisResourceConstraintError( + API_CONTRACT_OPERATION, + "memory", + "GraphQL schema construction exceeded a runtime representation allowance.", + {}, + { cause }, + ); + if (!(cause instanceof Error)) throw cause; + inventory.diagnostic( + "schema-model-unavailable", + "The supplied SDL could not construct a schema model. Declaration occurrences remain available; bindings stay unknown. Full schema validation diagnostics are outside this profile.", + "schema", + ); + } + yield; + if (operations !== undefined) { + try { + const operationDocument = parseGraphqlDocument( + operations, + "operations", + ); + yield* collectGraphqlOperationFacts( + operationDocument, + operations, + inventory, + model, + ); + } catch (cause) { + if (!(cause instanceof GraphqlDocumentRefusal)) throw cause; + inventory.diagnostic( + "operations-parse-refused", + "The operation document was refused by the GraphQL syntax/parser allowance checks; inspect this source location and the documented parser allowances.", + "operations", + cause.source, + ); + } + } + return ok(inventory.finish()); + } catch (cause) { + let error: AnalysisError; + if (cause instanceof GraphqlDocumentRefusal) { + inventory.diagnostic( + "schema-parse-refused", + "The schema document was refused by GraphQL syntax/parser allowance checks; inspect this source location and the documented allowances.", + "schema", + cause.source, + ); + error = new AnalysisInputError(API_CONTRACT_OPERATION, { cause }, [ + { + path: ["path"], + reason: "invalid_format", + message: + "The schema document was refused by the GraphQL syntax/parser allowance checks. Inspect its source location and documented allowances.", + }, + ]); + } else if (cause instanceof AnalysisError) error = cause; + else throw cause; + error.retainPartialObservation(inventory.finish(true)); + return err(error); + } +} diff --git a/src/domain/evidence.test.ts b/src/domain/evidence.test.ts index cd044e5278..12e1f5d639 100644 --- a/src/domain/evidence.test.ts +++ b/src/domain/evidence.test.ts @@ -322,6 +322,45 @@ it("retains verbatim caller input without changing semantic identity or copying expect(() => withEvidenceCallerInputPath(second, "invalid\0path")).toThrow(); }); +it("retains named auxiliary path choices without changing identity, history or shared payloads", () => { + const original = immutableEvidence( + createEvidence(TARGET, PROVIDER, { + operation: "inspect", + parameters: {}, + result: { rows: ["source facts"] }, + }), + ); + const paths = { operations_path: "./queries 雪.graphql" }; + const rebound = withEvidenceCallerInputPath( + original, + "./schema.graphql", + paths, + ); + paths.operations_path = "later unrelated choice"; + expect(rebound.caller_input_paths).toEqual({ + operations_path: "./queries 雪.graphql", + }); + expect(original.caller_input_paths).toBeUndefined(); + expect(rebound.evidence_id).toBe(original.evidence_id); + expect(rebound.normalized_result).toBe(original.normalized_result); + expect(Object.isFrozen(rebound.caller_input_paths)).toBe(true); + expect(parseEvidence(JSON.parse(JSON.stringify(rebound)))).toEqual(rebound); + expect( + withEvidenceCallerInputPath(rebound, "./schema.graphql", { + operations_path: "./queries 雪.graphql", + }), + ).toBe(rebound); + expect( + evidenceSchema.safeParse({ ...original, caller_input_paths: undefined }) + .success, + ).toBe(false); + expect(() => + withEvidenceCallerInputPath(original, "./schema.graphql", { + operations_path: "invalid\0path", + }), + ).toThrow(); +}); + it.each(["caller selection", "subject display"])( "preserves sealed JSON ownership when rebinding %s metadata", (metadata) => { diff --git a/src/domain/evidence.ts b/src/domain/evidence.ts index 9439563979..9b26620951 100644 --- a/src/domain/evidence.ts +++ b/src/domain/evidence.ts @@ -1,3 +1,4 @@ +import { isDeepStrictEqual } from "node:util"; import { basename } from "node:path"; import { z } from "zod"; @@ -83,6 +84,10 @@ const executionEnvironmentSchema = z.strictObject({ architecture: z.string().min(1), isolation: z.enum(["none", "process", "container", "virtual-machine"]), }); +const callerInputPathsSchema = z.record( + nulFreeStringSchema, + nulFreeStringSchema, +); const evidenceBaseSchema = z .object({ @@ -97,6 +102,11 @@ const evidenceBaseSchema = z caller_input_path: nulFreeStringSchema .exactOptional() .describe("Verbatim direct-request path; not part of semantic identity."), + caller_input_paths: callerInputPathsSchema + .exactOptional() + .describe( + "Verbatim auxiliary input paths keyed by argument name; caller assertions outside semantic identity.", + ), raw_result: jsonValueSchema.nullable(), normalized_result: jsonValueSchema, confidence: z.enum(["observed", "derived", "inferred"]), @@ -277,6 +287,16 @@ export const parseEvidence = (input: unknown): Evidence => { return authenticatedEvidenceSchema.parse(input); }; +/** Evidence owns all presentation metadata excluded from its semantic identity. */ +export const evidenceDisplayMetadataAgree = ( + left: Evidence, + right: Evidence, +): boolean => + left.subject?.name === right.subject?.name && + left.subject?.local_path === right.subject?.local_path && + left.caller_input_path === right.caller_input_path && + isDeepStrictEqual(left.caller_input_paths, right.caller_input_paths); + /** Recognize authenticated Evidence in a union of partial observations. */ export const tryParseEvidence = (input: unknown): Evidence | undefined => { if (typeof input !== "object" || input === null || !("evidence_id" in input)) @@ -332,15 +352,23 @@ export const rebindEvidenceToTargetPath = ( export const withEvidenceCallerInputPath = ( evidence: Evidence, rawPath: string, + additionalPaths?: Readonly>, ): Evidence => { const callerInputPath = nulFreeStringSchema.parse(rawPath); const owned = immutableEvidence(evidence); - if (owned.caller_input_path === callerInputPath) return owned; + const paths = + additionalPaths === undefined + ? undefined + : callerInputPathsSchema.parse(additionalPaths); + const samePaths = + paths === undefined || isDeepStrictEqual(owned.caller_input_paths, paths); + if (owned.caller_input_path === callerInputPath && samePaths) return owned; // The authenticated source owns its immutable payloads. Only this small // envelope changes, so replay does not copy a document-sized result. const rebound = { ...owned, caller_input_path: callerInputPath, + ...(paths === undefined ? {} : { caller_input_paths: paths }), }; return rememberReboundEvidence(owned, rebound); }; diff --git a/src/server/registerApplicationTools/apiContract.ts b/src/server/registerApplicationTools/apiContract.ts index 2b77c017c1..dbbe07245e 100644 --- a/src/server/registerApplicationTools/apiContract.ts +++ b/src/server/registerApplicationTools/apiContract.ts @@ -42,7 +42,11 @@ export const registerApiContractTool = ( }), ); if (!result.ok) - return server.delivery.toCallToolResult(result, contract); + return server.delivery.toPartialErrorToolResult( + result.error, + contract.returnsTargetContent, + options.recordEvidence, + ); return deliverApplicationEvidence( server, options, diff --git a/tests/acceptance/applications/graphqlContract.test.ts b/tests/acceptance/applications/graphqlContract.test.ts new file mode 100644 index 0000000000..c3c26063d5 --- /dev/null +++ b/tests/acceptance/applications/graphqlContract.test.ts @@ -0,0 +1,415 @@ +import { expectCliMcpEvidenceParity } from "../../fixtures/cliMcpEvidenceParity.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { createHash } from "node:crypto"; +import { readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { cliTest, type TestCli } from "../../support/cli/cliFixture.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { advertisedMcpToolSchemas } from "../../fixtures/advertisedMcpTool.js"; +import { parseEvidence, type Evidence } from "../../../src/domain/evidence.js"; +import { evidenceBundleSchema } from "../../../src/domain/evidenceBundle.js"; +import { graphqlContractSchema } from "../../../src/domain/apiContract/graphqlContract.js"; +import { analysisViewResultSchema } from "../../../src/domain/analysisView/analysisView.js"; + +const fixture = new URL( + "../../fixtures/graphql/swapi.graphql.txt", + import.meta.url, +); +const operations = + "query Films { renamed: allFilms(first: 2) { films { ...Names director } } } fragment Names on Film { title }"; +const asView = (value: unknown) => + analysisViewResultSchema.parse(parseEvidence(value).normalized_result); +const retained = (parent: Evidence) => ({ + kind: "retained-evidence", + evidence_id: parent.evidence_id, +}); + +const checkCliPublication = async ( + cli: TestCli, + context: { + root: string; + parent: Evidence; + selected: ReturnType; + }, +) => { + const { root, parent, selected } = context; + const run = cli.sharedCliRun?.bind(cli) ?? cli.run.bind(cli); + const args = [ + "inspect-api-contract", + "./SDL 雪.graphql", + "--contractFormat", + "graphql-sdl", + "--operationsPath", + "./queries file.graphql", + ]; + const cliComplete = await run({ + cwd: root, + arguments: [...args, "--json"], + }); + expect(cliComplete.exitCode, cliComplete.stderr).toBe(0); + expectCliMcpEvidenceParity(cliComplete.json, parent, "./SDL 雪.graphql", { + operations_path: "./queries file.graphql", + }); + const bundlePath = join(root, "graphql.evidence.json"); + const cliSelected = await run({ + cwd: root, + arguments: [ + ...args, + "--detail", + "summary", + "--evidence_output", + bundlePath, + "--view", + JSON.stringify({ + kind: "graphql-contract", + collection: "fields", + field_keys: ["Film.title", "Film.director"], + fields: [], + limit: 1, + }), + "--json", + ], + }); + expect(cliSelected.exitCode, cliSelected.stderr).toBe(0); + const receipt = cliSelected.json as { summary: unknown }; + expect(parseEvidence(receipt.summary)).toMatchObject({ + caller_input_paths: { operations_path: "./queries file.graphql" }, + }); + expect(asView(receipt.summary)).toEqual(selected); + const bundle = evidenceBundleSchema.parse( + JSON.parse(await readFile(bundlePath, "utf8")), + ); + expect(bundle.records[0]?.evidence_id).toBe(parent.evidence_id); + return bundlePath; +}; + +type McpCall = Awaited>["call"]; +const checkRetainedPages = async (call: McpCall, parent: Evidence) => { + const report = graphqlContractSchema.parse(parent.normalized_result); + const paged = []; + for (const offset of [0, 100, 200]) { + const response = await call("inspect_analysis_view", { + source: retained(parent), + view: { + kind: "graphql-contract", + collection: "fields", + offset, + limit: 100, + fields: [ + "field_id", + "field_key", + "owner_type_id", + "owner_type_name", + "kind", + "name", + "type_ref", + "named_type", + "source", + ], + }, + }); + const page = asView(response.structuredContent); + if (page.kind !== "graphql-contract") + throw new Error("Expected retained GraphQL page"); + expect(page.source_coverage).toEqual(report.source_coverage); + paged.push(...page.items); + } + expect(paged).toEqual(report.fields); +}; + +const checkAdvertisedSchema = async ( + client: Awaited>["client"], + input: { path: string; format: string; operations_path: string }, +) => { + const schemas = await advertisedMcpToolSchemas( + client, + "inspect_api_contract", + ); + expect(schemas.ajv.validate(schemas.inputSchema, input)).toBe(true); + for (const invalid of [ + { ...input, format: "openapi-json" }, + { ...input, view: { kind: "api-contract", collection: "operations" } }, + { + ...input, + view: { + kind: "graphql-contract", + collection: "fields", + fields: ["unknown-column"], + }, + }, + ]) + expect(schemas.ajv.validate(schemas.inputSchema, invalid)).toBe(false); +}; + +cliTest( + "imports unchanged SWAPI SDL through public CLI/MCP with exact bindings, selected replies and source-free retained pages", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-public-graphql-"); + const path = join(root, "SDL 雪.graphql"), + operationsPath = join(root, "queries file.graphql"); + const bytes = await readFile(fixture); + expect(createHash("sha256").update(bytes).digest("hex")).toBe( + "ebc4d09b21e2aec2735d6f0d3c812fc017cd49a44900dd3ae34f49f6f8c25133", + ); + await writeFile(path, bytes); + await writeFile(operationsPath, operations); + const { client, call } = await connectLocalToolsMcp({ + REA_MCP_INPUT_SCHEMA_PROFILE: "full", + REA_MCP_TOOL_FAMILIES: "application,session", + }); + const input = { + path, + format: "graphql-sdl", + operations_path: operationsPath, + }; + await checkAdvertisedSchema(client, input); + const summaryResponse = await call("inspect_api_contract", input); + expect(summaryResponse.isError).not.toBe(true); + const summary = asView(summaryResponse.structuredContent); + expect(summary).toMatchObject({ + kind: "summary", + summary: { + model_roots: { query: "Root" }, + summary: { + types: 53, + fields: 243, + definitions: 2, + references: 4, + fragment_spreads: 1, + }, + }, + }); + expect(JSON.stringify(summaryResponse.structuredContent)).not.toContain( + "Root.allFilms", + ); + const complete = await call("inspect_api_contract", { + ...input, + detail: "complete", + }); + const parent = parseEvidence(complete.structuredContent); + const report = graphqlContractSchema.parse(parent.normalized_result); + expect(summary.parent_evidence_id).toBe(parent.evidence_id); + expect( + report.references.map((row) => [ + row.field_name, + row.parent_type_name, + row.state, + ]), + ).toEqual([ + ["allFilms", "Root", "resolved"], + ["films", "FilmsConnection", "resolved"], + ["director", "Film", "resolved"], + ["title", "Film", "resolved"], + ]); + for (const reference of report.references) { + const field = report.fields.find( + (row) => row.field_id === reference.schema_field_id, + )!; + expect(field.field_key).toBe( + reference.parent_type_name + "." + reference.field_name, + ); + expect( + bytes + .toString("utf8") + .slice(field.source.start_utf16, field.source.end_utf16), + ).toContain(reference.field_name); + expect(reference.source.sha256).toBe(report.operations_artifact?.sha256); + } + const selectedResponse = await call("inspect_api_contract", { + ...input, + detail: "complete", + view: { + kind: "graphql-contract", + collection: "fields", + field_keys: ["Film.title", "Film.director"], + fields: [], + limit: 1, + }, + }); + const selected = asView(selectedResponse.structuredContent); + expect(selected).toMatchObject({ + kind: "graphql-contract", + coverage: { total: 2, next_offset: 1 }, + items: [ + { + field_id: report.fields.find((row) => row.field_key === "Film.title")! + .field_id, + }, + ], + }); + if (selected.kind !== "graphql-contract") + throw new Error("Expected selected field page"); + expect(Object.keys(selected.items[0]!).sort()).toEqual([ + "field_id", + "owner_type_id", + "source", + ]); + const bundlePath = await checkCliPublication(cli, { + root, + parent, + selected, + }); + await rm(path); + await rm(operationsPath); + await checkRetainedPages(call, parent); + const queryPath = join(root, "offline-query.json"); + await writeFile( + queryPath, + JSON.stringify({ + source: { + kind: "evidence-file", + path: bundlePath, + evidence_id: parent.evidence_id, + }, + view: { + kind: "graphql-contract", + collection: "fields", + field_keys: [], + fields: [], + }, + }), + ); + const run = cli.sharedCliRun?.bind(cli) ?? cli.run.bind(cli); + const offline = await run({ + arguments: ["inspect-analysis-view", queryPath, "--json"], + }); + expect(offline.exitCode, offline.stderr).toBe(0); + expect(asView(offline.json)).toMatchObject({ + items: [], + coverage: { total: 0, exhausted: true }, + source_coverage: report.source_coverage, + }); + }, +); + +cliTest( + "keeps duplicate and malformed GraphQL declarations explicit through advertised public replies", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-partial-graphql-"); + const path = join(root, "schema.graphql"), + operations_path = join(root, "queries.graphql"); + await writeFile(path, "type Query { value: String value: Int }"); + await writeFile(operations_path, "query Q { value ...Missing }"); + const { call } = await connectLocalToolsMcp({ + REA_MCP_TOOL_FAMILIES: "application,session", + }); + const result = await call("inspect_api_contract", { + path, + format: "graphql-sdl", + operations_path, + detail: "complete", + }); + const parent = parseEvidence(result.structuredContent); + const report = graphqlContractSchema.parse(parent.normalized_result); + expect(report.fields).toHaveLength(2); + expect(report.references[0]).toMatchObject({ + state: "unknown", + reason: "schema-model-unavailable", + schema_field_id: null, + }); + expect(report.fragment_spreads[0]?.state).toBe("missing"); + expect(report.source_coverage.status).toBe("partial"); + for (const collection of [ + "types", + "definitions", + "references", + "fragment-spreads", + "diagnostics", + ]) { + const response = await call("inspect_analysis_view", { + source: retained(parent), + view: { kind: "graphql-contract", collection, fields: [], limit: 1 }, + }); + expect(response.isError).not.toBe(true); + expect(asView(response.structuredContent)).toMatchObject({ + source_coverage: report.source_coverage, + }); + } + await writeFile(operations_path, "query Q { value("); + const malformed = await call("inspect_api_contract", { + path, + format: "graphql-sdl", + operations_path, + }); + expect(malformed.isError).not.toBe(true); + expect(asView(malformed.structuredContent)).toMatchObject({ + summary: { + source_coverage: { status: "partial", operations: "unavailable" }, + }, + }); + const run = cli.sharedCliRun?.bind(cli) ?? cli.run.bind(cli); + const cliResult = await run({ + arguments: [ + "inspect-api-contract", + path, + "--contractFormat", + "graphql-sdl", + "--operationsPath", + operations_path, + "--json", + ], + }); + expect(cliResult.exitCode, cliResult.stderr).toBe(0); + expect( + graphqlContractSchema.parse( + parseEvidence(cliResult.json).normalized_result, + ).fields, + ).toEqual(report.fields); + }, +); + +cliTest( + "retains a primary GraphQL parser refusal for later queries on the same connection", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-invalid-graphql-"); + const path = join(root, "malformed.graphql"); + const text = 'type Query { field: "https://u:p@host/path" }'; + await writeFile(path, text); + const { call } = await connectLocalToolsMcp({ + REA_MCP_TOOL_FAMILIES: "application,session", + }); + const response = await call("inspect_api_contract", { + path, + format: "graphql-sdl", + }); + const error = parseMcpToolError(response); + const source = error.error.details?.partial_observation; + expect(source).toMatchObject({ kind: "retained-evidence" }); + expect(JSON.stringify(response)).not.toContain("u:p@host"); + await rm(path); + const page = await call("inspect_analysis_view", { + source, + view: { kind: "graphql-contract", collection: "diagnostics" }, + }); + expect(page.isError).not.toBe(true); + expect(asView(page.structuredContent)).toMatchObject({ + source_coverage: { + status: "partial", + schema_declarations: "unavailable", + }, + items: [{ code: "schema-parse-refused", source: { role: "schema" } }], + }); + await writeFile(path, text); + const run = cli.sharedCliRun?.bind(cli) ?? cli.run.bind(cli); + const result = await run({ + arguments: [ + "inspect-api-contract", + path, + "--contractFormat", + "graphql-sdl", + "--json", + ], + }); + expect(result.exitCode).not.toBe(0); + expect(result.json).toMatchObject({ + details: { + partial_observation: { + caller_input_path: path, + operation: "inspect_api_contract", + }, + }, + }); + }, +); diff --git a/tests/boundary/cli/cliEvidenceCallerPath.test.ts b/tests/boundary/cli/cliEvidenceCallerPath.test.ts index e2c1d08ad3..f9bde75805 100644 --- a/tests/boundary/cli/cliEvidenceCallerPath.test.ts +++ b/tests/boundary/cli/cliEvidenceCallerPath.test.ts @@ -3,7 +3,11 @@ import { expect, it } from "vitest"; import { createEvidence } from "../../../src/domain/evidence.js"; import { err, ok } from "../../../src/domain/result.js"; import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js"; -import { finishCliEvidenceCommand } from "../../../src/cli/cliEvidenceCallerPath.js"; +import { finishAnalysisCommand } from "../../../src/cli/finishAnalysisCommand.js"; +import { + bindCliEvidenceCallerPath, + finishCliEvidenceCommand, +} from "../../../src/cli/cliEvidenceCallerPath.js"; it("retains the original file selection when final cleanup fails", async () => { const evidence = createEvidence( @@ -83,3 +87,35 @@ it("binds authenticated partial Evidence but preserves raw partial observations" expect(result.error.details?.partial_observation).toEqual(expected); } }); + +it("preserves both direct input spellings when final cleanup fails", async () => { + const evidence = createEvidence( + { path: "/tmp/schema", sha256: "a".repeat(64), format: "file" }, + { id: "test", name: "Test", version: "1" }, + { operation: "inspect", parameters: {}, result: null }, + ); + const result = await finishAnalysisCommand( + bindCliEvidenceCallerPath("./schema", async () => ok(evidence), { + operations_path: "./queries 雪.graphql", + }), + { + close: async () => { + throw new Error("cleanup failed"); + }, + }, + "test", + "inspect", + new AbortController().signal, + ); + if (result.ok) throw new Error("Expected cleanup failure"); + expect(result.error).toMatchObject({ + code: "cleanup_incomplete", + details: { + partial_observation: { + evidence_id: evidence.evidence_id, + caller_input_path: "./schema", + caller_input_paths: { operations_path: "./queries 雪.graphql" }, + }, + }, + }); +}); diff --git a/tests/boundary/filesystem/evidenceBundleFiles.test.ts b/tests/boundary/filesystem/evidenceBundleFiles.test.ts index 86b955dc05..3fe76795b4 100644 --- a/tests/boundary/filesystem/evidenceBundleFiles.test.ts +++ b/tests/boundary/filesystem/evidenceBundleFiles.test.ts @@ -312,10 +312,15 @@ it.each([undefined, "../selected-link/./artifact"])( const parent = callerInputPath === undefined ? original - : withEvidenceCallerInputPath(original, callerInputPath); + : withEvidenceCallerInputPath(original, callerInputPath, { + operations_path: "./query 雪.graphql", + }); const delivered = await deliverAnalysisEvidence(parent, path, "summary"); if (!delivered.ok) throw delivered.error; expect(delivered.value.caller_input_path).toBe(callerInputPath); + expect(delivered.value.caller_input_paths).toEqual( + parent.caller_input_paths, + ); expect(delivered.value.evidence_links).toContain(parent.evidence_id); expect(original.caller_input_path).toBeUndefined(); const saved = await readEvidenceBundle(path); @@ -324,6 +329,7 @@ it.each([undefined, "../selected-link/./artifact"])( (record) => record.evidence_id === parent.evidence_id, ); expect(savedParent?.caller_input_path).toBe(callerInputPath); + expect(savedParent?.caller_input_paths).toEqual(parent.caller_input_paths); }, ); diff --git a/tests/fixtures/cliMcpEvidenceParity.ts b/tests/fixtures/cliMcpEvidenceParity.ts index 9efe03c832..fb771ed1c6 100644 --- a/tests/fixtures/cliMcpEvidenceParity.ts +++ b/tests/fixtures/cliMcpEvidenceParity.ts @@ -6,9 +6,16 @@ export const expectCliMcpEvidenceParity = ( cli: unknown, mcp: unknown, callerPath: string, + auxiliaryPaths?: Readonly>, ): void => { - const { caller_input_path: callerInputPath, ...shared } = parseEvidence(cli); + const { + caller_input_path: callerInputPath, + caller_input_paths: callerInputPaths, + ...shared + } = parseEvidence(cli); expect(callerInputPath).toBe(callerPath); + expect(callerInputPaths).toEqual(auxiliaryPaths); expect(mcp).not.toHaveProperty("caller_input_path"); + expect(mcp).not.toHaveProperty("caller_input_paths"); expect(mcp).toEqual(shared); }; diff --git a/tests/fixtures/graphql/LICENSE b/tests/fixtures/graphql/LICENSE new file mode 100644 index 0000000000..7bbf892a04 --- /dev/null +++ b/tests/fixtures/graphql/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) GraphQL Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/tests/fixtures/graphql/README.md b/tests/fixtures/graphql/README.md new file mode 100644 index 0000000000..3ce8f9d458 --- /dev/null +++ b/tests/fixtures/graphql/README.md @@ -0,0 +1,15 @@ +# GraphQL contract fixture + +`swapi.graphql.txt` is the unmodified SDL published by the +[SWAPI GraphQL application](https://github.com/graphql/swapi-graphql/blob/8c0bf5868f11fe84942548180751fceeb36bd606/schema.graphql), +at revision `8c0bf5868f11fe84942548180751fceeb36bd606`, retrieved 2026-11-06. +Its MIT license is preserved in `LICENSE`. + +- Size: 35,868 bytes. +- SHA-256: `ebc4d09b21e2aec2735d6f0d3c812fc017cd49a44900dd3ae34f49f6f8c25133`. +- The `.txt` suffix protects the original bytes from source formatters. + +This is a published application contract, not captured runtime traffic. +Owned operation documents and malformed variants are separately constructed +by tests. Parsing this fixture does not establish endpoint reachability, +authorization, operation execution or complete server behavior. diff --git a/tests/fixtures/graphql/swapi.graphql.txt b/tests/fixtures/graphql/swapi.graphql.txt new file mode 100644 index 0000000000..3e739807d7 --- /dev/null +++ b/tests/fixtures/graphql/swapi.graphql.txt @@ -0,0 +1,1166 @@ +schema { + query: Root +} + +"""A single film.""" +type Film implements Node { + """The title of this film.""" + title: String + + """The episode number of this film.""" + episodeID: Int + + """The opening paragraphs at the beginning of this film.""" + openingCrawl: String + + """The name of the director of this film.""" + director: String + + """The name(s) of the producer(s) of this film.""" + producers: [String] + + """The ISO 8601 date format of film release at original creator country.""" + releaseDate: String + speciesConnection(after: String, first: Int, before: String, last: Int): FilmSpeciesConnection + starshipConnection(after: String, first: Int, before: String, last: Int): FilmStarshipsConnection + vehicleConnection(after: String, first: Int, before: String, last: Int): FilmVehiclesConnection + characterConnection(after: String, first: Int, before: String, last: Int): FilmCharactersConnection + planetConnection(after: String, first: Int, before: String, last: Int): FilmPlanetsConnection + + """The ISO 8601 date format of the time that this resource was created.""" + created: String + + """The ISO 8601 date format of the time that this resource was edited.""" + edited: String + + """The ID of an object""" + id: ID! +} + +"""A connection to a list of items.""" +type FilmCharactersConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [FilmCharactersEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + characters: [Person] +} + +"""An edge in a connection.""" +type FilmCharactersEdge { + """The item at the end of the edge""" + node: Person + + """A cursor for use in pagination""" + cursor: String! +} + +"""A connection to a list of items.""" +type FilmPlanetsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [FilmPlanetsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + planets: [Planet] +} + +"""An edge in a connection.""" +type FilmPlanetsEdge { + """The item at the end of the edge""" + node: Planet + + """A cursor for use in pagination""" + cursor: String! +} + +"""A connection to a list of items.""" +type FilmsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [FilmsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + films: [Film] +} + +"""An edge in a connection.""" +type FilmsEdge { + """The item at the end of the edge""" + node: Film + + """A cursor for use in pagination""" + cursor: String! +} + +"""A connection to a list of items.""" +type FilmSpeciesConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [FilmSpeciesEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + species: [Species] +} + +"""An edge in a connection.""" +type FilmSpeciesEdge { + """The item at the end of the edge""" + node: Species + + """A cursor for use in pagination""" + cursor: String! +} + +"""A connection to a list of items.""" +type FilmStarshipsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [FilmStarshipsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + starships: [Starship] +} + +"""An edge in a connection.""" +type FilmStarshipsEdge { + """The item at the end of the edge""" + node: Starship + + """A cursor for use in pagination""" + cursor: String! +} + +"""A connection to a list of items.""" +type FilmVehiclesConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [FilmVehiclesEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + vehicles: [Vehicle] +} + +"""An edge in a connection.""" +type FilmVehiclesEdge { + """The item at the end of the edge""" + node: Vehicle + + """A cursor for use in pagination""" + cursor: String! +} + +"""An object with an ID""" +interface Node { + """The id of the object.""" + id: ID! +} + +"""Information about pagination in a connection.""" +type PageInfo { + """When paginating forwards, are there more items?""" + hasNextPage: Boolean! + + """When paginating backwards, are there more items?""" + hasPreviousPage: Boolean! + + """When paginating backwards, the cursor to continue.""" + startCursor: String + + """When paginating forwards, the cursor to continue.""" + endCursor: String +} + +"""A connection to a list of items.""" +type PeopleConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [PeopleEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + people: [Person] +} + +"""An edge in a connection.""" +type PeopleEdge { + """The item at the end of the edge""" + node: Person + + """A cursor for use in pagination""" + cursor: String! +} + +"""An individual person or character within the Star Wars universe.""" +type Person implements Node { + """The name of this person.""" + name: String + + """ + The birth year of the person, using the in-universe standard of BBY or ABY - + Before the Battle of Yavin or After the Battle of Yavin. The Battle of Yavin is + a battle that occurs at the end of Star Wars episode IV: A New Hope. + """ + birthYear: String + + """ + The eye color of this person. Will be "unknown" if not known or "n/a" if the + person does not have an eye. + """ + eyeColor: String + + """ + The gender of this person. Either "Male", "Female" or "unknown", + "n/a" if the person does not have a gender. + """ + gender: String + + """ + The hair color of this person. Will be "unknown" if not known or "n/a" if the + person does not have hair. + """ + hairColor: String + + """The height of the person in centimeters.""" + height: Int + + """The mass of the person in kilograms.""" + mass: Float + + """The skin color of this person.""" + skinColor: String + + """A planet that this person was born on or inhabits.""" + homeworld: Planet + filmConnection(after: String, first: Int, before: String, last: Int): PersonFilmsConnection + + """The species that this person belongs to, or null if unknown.""" + species: Species + starshipConnection(after: String, first: Int, before: String, last: Int): PersonStarshipsConnection + vehicleConnection(after: String, first: Int, before: String, last: Int): PersonVehiclesConnection + + """The ISO 8601 date format of the time that this resource was created.""" + created: String + + """The ISO 8601 date format of the time that this resource was edited.""" + edited: String + + """The ID of an object""" + id: ID! +} + +"""A connection to a list of items.""" +type PersonFilmsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [PersonFilmsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + films: [Film] +} + +"""An edge in a connection.""" +type PersonFilmsEdge { + """The item at the end of the edge""" + node: Film + + """A cursor for use in pagination""" + cursor: String! +} + +"""A connection to a list of items.""" +type PersonStarshipsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [PersonStarshipsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + starships: [Starship] +} + +"""An edge in a connection.""" +type PersonStarshipsEdge { + """The item at the end of the edge""" + node: Starship + + """A cursor for use in pagination""" + cursor: String! +} + +"""A connection to a list of items.""" +type PersonVehiclesConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [PersonVehiclesEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + vehicles: [Vehicle] +} + +"""An edge in a connection.""" +type PersonVehiclesEdge { + """The item at the end of the edge""" + node: Vehicle + + """A cursor for use in pagination""" + cursor: String! +} + +""" +A large mass, planet or planetoid in the Star Wars Universe, at the time of +0 ABY. +""" +type Planet implements Node { + """The name of this planet.""" + name: String + + """The diameter of this planet in kilometers.""" + diameter: Int + + """ + The number of standard hours it takes for this planet to complete a single + rotation on its axis. + """ + rotationPeriod: Int + + """ + The number of standard days it takes for this planet to complete a single orbit + of its local star. + """ + orbitalPeriod: Int + + """ + A number denoting the gravity of this planet, where "1" is normal or 1 standard + G. "2" is twice or 2 standard Gs. "0.5" is half or 0.5 standard Gs. + """ + gravity: String + + """The average population of sentient beings inhabiting this planet.""" + population: Float + + """The climates of this planet.""" + climates: [String] + + """The terrains of this planet.""" + terrains: [String] + + """ + The percentage of the planet surface that is naturally occurring water or bodies + of water. + """ + surfaceWater: Float + residentConnection(after: String, first: Int, before: String, last: Int): PlanetResidentsConnection + filmConnection(after: String, first: Int, before: String, last: Int): PlanetFilmsConnection + + """The ISO 8601 date format of the time that this resource was created.""" + created: String + + """The ISO 8601 date format of the time that this resource was edited.""" + edited: String + + """The ID of an object""" + id: ID! +} + +"""A connection to a list of items.""" +type PlanetFilmsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [PlanetFilmsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + films: [Film] +} + +"""An edge in a connection.""" +type PlanetFilmsEdge { + """The item at the end of the edge""" + node: Film + + """A cursor for use in pagination""" + cursor: String! +} + +"""A connection to a list of items.""" +type PlanetResidentsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [PlanetResidentsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + residents: [Person] +} + +"""An edge in a connection.""" +type PlanetResidentsEdge { + """The item at the end of the edge""" + node: Person + + """A cursor for use in pagination""" + cursor: String! +} + +"""A connection to a list of items.""" +type PlanetsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [PlanetsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + planets: [Planet] +} + +"""An edge in a connection.""" +type PlanetsEdge { + """The item at the end of the edge""" + node: Planet + + """A cursor for use in pagination""" + cursor: String! +} + +type Root { + allFilms(after: String, first: Int, before: String, last: Int): FilmsConnection + film(id: ID, filmID: ID): Film + allPeople(after: String, first: Int, before: String, last: Int): PeopleConnection + person(id: ID, personID: ID): Person + allPlanets(after: String, first: Int, before: String, last: Int): PlanetsConnection + planet(id: ID, planetID: ID): Planet + allSpecies(after: String, first: Int, before: String, last: Int): SpeciesConnection + species(id: ID, speciesID: ID): Species + allStarships(after: String, first: Int, before: String, last: Int): StarshipsConnection + starship(id: ID, starshipID: ID): Starship + allVehicles(after: String, first: Int, before: String, last: Int): VehiclesConnection + vehicle(id: ID, vehicleID: ID): Vehicle + + """Fetches an object given its ID""" + node( + """The ID of an object""" + id: ID! + ): Node +} + +"""A type of person or character within the Star Wars Universe.""" +type Species implements Node { + """The name of this species.""" + name: String + + """The classification of this species, such as "mammal" or "reptile".""" + classification: String + + """The designation of this species, such as "sentient".""" + designation: String + + """The average height of this species in centimeters.""" + averageHeight: Float + + """The average lifespan of this species in years, null if unknown.""" + averageLifespan: Int + + """ + Common eye colors for this species, null if this species does not typically + have eyes. + """ + eyeColors: [String] + + """ + Common hair colors for this species, null if this species does not typically + have hair. + """ + hairColors: [String] + + """ + Common skin colors for this species, null if this species does not typically + have skin. + """ + skinColors: [String] + + """The language commonly spoken by this species.""" + language: String + + """A planet that this species originates from.""" + homeworld: Planet + personConnection(after: String, first: Int, before: String, last: Int): SpeciesPeopleConnection + filmConnection(after: String, first: Int, before: String, last: Int): SpeciesFilmsConnection + + """The ISO 8601 date format of the time that this resource was created.""" + created: String + + """The ISO 8601 date format of the time that this resource was edited.""" + edited: String + + """The ID of an object""" + id: ID! +} + +"""A connection to a list of items.""" +type SpeciesConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [SpeciesEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + species: [Species] +} + +"""An edge in a connection.""" +type SpeciesEdge { + """The item at the end of the edge""" + node: Species + + """A cursor for use in pagination""" + cursor: String! +} + +"""A connection to a list of items.""" +type SpeciesFilmsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [SpeciesFilmsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + films: [Film] +} + +"""An edge in a connection.""" +type SpeciesFilmsEdge { + """The item at the end of the edge""" + node: Film + + """A cursor for use in pagination""" + cursor: String! +} + +"""A connection to a list of items.""" +type SpeciesPeopleConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [SpeciesPeopleEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + people: [Person] +} + +"""An edge in a connection.""" +type SpeciesPeopleEdge { + """The item at the end of the edge""" + node: Person + + """A cursor for use in pagination""" + cursor: String! +} + +"""A single transport craft that has hyperdrive capability.""" +type Starship implements Node { + """The name of this starship. The common name, such as "Death Star".""" + name: String + + """ + The model or official name of this starship. Such as "T-65 X-wing" or "DS-1 + Orbital Battle Station". + """ + model: String + + """ + The class of this starship, such as "Starfighter" or "Deep Space Mobile + Battlestation" + """ + starshipClass: String + + """The manufacturers of this starship.""" + manufacturers: [String] + + """The cost of this starship new, in galactic credits.""" + costInCredits: Float + + """The length of this starship in meters.""" + length: Float + + """The number of personnel needed to run or pilot this starship.""" + crew: String + + """The number of non-essential people this starship can transport.""" + passengers: String + + """ + The maximum speed of this starship in atmosphere. null if this starship is + incapable of atmosphering flight. + """ + maxAtmospheringSpeed: Int + + """The class of this starships hyperdrive.""" + hyperdriveRating: Float + + """ + The Maximum number of Megalights this starship can travel in a standard hour. + A "Megalight" is a standard unit of distance and has never been defined before + within the Star Wars universe. This figure is only really useful for measuring + the difference in speed of starships. We can assume it is similar to AU, the + distance between our Sun (Sol) and Earth. + """ + MGLT: Int + + """The maximum number of kilograms that this starship can transport.""" + cargoCapacity: Float + + """ + The maximum length of time that this starship can provide consumables for its + entire crew without having to resupply. + """ + consumables: String + pilotConnection(after: String, first: Int, before: String, last: Int): StarshipPilotsConnection + filmConnection(after: String, first: Int, before: String, last: Int): StarshipFilmsConnection + + """The ISO 8601 date format of the time that this resource was created.""" + created: String + + """The ISO 8601 date format of the time that this resource was edited.""" + edited: String + + """The ID of an object""" + id: ID! +} + +"""A connection to a list of items.""" +type StarshipFilmsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [StarshipFilmsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + films: [Film] +} + +"""An edge in a connection.""" +type StarshipFilmsEdge { + """The item at the end of the edge""" + node: Film + + """A cursor for use in pagination""" + cursor: String! +} + +"""A connection to a list of items.""" +type StarshipPilotsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [StarshipPilotsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + pilots: [Person] +} + +"""An edge in a connection.""" +type StarshipPilotsEdge { + """The item at the end of the edge""" + node: Person + + """A cursor for use in pagination""" + cursor: String! +} + +"""A connection to a list of items.""" +type StarshipsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [StarshipsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + starships: [Starship] +} + +"""An edge in a connection.""" +type StarshipsEdge { + """The item at the end of the edge""" + node: Starship + + """A cursor for use in pagination""" + cursor: String! +} + +"""A single transport craft that does not have hyperdrive capability""" +type Vehicle implements Node { + """ + The name of this vehicle. The common name, such as "Sand Crawler" or "Speeder + bike". + """ + name: String + + """ + The model or official name of this vehicle. Such as "All-Terrain Attack + Transport". + """ + model: String + + """The class of this vehicle, such as "Wheeled" or "Repulsorcraft".""" + vehicleClass: String + + """The manufacturers of this vehicle.""" + manufacturers: [String] + + """The cost of this vehicle new, in Galactic Credits.""" + costInCredits: Float + + """The length of this vehicle in meters.""" + length: Float + + """The number of personnel needed to run or pilot this vehicle.""" + crew: String + + """The number of non-essential people this vehicle can transport.""" + passengers: String + + """The maximum speed of this vehicle in atmosphere.""" + maxAtmospheringSpeed: Int + + """The maximum number of kilograms that this vehicle can transport.""" + cargoCapacity: Float + + """ + The maximum length of time that this vehicle can provide consumables for its + entire crew without having to resupply. + """ + consumables: String + pilotConnection(after: String, first: Int, before: String, last: Int): VehiclePilotsConnection + filmConnection(after: String, first: Int, before: String, last: Int): VehicleFilmsConnection + + """The ISO 8601 date format of the time that this resource was created.""" + created: String + + """The ISO 8601 date format of the time that this resource was edited.""" + edited: String + + """The ID of an object""" + id: ID! +} + +"""A connection to a list of items.""" +type VehicleFilmsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [VehicleFilmsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + films: [Film] +} + +"""An edge in a connection.""" +type VehicleFilmsEdge { + """The item at the end of the edge""" + node: Film + + """A cursor for use in pagination""" + cursor: String! +} + +"""A connection to a list of items.""" +type VehiclePilotsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [VehiclePilotsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + pilots: [Person] +} + +"""An edge in a connection.""" +type VehiclePilotsEdge { + """The item at the end of the edge""" + node: Person + + """A cursor for use in pagination""" + cursor: String! +} + +"""A connection to a list of items.""" +type VehiclesConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [VehiclesEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + vehicles: [Vehicle] +} + +"""An edge in a connection.""" +type VehiclesEdge { + """The item at the end of the edge""" + node: Vehicle + + """A cursor for use in pagination""" + cursor: String! +}