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
This commit is contained in:
gensokyo-dev authored and GitHub committed 2026-11-06 20:16:31 +08:00
1 parent 14d2c51d85
commit 5303199163
45 files changed
+4204 -99

No files matched your search

+78 -2
View File
@@ -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.
+12 -9
View File
@@ -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
+6 -1
View File
@@ -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/[email protected] | development | MIT
@@ -2825,6 +2825,11 @@ [email protected] | 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==
[email protected] | 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==
[email protected] | development | MIT
Lockfile path: node_modules/handlebars
Package: https://www.npmjs.com/package/handlebars/v/4.7.10
+31 -1
View File
@@ -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/[email protected] =====
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.
===== [email protected] =====
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.
===== [email protected] =====
Declared license: MIT
Archive: https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz
+10
View File
@@ -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",
+1
View File
@@ -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",
+5 -1
View File
@@ -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,
+16 -6
View File
@@ -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,
) => {
@@ -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 <Value>(
steps: Generator<void, Result<Value, AnalysisError>>,
signal?: AbortSignal,
): Promise<Result<Value, AnalysisError>> => {
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<InspectApiContractInput, { format: "openapi-json" }>,
bytes: Buffer,
sha256: string,
signal?: AbortSignal,
): Promise<Result<Evidence, AnalysisError>> => {
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(
@@ -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 = <Value>(
steps: Generator<void, Result<Value, AnalysisError>>,
reason: unknown,
): Result<Value, AnalysisError> => {
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 <Value>(
steps: Generator<void, Result<Value, AnalysisError>>,
signal?: AbortSignal,
): Promise<Result<Value, AnalysisError>> => {
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);
}
};
@@ -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<InspectApiContractInput, { format: "graphql-sdl" }>;
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<string, AnalysisError> => {
// 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);
};
@@ -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 <Value>(
run: (resources: ArtifactResourceScope) => Promise<Value>,
) => {
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();
}
},
);
@@ -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, {
@@ -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;
+12 -2
View File
@@ -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<Record<string, string>>;
/** 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);
+34 -7
View File
@@ -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 },
+7 -1
View File
@@ -13,19 +13,25 @@ export const bindCliEvidenceCallerPath =
(
callerPath: string,
run: () => Promise<Result<Evidence, AnalysisError>>,
additionalPaths?: Readonly<Record<string, string>>,
): (() => Promise<Result<Evidence, AnalysisError>>) =>
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.
+1 -1
View File
@@ -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 orLine truncated
'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 bLine truncated
inputSchema: inspectAnalysisViewInputSchema,
outputSchema: evidenceResultOf(analysisViewResultSchema),
examples: [
+18 -1
View File
@@ -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",
+2
View File
@@ -88,6 +88,8 @@ const EXECUTION_WORDING_EXCEPTIONS: Readonly<Record<string, string>> = {
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",
+2
View File
@@ -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
+2
View File
@@ -163,6 +163,7 @@ import {
playwrightTraceSummaryViewResultSchema,
playwrightTracePageViewResultSchema,
apiContractPageViewResultSchema,
graphqlContractPageViewResultSchema,
endpointComparisonPageViewResultSchema,
featureTraceSummaryViewResultSchema,
batchDecompileSummaryViewResultSchema,
@@ -238,6 +239,7 @@ export {
playwrightTraceSummaryViewResultSchema,
playwrightTracePageViewResultSchema,
apiContractPageViewResultSchema,
graphqlContractPageViewResultSchema,
endpointComparisonPageViewResultSchema,
featureTraceSummaryViewResultSchema,
batchDecompileSummaryViewResultSchema,
@@ -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,
+17 -1
View File
@@ -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({
+9 -3
View File
@@ -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<string> | 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<typeof apiContractViewRequestSchema>
>(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,
@@ -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<typeof graphqlContractViewRequestSchema>;
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 = <Row extends object, Key extends keyof Row>(
rows: readonly Row[],
view: View,
filters: Partial<Record<keyof Row, ReadonlySet<string> | undefined>>,
columns: {
readonly defaults: readonly string[];
readonly required: readonly Key[];
},
) => {
const keys = [
...new Set<string | keyof Row>([
...(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<keyof Row, JsonValue>;
return Object.fromEntries(
keys.map((key) => [key, source[key as keyof Row]]),
) as Partial<Row> & Pick<Row, Key>;
},
);
};
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<SummaryAnalysisView | View>(
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,
),
});
};
@@ -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;
}
};
+180
View File
@@ -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<number, string>();
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.",
],
};
}
}
@@ -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<string, readonly GraphqlDefinition[]>;
}
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<void> {
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<void> {
inventory.operations = "partial";
const fragments = new Map<string, GraphqlDefinition[]>();
const owners = new Map<number, GraphqlDefinition>();
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";
}
@@ -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<Record<Kind, GraphqlType["kind"]>> = {
[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<string>([
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<void> {
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";
}
+43 -17
View File
@@ -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
>;
+142
View File
@@ -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<typeof graphqlContractSchema>;
export type GraphqlSource = z.output<typeof graphqlSourceSchema>;
export type GraphqlType = z.output<typeof graphqlTypeSchema>;
export type GraphqlField = z.output<typeof graphqlFieldSchema>;
export type GraphqlDefinition = z.output<typeof graphqlDefinitionSchema>;
export type GraphqlReference = z.output<typeof graphqlReferenceSchema>;
export type GraphqlSpread = z.output<typeof graphqlSpreadSchema>;
@@ -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(),
}),
],
);
@@ -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:[email protected]" ) ',
);
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:[email protected]" }',
);
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);
});
@@ -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<void, Result<GraphqlContract, AnalysisError>> {
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);
}
}
+39
View File
@@ -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) => {
+29 -1
View File
@@ -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<Record<string, string>>,
): 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);
};
@@ -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,
@@ -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<typeof asView>;
},
) => {
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<ReturnType<typeof connectLocalToolsMcp>>["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<ReturnType<typeof connectLocalToolsMcp>>["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",
},
},
});
},
);
@@ -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" },
},
},
});
});
@@ -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);
},
);
+8 -1
View File
@@ -6,9 +6,16 @@ export const expectCliMcpEvidenceParity = (
cli: unknown,
mcp: unknown,
callerPath: string,
auxiliaryPaths?: Readonly<Record<string, string>>,
): 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);
};
+21
View File
@@ -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.
+15
View File
@@ -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.
File diff suppressed because it is too large. Load diff