From c7765012dd2ccb991d99808bc27629488767354a Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Thu, 30 Jul 2026 21:00:42 +0900 Subject: [PATCH 01/52] chore: claim complete graph snapshot mission From 22da7ccb04654ad0b1f4c1305ba372f5ec00b873 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Thu, 30 Jul 2026 22:41:05 +0900 Subject: [PATCH 02/52] feat: establish graph snapshot trust foundation Close #163: Make shipped-sidecar integrity tests package-complete and toolchain-independent Close #165: Make cross-process request-log tests wait for completed marker writes --- README.md | 24 +- packages/graph/src/SamchonGraphApplication.ts | 7 + packages/graph/src/SamchonGraphMemory.ts | 11 + packages/graph/src/indexer/buildLspGraph.ts | 18 + .../src/indexer/buildStaticGraphResult.ts | 3 + .../src/indexer/createResidentGraphSource.ts | 15 + packages/graph/src/indexer/parseGraphDump.ts | 107 ++ packages/graph/src/operations/graphTrust.ts | 92 ++ .../src/provider/GraphSnapshotProtocol.ts | 744 ++++++++++++ .../graph/src/provider/IBulkGraphSession.ts | 36 + .../provider/assertGraphSnapshotContract.ts | 45 + packages/graph/src/provider/coverageRows.ts | 31 + .../graph/src/provider/fallbackCoverage.ts | 22 + .../graph/src/provider/graphCoverageOf.ts | 24 + .../src/provider/graphSnapshotDigests.ts | 11 + .../graph/src/provider/graphUnresolvedOf.ts | 15 + packages/graph/src/provider/index.ts | 4 + .../src/provider/rust/rustScipProvider.ts | 3 + .../structures/ISamchonGraphApplication.ts | 27 +- .../src/structures/ISamchonGraphCoverage.ts | 36 + .../ISamchonGraphCoverageSummary.ts | 14 + .../graph/src/structures/ISamchonGraphDump.ts | 14 + .../src/structures/ISamchonGraphUnresolved.ts | 44 + .../ISamchonGraphUnresolvedSummary.ts | 16 + packages/graph/src/structures/index.ts | 4 + .../graph/src/typings/GRAPH_EDGE_KINDS.ts | 20 + packages/graph/src/typings/index.ts | 1 + sidecars/go/analyze.go | 7 +- ...e_covers_every_graph_node_and_edge_kind.ts | 7 +- ...rser_closes_every_public_trust_boundary.ts | 123 +- ...otocol_commits_atomic_shard_generations.ts | 1026 +++++++++++++++++ ...preserve_graph_coverage_and_uncertainty.ts | 156 +++ ...t_mcp_server_exposes_inspect_code_graph.ts | 6 +- ...s_and_inputs_respect_project_boundaries.ts | 52 +- .../test_result_audits_before_the_facts.ts | 2 +- ...reserves_cargo_and_toolchain_boundaries.ts | 2 +- ...ce_does_not_leak_benchmark_corpus_names.ts | 24 +- ...aph_native_requests_recover_from_stalls.ts | 10 +- .../test-graph/src/internal/ContractParity.ts | 105 ++ 39 files changed, 2867 insertions(+), 41 deletions(-) create mode 100644 packages/graph/src/operations/graphTrust.ts create mode 100644 packages/graph/src/provider/GraphSnapshotProtocol.ts create mode 100644 packages/graph/src/provider/coverageRows.ts create mode 100644 packages/graph/src/provider/fallbackCoverage.ts create mode 100644 packages/graph/src/provider/graphCoverageOf.ts create mode 100644 packages/graph/src/provider/graphUnresolvedOf.ts create mode 100644 packages/graph/src/structures/ISamchonGraphCoverage.ts create mode 100644 packages/graph/src/structures/ISamchonGraphCoverageSummary.ts create mode 100644 packages/graph/src/structures/ISamchonGraphUnresolved.ts create mode 100644 packages/graph/src/structures/ISamchonGraphUnresolvedSummary.ts create mode 100644 packages/graph/src/typings/GRAPH_EDGE_KINDS.ts create mode 100644 tests/test-graph/src/features/test_graph_snapshot_protocol_commits_atomic_shard_generations.ts create mode 100644 tests/test-graph/src/features/test_mcp_results_preserve_graph_coverage_and_uncertainty.ts diff --git a/README.md b/README.md index b84d9cf0..da6e2f7b 100644 --- a/README.md +++ b/README.md @@ -249,9 +249,8 @@ export interface ISamchonGraphApplication { /** * Answer a __LANG__ question from this repository's own program index. * - * The graph holds every symbol, call, type, decorator and test, each with its - * file and line, resolved from the source on disk now. Submit exactly one - * request: + * The graph returns proved indexed facts plus structured coverage and + * uncertainty. Submit exactly one request: * * - `tour`: architecture, the runtime flow from the public API to the code that * does the work, nearby paths, and the tests to read — a whole orientation @@ -335,6 +334,25 @@ export namespace ISamchonGraphApplication { */ audit: string; + /** + * Strict producer, authority, compiler and build-universe identity for the + * synchronized graph. Absent only for `escape` or a legacy/fallback-only + * dump with no strict producer. + */ + provenance?: ISamchonGraphDump.IProvenance[]; + + /** + * Machine-readable completeness for the relationship families relevant to + * this operation. Absent only for `escape`. + */ + coverage?: ISamchonGraphCoverageSummary; + + /** + * Bounded structured uncertainty for the same operation-scoped families. + * Absent only for `escape`. + */ + unresolved?: ISamchonGraphUnresolvedSummary; + /** What to do with `result`: answer, inspect one named request, or escape. */ next: ISamchonGraphNext; diff --git a/packages/graph/src/SamchonGraphApplication.ts b/packages/graph/src/SamchonGraphApplication.ts index 48c4e62d..b537aa0f 100644 --- a/packages/graph/src/SamchonGraphApplication.ts +++ b/packages/graph/src/SamchonGraphApplication.ts @@ -4,6 +4,7 @@ import { RESULT_AUDIT_DETAILS } from "./operations/RESULT_AUDIT_DETAILS"; import { RESULT_AUDIT_SELECTION } from "./operations/RESULT_AUDIT_SELECTION"; import { RESULT_AUDIT_ESCAPE } from "./operations/RESULT_AUDIT_ESCAPE"; import { resultNext } from "./operations/resultNext"; +import { graphTrust } from "./operations/graphTrust"; import { runDetails } from "./operations/runDetails"; import { runEntrypoints } from "./operations/runEntrypoints"; import { runLookup } from "./operations/runLookup"; @@ -62,6 +63,7 @@ export class SamchonGraphApplication implements ISamchonGraphApplication { const r = runEntrypoints(graph, props.request); return { audit: RESULT_AUDIT_SELECTION(graph.indexer), + ...graphTrust(graph, props.request.type), next: r.next, result: r.result, }; @@ -72,6 +74,7 @@ export class SamchonGraphApplication implements ISamchonGraphApplication { const r = runLookup(graph, props.request); return { audit: RESULT_AUDIT_SELECTION(graph.indexer), + ...graphTrust(graph, props.request.type), next: r.next, result: r.result, }; @@ -80,6 +83,7 @@ export class SamchonGraphApplication implements ISamchonGraphApplication { const r = runTrace(graph, props.request); return { audit: RESULT_AUDIT(graph.indexer), + ...graphTrust(graph, props.request.type), next: r.next, result: r.result, }; @@ -88,6 +92,7 @@ export class SamchonGraphApplication implements ISamchonGraphApplication { const r = runDetails(graph, props.request); return { audit: RESULT_AUDIT_DETAILS(graph.indexer, props.request.memberLimit), + ...graphTrust(graph, props.request.type), next: r.next, result: r.result, }; @@ -96,6 +101,7 @@ export class SamchonGraphApplication implements ISamchonGraphApplication { const r = runOverview(graph, props.request); return { audit: RESULT_AUDIT(graph.indexer), + ...graphTrust(graph, props.request.type), next: r.next, result: r.result, }; @@ -107,6 +113,7 @@ export class SamchonGraphApplication implements ISamchonGraphApplication { const r = runTour(graph, props.request, props.question); return { audit: RESULT_AUDIT_SELECTION(graph.indexer), + ...graphTrust(graph, props.request.type), next: r.next, result: r.result, }; diff --git a/packages/graph/src/SamchonGraphMemory.ts b/packages/graph/src/SamchonGraphMemory.ts index f1098c5d..af20341e 100644 --- a/packages/graph/src/SamchonGraphMemory.ts +++ b/packages/graph/src/SamchonGraphMemory.ts @@ -9,8 +9,10 @@ import { ISamchonGraphDump, ISamchonGraphEdge, ISamchonGraphEvidence, + ISamchonGraphCoverage, ISamchonGraphNode, ISamchonGraphSpan, + ISamchonGraphUnresolved, } from "./structures"; import { GraphLanguage } from "./typings"; import { basename } from "./utils/path"; @@ -48,6 +50,12 @@ export class SamchonGraphMemory { public readonly diagnostics: readonly ISamchonGraphDiagnostic[]; /** Non-fatal problems encountered while building the graph. */ public readonly warnings: readonly string[]; + /** Strict-provider provenance retained from the exact dump generation. */ + public readonly provenance: readonly ISamchonGraphDump.IProvenance[]; + /** Machine-readable completeness of every strict relationship family. */ + public readonly coverage: readonly ISamchonGraphCoverage[]; + /** Exact sites whose relationships remain unresolved. */ + public readonly unresolved: readonly ISamchonGraphUnresolved[]; /** Provenance-gated source display facts owned by this exact snapshot. */ public readonly source: SamchonGraphSourceReader; @@ -64,6 +72,9 @@ export class SamchonGraphMemory { this.edges = edges; this.diagnostics = dump.diagnostics ?? []; this.warnings = dump.warnings ?? []; + this.provenance = dump.provenance ?? []; + this.coverage = dump.coverage ?? []; + this.unresolved = dump.unresolved ?? []; this.source = source; this.byId = indexNodesById(nodes); diff --git a/packages/graph/src/indexer/buildLspGraph.ts b/packages/graph/src/indexer/buildLspGraph.ts index 7b3a7dc0..aa87ff7b 100644 --- a/packages/graph/src/indexer/buildLspGraph.ts +++ b/packages/graph/src/indexer/buildLspGraph.ts @@ -8,7 +8,9 @@ import { ISamchonGraphDiagnostic, ISamchonGraphDump, ISamchonGraphEdge, + ISamchonGraphCoverage, ISamchonGraphNode, + ISamchonGraphUnresolved, } from "../structures"; import { GraphLanguage } from "../typings"; import { projectRelative, readText } from "../utils/fs"; @@ -16,6 +18,9 @@ import { fileFromUri, fileUri, isSubPath } from "../utils/path"; import { spawnableCommand } from "../utils/spawnableCommand"; import { assertGraphSnapshotContract } from "../provider/assertGraphSnapshotContract"; import { dumpProvenanceOf } from "../provider/dumpProvenanceOf"; +import { fallbackCoverage } from "../provider/fallbackCoverage"; +import { graphCoverageOf } from "../provider/graphCoverageOf"; +import { graphUnresolvedOf } from "../provider/graphUnresolvedOf"; import { IBulkGraphSession } from "../provider/IBulkGraphSession"; import { isBulkGraphSession } from "../provider/isBulkGraphSession"; import { mergeGraphSlices } from "../provider/mergeGraphSlices"; @@ -112,6 +117,8 @@ async function buildLspGraphAttempt( const strictNodes: ISamchonGraphNode[] = []; const strictEdges: ISamchonGraphEdge[] = []; const diagnostics: ISamchonGraphDiagnostic[] = []; + const coverage: ISamchonGraphCoverage[] = []; + const unresolved: ISamchonGraphUnresolved[] = []; const warnings: string[] = []; const staticFallbackLanguages: GraphLanguage[] = []; const sessions = new Map(); @@ -201,6 +208,8 @@ async function buildLspGraphAttempt( appendAll(strictNodes, snapshot.nodes); appendAll(strictEdges, snapshot.edges); appendAll(diagnostics, snapshot.diagnostics); + appendAll(coverage, graphCoverageOf(snapshot)); + appendAll(unresolved, graphUnresolvedOf(snapshot)); appendAll(warnings, snapshot.warnings); // The manifest names the files, and the provider owns the fact that it // does. Nothing reads their text here: the strict lane's facts are @@ -334,6 +343,7 @@ async function buildLspGraphAttempt( appendAll(nodes, result.nodes); appendAll(edges, result.edges); appendAll(diagnostics, result.diagnostics); + appendAll(coverage, fallbackCoverage("@samchon/graph-lsp", [language])); appendAll(warnings, result.warnings); semanticSliceCount += 1; servedLanguages.add(language); @@ -375,6 +385,10 @@ async function buildLspGraphAttempt( } appendAll(nodes, fallback.nodes); appendAll(edges, fallback.edges); + appendAll( + coverage, + fallbackCoverage("@samchon/graph-sitter", fallback.languages), + ); appendAll(warnings, fallback.warnings); } @@ -413,6 +427,8 @@ async function buildLspGraphAttempt( nodes: wireNodes(finalized.nodes), edges: wireEdges(finalized.edges, finalized.nodes), diagnostics, + coverage, + unresolved, warnings, ...dumpProvenanceOf.fieldOf(provenance), }, @@ -577,6 +593,8 @@ function staticDump( project: parts.root, languages: parts.languages, indexer: "static", + coverage: fallbackCoverage("@samchon/graph-sitter", parts.languages), + unresolved: [], nodes: wireNodes(nodes), edges: wireEdges(dedupeEdges(finalized.edges), nodes), warnings: [...parts.warnings, ...warnings, ...dedupeWarnings], diff --git a/packages/graph/src/indexer/buildStaticGraphResult.ts b/packages/graph/src/indexer/buildStaticGraphResult.ts index b63b35e5..bbc87005 100644 --- a/packages/graph/src/indexer/buildStaticGraphResult.ts +++ b/packages/graph/src/indexer/buildStaticGraphResult.ts @@ -1,5 +1,6 @@ import { SamchonGraphSourceReader } from "../SamchonGraphSourceReader"; import { ISamchonGraphDump } from "../structures"; +import { fallbackCoverage } from "../provider/fallbackCoverage"; import { dedupeEdges } from "./dedupeEdges"; import { dedupeNodes } from "./dedupeNodes"; import { finalizeGraph } from "./finalizeGraph"; @@ -30,6 +31,8 @@ export function buildStaticGraphResult( project: parts.root, languages: parts.languages, indexer: "static", + coverage: fallbackCoverage("@samchon/graph-sitter", parts.languages), + unresolved: [], nodes: wireNodes(nodes), edges: wireEdges(dedupeEdges(finalized.edges), nodes), warnings, diff --git a/packages/graph/src/indexer/createResidentGraphSource.ts b/packages/graph/src/indexer/createResidentGraphSource.ts index 1f92de08..dd6596f2 100644 --- a/packages/graph/src/indexer/createResidentGraphSource.ts +++ b/packages/graph/src/indexer/createResidentGraphSource.ts @@ -4,12 +4,17 @@ import { ISamchonGraphDiagnostic, ISamchonGraphDump, ISamchonGraphEdge, + ISamchonGraphCoverage, ISamchonGraphNode, + ISamchonGraphUnresolved, } from "../structures"; import { GraphLanguage } from "../typings"; import { SamchonGraphSourceReader } from "../SamchonGraphSourceReader"; import { assertGraphSnapshotContract } from "../provider/assertGraphSnapshotContract"; import { dumpProvenanceOf } from "../provider/dumpProvenanceOf"; +import { fallbackCoverage } from "../provider/fallbackCoverage"; +import { graphCoverageOf } from "../provider/graphCoverageOf"; +import { graphUnresolvedOf } from "../provider/graphUnresolvedOf"; import { IGraphProvider } from "../provider/IGraphProvider"; import { GRAPH_PROVIDERS } from "../provider/GRAPH_PROVIDERS"; import { IBulkGraphSession } from "../provider/IBulkGraphSession"; @@ -239,6 +244,8 @@ export function createResidentGraphSource( // dump whose own contract is that it is a function of its source (§6a). The // session holds them per file now, and a `didClose` drops the file's. const diagnostics: ISamchonGraphDiagnostic[] = []; + const coverage: ISamchonGraphCoverage[] = []; + const unresolved: ISamchonGraphUnresolved[] = []; const warnings: string[] = []; const sources = new Map(); const generations = new Map(current.generations); @@ -285,6 +292,8 @@ export function createResidentGraphSource( // the edges, and for the same reason the LSP lane stopped carrying them // forward: a diagnostic belongs to the generation that produced it. diagnostics.push(...refresh.snapshot.diagnostics); + coverage.push(...graphCoverageOf(refresh.snapshot)); + unresolved.push(...graphUnresolvedOf(refresh.snapshot)); warnings.push(...refresh.snapshot.warnings); provenance.push(dumpProvenanceOf(refresh.snapshot)); modes.set(refresh.snapshot.provenance.provider, refresh.mode); @@ -303,6 +312,7 @@ export function createResidentGraphSource( nodes.push(...result.nodes); edges.push(...result.edges); diagnostics.push(...result.diagnostics); + coverage.push(...fallbackCoverage("@samchon/graph-lsp", [language])); warnings.push(...result.warnings); for (const opened of session.opened.values()) { sources.set(opened.abs, opened.text); @@ -321,6 +331,9 @@ export function createResidentGraphSource( nodes.push(...fallback.nodes); edges.push(...fallback.edges); warnings.push(...fallback.warnings); + coverage.push( + ...fallbackCoverage("@samchon/graph-sitter", fallback.languages), + ); for (const [file, text] of fallback.sources) sources.set(file, text); } @@ -434,6 +447,8 @@ export function createResidentGraphSource( nodes: wireNodes(finalized.nodes), edges: wireEdges(finalized.edges, finalized.nodes), diagnostics, + coverage, + unresolved, warnings, ...dumpProvenanceOf.fieldOf(provenance), }; diff --git a/packages/graph/src/indexer/parseGraphDump.ts b/packages/graph/src/indexer/parseGraphDump.ts index fe15dcaa..b318c749 100644 --- a/packages/graph/src/indexer/parseGraphDump.ts +++ b/packages/graph/src/indexer/parseGraphDump.ts @@ -3,6 +3,7 @@ import path from "node:path"; import typia from "typia"; import { ISamchonGraphDump, ISamchonGraphSpan } from "../structures"; +import { GRAPH_EDGE_KINDS } from "../typings"; import { validateSemanticGraphNode } from "../provider/semanticIdentity"; import { fileOfNodeId } from "../utils/fileOfNodeId"; @@ -146,9 +147,115 @@ export function parseGraphDump(input: unknown): ISamchonGraphDump { } } } + const coverage = new Map< + string, + NonNullable[number] + >(); + const coverageSlices = new Map< + string, + Pick< + NonNullable[number], + "provider" | "language" | "target" + > + >(); + for (const row of dump.coverage ?? []) { + if ( + row.provider === "" || + row.provider.includes("\0") || + row.target === "" || + row.target.includes("\0") || + !dumpLanguages.has(row.language) + ) { + throw new Error("@samchon/graph: coverage row has invalid ownership"); + } + const key = coverageKey(row); + if (coverage.has(key)) { + throw new Error(`@samchon/graph: duplicate coverage row: ${key}`); + } + coverage.set(key, row); + coverageSlices.set( + coverageSliceKey(row), + { + provider: row.provider, + language: row.language, + target: row.target, + }, + ); + } + if (dump.coverage !== undefined) { + for (const slice of coverageSlices.values()) + for (const family of GRAPH_EDGE_KINDS) { + const key = coverageKey({ ...slice, family }); + if (!coverage.has(key)) { + throw new Error(`@samchon/graph: coverage is not exhaustive: ${key}`); + } + } + for (const provenance of dump.provenance ?? []) { + for (const language of provenance.languages) { + if ( + ![...coverageSlices.values()].some( + (slice) => + slice.provider === provenance.provider && + slice.language === language, + ) + ) { + throw new Error( + `@samchon/graph: coverage is missing for ${provenance.provider}/${language}`, + ); + } + } + } + } + const unresolved = new Set(); + for (const row of dump.unresolved ?? []) { + validateSpan(row.evidence, undefined, "unresolved evidence"); + assertUnique(row.candidates ?? [], "unresolved candidate"); + const owner = (dump.provenance ?? []).find( + (candidate) => + candidate.provider === row.provider && + candidate.languages.includes(row.language), + ); + if ( + !/^[0-9a-f]{64}$/.test(row.universe) || + owner === undefined || + owner.universe !== row.universe + ) { + throw new Error( + "@samchon/graph: unresolved site has no matching provider universe", + ); + } + const covered = coverage.get(coverageKey(row)); + if (covered?.state !== "partial") { + throw new Error( + "@samchon/graph: unresolved site does not have partial coverage", + ); + } + const key = JSON.stringify(row); + if (unresolved.has(key)) { + throw new Error("@samchon/graph: duplicate unresolved site"); + } + unresolved.add(key); + } return dump; } +function coverageKey(row: { + provider: string; + language: string; + target: string; + family: string; +}): string { + return `${row.provider}\0${row.language}\0${row.target}\0${row.family}`; +} + +function coverageSliceKey(row: { + provider: string; + language: string; + target: string; +}): string { + return `${row.provider}\0${row.language}\0${row.target}`; +} + function validateEndpoint( endpoint: string, side: "source" | "target", diff --git a/packages/graph/src/operations/graphTrust.ts b/packages/graph/src/operations/graphTrust.ts new file mode 100644 index 00000000..42a1b1d7 --- /dev/null +++ b/packages/graph/src/operations/graphTrust.ts @@ -0,0 +1,92 @@ +import { SamchonGraphMemory } from "../SamchonGraphMemory"; +import { + ISamchonGraphApplication, + ISamchonGraphCoverageSummary, + ISamchonGraphUnresolvedSummary, +} from "../structures"; +import { GRAPH_EDGE_KINDS, GraphEdgeKind } from "../typings"; + +/** Structured trust envelope for one non-escape operation. */ +export function graphTrust( + graph: SamchonGraphMemory, + type: Exclude< + ISamchonGraphApplication.IProps["request"]["type"], + "escape" + >, +): { + provenance?: ISamchonGraphApplication.IOutput["provenance"]; + coverage: ISamchonGraphCoverageSummary; + unresolved: ISamchonGraphUnresolvedSummary; +} { + const families = familiesOf(type); + const relevant = new Set(families); + const sites = graph.unresolved.filter((row) => relevant.has(row.family)); + const reasonCounts = new Map< + ISamchonGraphUnresolvedSummary["reasons"][number]["reason"], + number + >(); + for (const site of sites) + reasonCounts.set(site.reason, (reasonCounts.get(site.reason) ?? 0) + 1); + return { + ...(graph.provenance.length > 0 + ? { provenance: graph.provenance.map((row) => cloneProvenance(row)) } + : {}), + coverage: { + schemaVersion: 1, + families, + rows: graph.coverage + .filter((row) => relevant.has(row.family)) + .map((row) => ({ ...row })), + }, + unresolved: { + count: sites.length, + reasons: [...reasonCounts] + .sort(([left], [right]) => compareText(left, right)) + .map(([reason, count]) => ({ reason, count })), + examples: sites.slice(0, 20).map((site) => ({ + ...site, + evidence: { ...site.evidence }, + ...(site.candidates !== undefined + ? { candidates: [...site.candidates] } + : {}), + })), + }, + }; +} + +function familiesOf( + type: Exclude< + ISamchonGraphApplication.IProps["request"]["type"], + "escape" + >, +): GraphEdgeKind[] { + switch (type) { + case "entrypoints": + return ["contains", "exports", "calls", "tests"]; + case "lookup": + return ["contains", "exports", "references"]; + case "overview": + return ["contains", "exports", "imports"]; + case "trace": + case "details": + case "tour": + return [...GRAPH_EDGE_KINDS]; + } +} + +function cloneProvenance( + row: NonNullable[number], +): NonNullable[number] { + return { + ...row, + languages: [...row.languages], + facts: [...row.facts], + capabilities: [...row.capabilities], + producer: { ...row.producer }, + }; +} + +function compareText(left: string, right: string): number { + /* c8 ignore next 2 -- reason keys are distinct. */ + return left < right ? -1 : left > right ? 1 : 0; +} diff --git a/packages/graph/src/provider/GraphSnapshotProtocol.ts b/packages/graph/src/provider/GraphSnapshotProtocol.ts new file mode 100644 index 00000000..7758e849 --- /dev/null +++ b/packages/graph/src/provider/GraphSnapshotProtocol.ts @@ -0,0 +1,744 @@ +import { createHash } from "node:crypto"; +import path from "node:path"; + +import { + ISamchonGraphCoverage, + ISamchonGraphDiagnostic, + ISamchonGraphEdge, + ISamchonGraphNode, + ISamchonGraphUnresolved, +} from "../structures"; +import { + GRAPH_EDGE_KINDS, + GraphEdgeKind, + GraphLanguage, + GraphProviderAuthority, +} from "../typings"; +import { freezeDeep } from "../utils/freezeDeep"; +import { sealedMap } from "../utils/sealedMap"; +import { IBulkGraphSession } from "./IBulkGraphSession"; + +/** + * Versioned NDJSON producer contract for atomic, shard-based graph snapshots. + * + * A caller collects one complete frame transaction and applies it at once. + * There is deliberately no partially visible state: parsing, base checks, + * shard digests, coverage, endpoint closure and the final fact digest all pass + * before `current` changes. + */ +export namespace GraphSnapshotProtocol { + export const VERSION = 1; + export const SCHEMA_VERSION = 1; + + const LANGUAGES = new Set([ + "typescript", + "go", + "rust", + "cpp", + "c", + "java", + "csharp", + "kotlin", + "swift", + "scala", + "zig", + "python", + "ruby", + "php", + "lua", + "dart", + "unknown", + ]); + const AUTHORITIES = new Set([ + "compiler", + "analyzer", + "semantic-index", + "navigation", + "heuristic", + ]); + const FACTS = new Set(GRAPH_EDGE_KINDS); + const COVERAGE_STATES = new Set([ + "complete", + "partial", + "unsupported", + ]); + const UNRESOLVED_REASONS = new Set([ + "dynamic", + "reflection", + "macro-or-generated", + "conditional-build", + "external-boundary", + "analysis-error", + "excluded-input", + "identity-unstable", + "provider-gap", + ]); + + export interface IHello { + type: "hello"; + protocolVersion: 1; + schemaVersion: 1; + provider: string; + producer: string; + producerVersion: string; + compilerVersion: string; + languages: GraphLanguage[]; + authority: GraphProviderAuthority; + supportedFacts: GraphEdgeKind[]; + capabilities: string[]; + } + + export interface IBegin { + type: "begin"; + generation: string; + baseGeneration?: string; + universe: string; + manifest: string; + targets: string[]; + } + + export interface ISource { + file: string; + checkerDigest: string; + diskDigest: string; + } + + export interface IShard { + key: string; + target: string; + languages: GraphLanguage[]; + nodes: ISamchonGraphNode[]; + edges: ISamchonGraphEdge[]; + diagnostics: ISamchonGraphDiagnostic[]; + coverage: ISamchonGraphCoverage[]; + unresolved: ISamchonGraphUnresolved[]; + sources: ISource[]; + } + + export interface IUpsertShard { + type: "upsertShard"; + digest: string; + shard: IShard; + } + + export interface IDeleteShard { + type: "deleteShard"; + key: string; + } + + export interface ICommit { + type: "commit"; + generation: string; + shards: IBulkGraphSession.IShard[]; + factDigest: string; + } + + export type Frame = + | IHello + | IBegin + | IUpsertShard + | IDeleteShard + | ICommit; + + /** SHA-256 over the canonical content of one shard. */ + export function shardDigest(shard: IShard): string { + return digest(shard); + } + + /** + * SHA-256 over the complete reconstructed fact payload. + * + * Producer and consumer call this same function; a commit cannot substitute a + * manifest whose shards happen to parse but reconstruct different facts. + */ + export function factDigest(snapshot: Pick< + IBulkGraphSession.ISnapshot, + | "languages" + | "nodes" + | "edges" + | "diagnostics" + | "coverage" + | "unresolved" + | "provenance" + >): string { + return digest({ + languages: snapshot.languages, + nodes: snapshot.nodes, + edges: snapshot.edges, + diagnostics: snapshot.diagnostics, + coverage: snapshot.coverage ?? [], + unresolved: snapshot.unresolved ?? [], + provenance: snapshot.provenance, + }); + } + + /** Parse a complete NDJSON transaction without accepting blank frames. */ + export function parse(text: string): Frame[] { + if (text === "") throw new Error("graph snapshot protocol: empty stream"); + return text.split(/\r?\n/u).map((line, index) => { + if (line === "") { + throw new Error( + `graph snapshot protocol: empty frame at line ${String(index + 1)}`, + ); + } + try { + return JSON.parse(line) as Frame; + } catch { + throw new Error( + `graph snapshot protocol: malformed JSON at line ${String(index + 1)}`, + ); + } + }); + } + + function digest(value: unknown): string { + return createHash("sha256").update(canonical(value)).digest("hex"); + } + + function canonical(value: unknown): string { + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) + return `[${value.map((entry) => canonical(entry)).join(",")}]`; + const object = value as Record; + return `{${Object.keys(object) + .sort(compareText) + .map((key) => `${JSON.stringify(key)}:${canonical(object[key])}`) + .join(",")}}`; + } + + function compareText(left: string, right: string): number { + /* c8 ignore next -- canonical object keys and shard keys are distinct. */ + return left < right ? -1 : left > right ? 1 : 0; + } + + function sameList( + left: readonly string[], + right: readonly string[], + ): boolean { + return ( + left.length === right.length && + left.every((value, index) => value === right[index]) + ); + } + + /** + * Atomic shard store for one provider. + * + * Failed transactions throw without modifying `current`, `generation`, or + * the committed shard set. + */ + export class Store { + private committed = new Map(); + private identity: IHello | undefined; + private snapshot: IBulkGraphSession.ISnapshot | undefined; + + public get current(): IBulkGraphSession.ISnapshot | undefined { + return this.snapshot; + } + + public apply( + frames: readonly Frame[], + options: { signal?: AbortSignal } = {}, + ): IBulkGraphSession.ISnapshot { + throwIfAborted(options.signal); + if (frames.length < 3) { + throw new Error("graph snapshot protocol: incomplete transaction"); + } + const hello = frames[0]; + const begin = frames[1]; + const commit = frames.at(-1); + if (hello?.type !== "hello") { + throw new Error("graph snapshot protocol: transaction must start with hello"); + } + if (begin?.type !== "begin") { + throw new Error("graph snapshot protocol: hello must be followed by begin"); + } + if (commit?.type !== "commit") { + throw new Error("graph snapshot protocol: transaction must end with commit"); + } + assertHello(hello); + assertBegin(begin); + if (commit.generation !== begin.generation) { + throw new Error("graph snapshot protocol: commit generation does not match begin"); + } + const priorGeneration = this.snapshot?.protocol?.generation; + if ( + begin.baseGeneration !== undefined && + begin.baseGeneration !== priorGeneration + ) { + throw new Error("graph snapshot protocol: stale base generation"); + } + if ( + begin.baseGeneration !== undefined && + this.identity !== undefined && + !sameIdentity(this.identity, hello) + ) { + throw new Error( + "graph snapshot protocol: producer identity changed across a delta", + ); + } + + const next = + begin.baseGeneration === undefined + ? new Map() + : new Map(this.committed); + const changed = new Set(); + for (const frame of frames.slice(2, -1)) { + throwIfAborted(options.signal); + if (frame.type === "upsertShard") { + if (changed.has(frame.shard.key)) { + throw new Error( + `graph snapshot protocol: duplicate shard delta: ${frame.shard.key}`, + ); + } + changed.add(frame.shard.key); + assertShard(frame.shard, hello, begin); + const digest = shardDigest(frame.shard); + if (frame.digest !== digest) { + throw new Error( + `graph snapshot protocol: shard digest mismatch: ${frame.shard.key}`, + ); + } + next.set(frame.shard.key, { + digest, + shard: clone(frame.shard), + }); + } else if (frame.type === "deleteShard") { + assertString(frame.key, "deleteShard.key"); + if (changed.has(frame.key)) { + throw new Error( + `graph snapshot protocol: duplicate shard delta: ${frame.key}`, + ); + } + changed.add(frame.key); + if (!next.delete(frame.key)) { + throw new Error( + `graph snapshot protocol: deleted shard does not exist: ${frame.key}`, + ); + } + } else { + throw new Error( + `graph snapshot protocol: unexpected ${frame.type} inside transaction`, + ); + } + } + if ( + begin.baseGeneration !== undefined && + this.snapshot !== undefined && + (begin.universe !== this.snapshot.provenance.universe || + !sameList(begin.targets, this.snapshot.protocol!.targets)) + ) { + const retained = [...this.committed.keys()].find( + (key) => !changed.has(key), + ); + if (retained !== undefined) { + throw new Error( + `graph snapshot protocol: universe or target movement retained shard ${retained}`, + ); + } + } + + const expectedManifest = [...next] + .sort(([left], [right]) => compareText(left, right)) + .map(([key, value]) => ({ key, digest: value.digest })); + if (!equalManifest(commit.shards, expectedManifest)) { + throw new Error("graph snapshot protocol: commit shard manifest mismatch"); + } + const assembled = assemble(hello, begin, commit, expectedManifest, next); + assertAssembledFacts(assembled); + if (factDigest(assembled) !== commit.factDigest) { + throw new Error("graph snapshot protocol: commit fact digest mismatch"); + } + assertCompleteCoverage(assembled, hello, begin); + throwIfAborted(options.signal); + freezeDeep(assembled, "the graph snapshot protocol generation"); + this.committed = next; + this.identity = clone(hello); + this.snapshot = assembled; + return assembled; + } + } + + interface ICommittedShard { + digest: string; + shard: IShard; + } + + function assemble( + hello: IHello, + begin: IBegin, + commit: ICommit, + manifest: IBulkGraphSession.IShard[], + shards: ReadonlyMap, + ): IBulkGraphSession.ISnapshot { + const nodes: ISamchonGraphNode[] = []; + const edges: ISamchonGraphEdge[] = []; + const diagnostics: ISamchonGraphDiagnostic[] = []; + const coverage: ISamchonGraphCoverage[] = []; + const unresolved: ISamchonGraphUnresolved[] = []; + const sources = new Map(); + for (const entry of manifest) { + const shard = shards.get(entry.key)!.shard; + nodes.push(...shard.nodes); + edges.push(...shard.edges); + diagnostics.push(...shard.diagnostics); + coverage.push(...shard.coverage); + unresolved.push(...shard.unresolved); + for (const source of shard.sources) { + const value = { + checkerDigest: source.checkerDigest, + diskDigest: source.diskDigest, + }; + const prior = sources.get(source.file); + if ( + prior !== undefined && + (prior.checkerDigest !== value.checkerDigest || + prior.diskDigest !== value.diskDigest) + ) { + throw new Error( + `graph snapshot protocol: shards disagree about source ${source.file}`, + ); + } + sources.set(source.file, value); + } + } + return { + languages: [...hello.languages], + nodes, + edges, + diagnostics, + sources: sealedMap(sources, "the graph snapshot protocol source manifest"), + provenance: { + provider: hello.provider, + authority: hello.authority, + facts: [...hello.supportedFacts], + schemaVersion: hello.schemaVersion, + tool: hello.producer, + toolVersion: hello.producerVersion, + compilerVersion: hello.compilerVersion, + protocolVersion: hello.protocolVersion, + universe: begin.universe, + capabilities: [...hello.capabilities], + }, + coverage, + unresolved, + protocol: { + version: VERSION, + generation: begin.generation, + ...(begin.baseGeneration !== undefined + ? { baseGeneration: begin.baseGeneration } + : {}), + manifest: begin.manifest, + targets: [...begin.targets], + shards: manifest.map((entry) => ({ ...entry })), + factDigest: commit.factDigest, + }, + warnings: [], + }; + } + + function assertHello(hello: IHello): void { + if (hello.protocolVersion !== VERSION) { + throw new Error( + `graph snapshot protocol: unsupported version ${String(hello.protocolVersion)}`, + ); + } + if (hello.schemaVersion !== SCHEMA_VERSION) { + throw new Error( + `graph snapshot protocol: unsupported schema version ${String(hello.schemaVersion)}`, + ); + } + assertString(hello.provider, "hello.provider"); + assertString(hello.producer, "hello.producer"); + assertString(hello.producerVersion, "hello.producerVersion"); + assertString(hello.compilerVersion, "hello.compilerVersion"); + assertUnique(hello.languages, "hello.languages"); + if ( + hello.languages.length === 0 || + hello.languages.some((language) => !LANGUAGES.has(language)) + ) { + throw new Error("graph snapshot protocol: hello languages are invalid"); + } + assertUnique(hello.supportedFacts, "hello.supportedFacts"); + if (hello.supportedFacts.some((fact) => !FACTS.has(fact))) { + throw new Error("graph snapshot protocol: hello facts are invalid"); + } + if (!AUTHORITIES.has(hello.authority)) { + throw new Error("graph snapshot protocol: hello authority is invalid"); + } + assertUnique(hello.capabilities, "hello.capabilities"); + if (hello.capabilities.some((capability) => capability === "")) { + throw new Error("graph snapshot protocol: hello capabilities are invalid"); + } + } + + function assertBegin(begin: IBegin): void { + assertString(begin.generation, "begin.generation"); + if (begin.baseGeneration !== undefined) + assertString(begin.baseGeneration, "begin.baseGeneration"); + assertDigest(begin.universe, "begin.universe"); + assertDigest(begin.manifest, "begin.manifest"); + assertUnique(begin.targets, "begin.targets"); + for (const target of begin.targets) assertString(target, "begin.targets"); + if (begin.targets.length === 0) { + throw new Error("graph snapshot protocol: begin targets are empty"); + } + } + + function assertShard(shard: IShard, hello: IHello, begin: IBegin): void { + assertString(shard.key, "shard.key"); + if (!begin.targets.includes(shard.target)) { + throw new Error( + `graph snapshot protocol: shard ${shard.key} has an unknown target`, + ); + } + assertUnique(shard.languages, `shard ${shard.key} languages`); + if ( + shard.languages.length === 0 || + shard.languages.some((language) => !hello.languages.includes(language)) + ) { + throw new Error( + `graph snapshot protocol: shard ${shard.key} has invalid languages`, + ); + } + const nodeIds = new Set(); + for (const node of shard.nodes) { + if (nodeIds.has(node.id)) { + throw new Error( + `graph snapshot protocol: shard ${shard.key} duplicated node ${node.id}`, + ); + } + nodeIds.add(node.id); + if (!shard.languages.includes(node.language)) { + throw new Error( + `graph snapshot protocol: shard ${shard.key} published a foreign-language node`, + ); + } + } + const edgeKeys = new Set(); + for (const edge of shard.edges) { + const key = `${edge.kind}\0${edge.from}\0${edge.to}`; + if (edgeKeys.has(key)) { + throw new Error( + `graph snapshot protocol: shard ${shard.key} duplicated edge ${key}`, + ); + } + edgeKeys.add(key); + } + const sourceFiles = new Set(); + for (const source of shard.sources) { + assertString(source.file, `shard ${shard.key} source file`); + if (!isCanonicalSource(source.file)) { + throw new Error( + `graph snapshot protocol: shard ${shard.key} has a non-canonical source identity`, + ); + } + if (sourceFiles.has(source.file)) { + throw new Error( + `graph snapshot protocol: shard ${shard.key} duplicated source ${source.file}`, + ); + } + sourceFiles.add(source.file); + assertDigest( + source.checkerDigest, + `shard ${shard.key} source checker digest`, + ); + if (source.diskDigest !== "") { + assertDigest( + source.diskDigest, + `shard ${shard.key} source disk digest`, + ); + } + } + } + + function assertCompleteCoverage( + snapshot: IBulkGraphSession.ISnapshot, + hello: IHello, + begin: IBegin, + ): void { + const rows = new Map(); + for (const row of snapshot.coverage!) { + if ( + row.provider !== hello.provider || + !hello.languages.includes(row.language) || + !begin.targets.includes(row.target) || + !FACTS.has(row.family) || + !COVERAGE_STATES.has(row.state) + ) { + throw new Error("graph snapshot protocol: coverage row has foreign ownership"); + } + const key = coverageKey(row); + if (rows.has(key)) { + throw new Error(`graph snapshot protocol: duplicate coverage row ${key}`); + } + rows.set(key, row); + } + for (const target of begin.targets) + for (const language of hello.languages) + for (const family of GRAPH_EDGE_KINDS) { + const key = coverageKey({ + provider: hello.provider, + language, + target, + family, + }); + const row = rows.get(key); + if (row === undefined) { + throw new Error(`graph snapshot protocol: missing coverage row ${key}`); + } + if ( + row.state !== "unsupported" && + !hello.supportedFacts.includes(family) + ) { + throw new Error( + `graph snapshot protocol: unadvertised family is not unsupported: ${key}`, + ); + } + } + const unresolvedKeys = new Set(); + const unresolvedCoverage = new Set(); + for (const site of snapshot.unresolved!) { + if (!UNRESOLVED_REASONS.has(site.reason)) { + throw new Error( + "graph snapshot protocol: unresolved site has an invalid reason", + ); + } + assertUnique(site.candidates ?? [], "unresolved candidates"); + if (site.universe !== begin.universe) { + throw new Error( + "graph snapshot protocol: unresolved site has a foreign universe", + ); + } + const row = rows.get( + coverageKey({ + provider: site.provider, + language: site.language, + target: site.target, + family: site.family, + }), + ); + if (row?.state !== "partial") { + throw new Error( + "graph snapshot protocol: unresolved site lacks partial coverage", + ); + } + unresolvedCoverage.add(coverageKey(site)); + const key = canonical(site); + if (unresolvedKeys.has(key)) { + throw new Error("graph snapshot protocol: duplicate unresolved site"); + } + unresolvedKeys.add(key); + } + for (const [key, row] of rows) { + if (row.state === "partial" && !unresolvedCoverage.has(key)) { + throw new Error( + `graph snapshot protocol: partial coverage lacks unresolved evidence: ${key}`, + ); + } + } + } + + function assertAssembledFacts( + snapshot: IBulkGraphSession.ISnapshot, + ): void { + const nodeIds = new Set(); + const files = new Set(snapshot.sources.keys()); + for (const node of snapshot.nodes) { + if (nodeIds.has(node.id)) { + throw new Error( + `graph snapshot protocol: duplicate assembled node ${node.id}`, + ); + } + nodeIds.add(node.id); + if (node.file !== "") files.add(node.file); + } + const edgeKeys = new Set(); + for (const edge of snapshot.edges) { + const key = `${edge.kind}\0${edge.from}\0${edge.to}`; + if (edgeKeys.has(key)) { + throw new Error( + `graph snapshot protocol: duplicate assembled edge ${key}`, + ); + } + edgeKeys.add(key); + if ( + (!nodeIds.has(edge.from) && !files.has(edge.from)) || + (!nodeIds.has(edge.to) && !files.has(edge.to)) + ) { + throw new Error( + `graph snapshot protocol: assembled edge has an absent endpoint: ${edge.from} -> ${edge.to}`, + ); + } + } + } + + function coverageKey(row: Pick< + ISamchonGraphCoverage, + "provider" | "language" | "target" | "family" + >): string { + return `${row.provider}\0${row.language}\0${row.target}\0${row.family}`; + } + + function equalManifest( + left: readonly IBulkGraphSession.IShard[], + right: readonly IBulkGraphSession.IShard[], + ): boolean { + return ( + left.length === right.length && + left.every( + (entry, index) => + entry.key === right[index]?.key && + entry.digest === right[index]?.digest, + ) + ); + } + + function sameIdentity(left: IHello, right: IHello): boolean { + return canonical(left) === canonical(right); + } + + function assertUnique(values: readonly T[], label: string): void { + if (new Set(values).size !== values.length) { + throw new Error(`graph snapshot protocol: ${label} contains duplicates`); + } + } + + function assertString(value: string, label: string): void { + if (value === "" || value.includes("\0")) { + throw new Error(`graph snapshot protocol: invalid ${label}`); + } + } + + function assertDigest(value: string, label: string): void { + if (!/^[a-f0-9]{64}$/u.test(value)) { + throw new Error(`graph snapshot protocol: invalid ${label}`); + } + } + + function isCanonicalSource(file: string): boolean { + if (!file.startsWith("bundled:///")) { + return path.isAbsolute(file) && path.normalize(file) === file; + } + const relative = file.slice("bundled:///".length); + return ( + relative !== "" && + !relative.includes("\\") && + path.posix.normalize(relative) === relative && + relative + .split("/") + .every((part) => part !== "" && part !== "." && part !== "..") + ); + } + + function clone(value: T): T { + return structuredClone(value); + } + + function throwIfAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted !== true) return; + const error = new Error("graph snapshot protocol: transaction was aborted"); + error.name = "AbortError"; + throw error; + } +} diff --git a/packages/graph/src/provider/IBulkGraphSession.ts b/packages/graph/src/provider/IBulkGraphSession.ts index 79e25d54..b5c35ea3 100644 --- a/packages/graph/src/provider/IBulkGraphSession.ts +++ b/packages/graph/src/provider/IBulkGraphSession.ts @@ -1,7 +1,9 @@ import { + ISamchonGraphCoverage, ISamchonGraphDiagnostic, ISamchonGraphEdge, ISamchonGraphNode, + ISamchonGraphUnresolved, } from "../structures"; import { GraphEdgeKind, @@ -102,9 +104,43 @@ export namespace IBulkGraphSession { /** Which program produced everything above, and what it can prove. */ provenance: IProvenance; + /** + * Exhaustive completeness rows for protocol-aware producers. + * + * Optional only while legacy strict producers migrate to Graph Snapshot + * Protocol v1. The coordinator normalizes an explicit partial/unsupported + * matrix for those producers so no current dump interprets missing edges as + * semantic absence. + */ + coverage?: ISamchonGraphCoverage[]; + + /** Structured unresolved sites published by a protocol-aware producer. */ + unresolved?: ISamchonGraphUnresolved[]; + + /** Validated protocol generation and content-addressed shard manifest. */ + protocol?: IProtocolGeneration; + warnings: string[]; } + /** Public identity of one committed Graph Snapshot Protocol generation. */ + export interface IProtocolGeneration { + version: number; + generation: string; + baseGeneration?: string; + /** Ordered source/configuration/dependency manifest digest. */ + manifest: string; + targets: string[]; + shards: IShard[]; + factDigest: string; + } + + /** One content-addressed shard retained by a committed generation. */ + export interface IShard { + key: string; + digest: string; + } + /** * The manifest entry for one file in the snapshot's program. * diff --git a/packages/graph/src/provider/assertGraphSnapshotContract.ts b/packages/graph/src/provider/assertGraphSnapshotContract.ts index 399feee8..03f0921e 100644 --- a/packages/graph/src/provider/assertGraphSnapshotContract.ts +++ b/packages/graph/src/provider/assertGraphSnapshotContract.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { parseGraphDump } from "../indexer/parseGraphDump"; import { GraphLanguage } from "../typings"; import { dumpProvenanceOf } from "./dumpProvenanceOf"; +import { GraphSnapshotProtocol } from "./GraphSnapshotProtocol"; import { IBulkGraphSession } from "./IBulkGraphSession"; import { IGraphProvider } from "./IGraphProvider"; @@ -40,6 +41,12 @@ export function assertGraphSnapshotContract( diagnostics: snapshot.diagnostics, warnings: snapshot.warnings, provenance: [dumpProvenanceOf(snapshot)], + ...(snapshot.coverage !== undefined + ? { coverage: snapshot.coverage } + : {}), + ...(snapshot.unresolved !== undefined + ? { unresolved: snapshot.unresolved } + : {}), }); const claimed = new Set(languages); for (const language of snapshot.languages) { @@ -95,6 +102,41 @@ export function assertGraphSnapshotContract( } assertSourceManifest(snapshot, project, label, files); + assertProtocol(snapshot, label); +} + +function assertProtocol( + snapshot: IBulkGraphSession.ISnapshot, + label: string, +): void { + const protocol = snapshot.protocol; + if (protocol === undefined) return; + if ( + protocol.version !== GraphSnapshotProtocol.VERSION || + protocol.generation === "" || + protocol.targets.length === 0 || + new Set(protocol.targets).size !== protocol.targets.length || + !SHA256.test(protocol.manifest) || + !SHA256.test(protocol.factDigest) || + snapshot.coverage === undefined || + snapshot.unresolved === undefined + ) { + throw new Error(`${label} published an invalid protocol generation`); + } + const shards = new Set(); + for (const shard of protocol.shards) { + if ( + shard.key === "" || + shards.has(shard.key) || + !SHA256.test(shard.digest) + ) { + throw new Error(`${label} published an invalid protocol shard manifest`); + } + shards.add(shard.key); + } + if (GraphSnapshotProtocol.factDigest(snapshot) !== protocol.factDigest) { + throw new Error(`${label} published a mismatched protocol fact digest`); + } } function assertSourceManifest( @@ -143,6 +185,9 @@ function assertSourceManifest( for (const diagnostic of snapshot.diagnostics) { if (diagnostic.file !== "") requireHostSource(required, diagnostic.file); } + for (const unresolved of snapshot.unresolved ?? []) { + requireHostSource(required, unresolved.evidence.file); + } for (const file of required) { const source = path.resolve(root, file); diff --git a/packages/graph/src/provider/coverageRows.ts b/packages/graph/src/provider/coverageRows.ts new file mode 100644 index 00000000..d8014a53 --- /dev/null +++ b/packages/graph/src/provider/coverageRows.ts @@ -0,0 +1,31 @@ +import { ISamchonGraphCoverage } from "../structures"; +import { + GRAPH_EDGE_KINDS, + GraphEdgeKind, + GraphLanguage, +} from "../typings"; + +/** Build one deterministic exhaustive coverage matrix. */ +export function coverageRows( + provider: string, + languages: readonly GraphLanguage[], + target: string, + supported: ReadonlySet, +): ISamchonGraphCoverage[] { + return [...languages] + .sort(compareText) + .flatMap((language) => + GRAPH_EDGE_KINDS.map((family) => ({ + provider, + language, + target, + family, + state: supported.has(family) ? "partial" : "unsupported", + })), + ); +} + +function compareText(left: string, right: string): number { + /* c8 ignore next 2 -- normalized language sets contain distinct values. */ + return left < right ? -1 : left > right ? 1 : 0; +} diff --git a/packages/graph/src/provider/fallbackCoverage.ts b/packages/graph/src/provider/fallbackCoverage.ts new file mode 100644 index 00000000..be392c91 --- /dev/null +++ b/packages/graph/src/provider/fallbackCoverage.ts @@ -0,0 +1,22 @@ +import { ISamchonGraphCoverage } from "../structures"; +import { GRAPH_EDGE_KINDS, GraphLanguage } from "../typings"; +import { coverageRows } from "./coverageRows"; + +/** + * Truthful coverage for a generic LSP or static lane. + * + * These lanes attempt multiple families heuristically and cannot make absence + * meaningful. Every family is therefore partial in one explicitly named + * fallback target. + */ +export function fallbackCoverage( + provider: "@samchon/graph-lsp" | "@samchon/graph-sitter", + languages: readonly GraphLanguage[], +): ISamchonGraphCoverage[] { + return coverageRows( + provider, + languages, + "fallback/default", + new Set(GRAPH_EDGE_KINDS), + ); +} diff --git a/packages/graph/src/provider/graphCoverageOf.ts b/packages/graph/src/provider/graphCoverageOf.ts new file mode 100644 index 00000000..fc475917 --- /dev/null +++ b/packages/graph/src/provider/graphCoverageOf.ts @@ -0,0 +1,24 @@ +import { ISamchonGraphCoverage } from "../structures"; +import { IBulkGraphSession } from "./IBulkGraphSession"; +import { coverageRows } from "./coverageRows"; + +/** + * Normalize one strict snapshot to an exhaustive coverage matrix. + * + * Protocol-aware producers publish their exact rows. Legacy strict producers + * are deliberately conservative during migration: a registered family is + * `partial`, never silently `complete`, and every other family is + * `unsupported`. + */ +export function graphCoverageOf( + snapshot: IBulkGraphSession.ISnapshot, +): ISamchonGraphCoverage[] { + return snapshot.coverage === undefined + ? coverageRows( + snapshot.provenance.provider, + snapshot.languages, + snapshot.provenance.universe, + new Set(snapshot.provenance.facts), + ) + : snapshot.coverage.map((row) => ({ ...row })); +} diff --git a/packages/graph/src/provider/graphSnapshotDigests.ts b/packages/graph/src/provider/graphSnapshotDigests.ts index be84786a..37053d80 100644 --- a/packages/graph/src/provider/graphSnapshotDigests.ts +++ b/packages/graph/src/provider/graphSnapshotDigests.ts @@ -1,5 +1,7 @@ import { createHash } from "node:crypto"; +import { graphCoverageOf } from "./graphCoverageOf"; +import { graphUnresolvedOf } from "./graphUnresolvedOf"; import { IBulkGraphSession } from "./IBulkGraphSession"; /** @@ -54,6 +56,12 @@ export namespace graphSnapshotDigests { for (const diagnostic of snapshot.diagnostics) { hash.update(`diagnostic\0${canonical(diagnostic)}\n`); } + for (const coverage of graphCoverageOf(snapshot)) { + hash.update(`coverage\0${canonical(coverage)}\n`); + } + for (const unresolved of graphUnresolvedOf(snapshot)) { + hash.update(`unresolved\0${canonical(unresolved)}\n`); + } return hash.digest("hex"); } @@ -77,8 +85,11 @@ export namespace graphSnapshotDigests { nodes: snapshot.nodes, edges: snapshot.edges, diagnostics: snapshot.diagnostics, + coverage: graphCoverageOf(snapshot), + unresolved: graphUnresolvedOf(snapshot), sources, provenance: snapshot.provenance, + protocol: snapshot.protocol, warnings: snapshot.warnings, }), ) diff --git a/packages/graph/src/provider/graphUnresolvedOf.ts b/packages/graph/src/provider/graphUnresolvedOf.ts new file mode 100644 index 00000000..199ff2d2 --- /dev/null +++ b/packages/graph/src/provider/graphUnresolvedOf.ts @@ -0,0 +1,15 @@ +import { ISamchonGraphUnresolved } from "../structures"; +import { IBulkGraphSession } from "./IBulkGraphSession"; + +/** Structured uncertainty retained from a protocol-aware producer. */ +export function graphUnresolvedOf( + snapshot: IBulkGraphSession.ISnapshot, +): ISamchonGraphUnresolved[] { + return (snapshot.unresolved ?? []).map((row) => ({ + ...row, + evidence: { ...row.evidence }, + ...(row.candidates !== undefined + ? { candidates: [...row.candidates] } + : {}), + })); +} diff --git a/packages/graph/src/provider/index.ts b/packages/graph/src/provider/index.ts index 3d5a7e54..b892fe99 100644 --- a/packages/graph/src/provider/index.ts +++ b/packages/graph/src/provider/index.ts @@ -3,6 +3,10 @@ export * from "./dumpProvenanceOf"; export * from "./BatchGraphSession"; export * from "./GRAPH_PROVIDERS"; export * from "./graphSnapshotDigests"; +export * from "./fallbackCoverage"; +export * from "./graphCoverageOf"; +export * from "./graphUnresolvedOf"; +export * from "./GraphSnapshotProtocol"; export * from "./go"; export * from "./IBulkGraphSession"; export * from "./IGraphProvider"; diff --git a/packages/graph/src/provider/rust/rustScipProvider.ts b/packages/graph/src/provider/rust/rustScipProvider.ts index f336a6b3..5fc62790 100644 --- a/packages/graph/src/provider/rust/rustScipProvider.ts +++ b/packages/graph/src/provider/rust/rustScipProvider.ts @@ -34,6 +34,9 @@ export const rustScipProvider = Object.assign( // rust-analyzer writes the protobuf default empty string for every // document, not a copy of the source bytes it analyzed. sourceText: false, + // Stock rust-analyzer SCIP emits occurrences, definitions, and references, + // but no relationship that proves the referenced symbol is a type. + omitFacts: ["type_ref"], // Stock rust-analyzer omits the protobuf-default project_root. The session // invokes `rust-analyzer scip .` with the project root as its exact cwd and // an isolated output artifact, so that cwd is the missing root evidence; an diff --git a/packages/graph/src/structures/ISamchonGraphApplication.ts b/packages/graph/src/structures/ISamchonGraphApplication.ts index 97a81d6a..fede08e7 100644 --- a/packages/graph/src/structures/ISamchonGraphApplication.ts +++ b/packages/graph/src/structures/ISamchonGraphApplication.ts @@ -4,8 +4,11 @@ import { ISamchonGraphEscape } from "./ISamchonGraphEscape"; import { ISamchonGraphLookup } from "./ISamchonGraphLookup"; import { ISamchonGraphNext } from "./ISamchonGraphNext"; import { ISamchonGraphOverview } from "./ISamchonGraphOverview"; +import { ISamchonGraphCoverageSummary } from "./ISamchonGraphCoverageSummary"; +import { ISamchonGraphDump } from "./ISamchonGraphDump"; import { ISamchonGraphTour } from "./ISamchonGraphTour"; import { ISamchonGraphTrace } from "./ISamchonGraphTrace"; +import { ISamchonGraphUnresolvedSummary } from "./ISamchonGraphUnresolvedSummary"; /** * ## Code Graph MCP @@ -92,9 +95,8 @@ export interface ISamchonGraphApplication { /** * Answer a __LANG__ question from this repository's own program index. * - * The graph holds every symbol, call, type, decorator and test, each with its - * file and line, resolved from the source on disk now. Submit exactly one - * request: + * The graph returns proved indexed facts plus structured coverage and + * uncertainty. Submit exactly one request: * * - `tour`: architecture, the runtime flow from the public API to the code that * does the work, nearby paths, and the tests to read — a whole orientation @@ -178,6 +180,25 @@ export namespace ISamchonGraphApplication { */ audit: string; + /** + * Strict producer, authority, compiler and build-universe identity for the + * synchronized graph. Absent only for `escape` or a legacy/fallback-only + * dump with no strict producer. + */ + provenance?: ISamchonGraphDump.IProvenance[]; + + /** + * Machine-readable completeness for the relationship families relevant to + * this operation. Absent only for `escape`. + */ + coverage?: ISamchonGraphCoverageSummary; + + /** + * Bounded structured uncertainty for the same operation-scoped families. + * Absent only for `escape`. + */ + unresolved?: ISamchonGraphUnresolvedSummary; + /** What to do with `result`: answer, inspect one named request, or escape. */ next: ISamchonGraphNext; diff --git a/packages/graph/src/structures/ISamchonGraphCoverage.ts b/packages/graph/src/structures/ISamchonGraphCoverage.ts new file mode 100644 index 00000000..f1f0b7aa --- /dev/null +++ b/packages/graph/src/structures/ISamchonGraphCoverage.ts @@ -0,0 +1,36 @@ +import { GraphEdgeKind, GraphLanguage } from "../typings"; + +/** + * What one producer can prove for one relationship family in one build target. + * + * Coverage is explicit because an empty edge list has two incompatible + * meanings: either the producer proved there are no such relationships, or it + * did not know how to collect them. Consumers must never infer which from the + * payload shape. + */ +export interface ISamchonGraphCoverage { + /** Stable registry identity of the producer that owns this row. */ + provider: string; + + /** Source language whose facts the row describes. */ + language: GraphLanguage; + + /** + * Producer-defined build target/configuration coordinate. + * + * This is not a display label. Equal values mean facts belong to the same + * semantic universe; incompatible source sets, features, triples or execution + * environments must use different values. + */ + target: string; + + /** Relationship family whose absence or uncertainty this row qualifies. */ + family: GraphEdgeKind; + + /** + * `complete` makes absence meaningful in the named universe; `partial` + * publishes proven facts while unresolved/excluded sites remain; + * `unsupported` says the producer cannot prove the family. + */ + state: "complete" | "partial" | "unsupported"; +} diff --git a/packages/graph/src/structures/ISamchonGraphCoverageSummary.ts b/packages/graph/src/structures/ISamchonGraphCoverageSummary.ts new file mode 100644 index 00000000..8348d46f --- /dev/null +++ b/packages/graph/src/structures/ISamchonGraphCoverageSummary.ts @@ -0,0 +1,14 @@ +import { GraphEdgeKind } from "../typings"; +import { ISamchonGraphCoverage } from "./ISamchonGraphCoverage"; + +/** Operation-scoped machine-readable completeness returned beside MCP audit. */ +export interface ISamchonGraphCoverageSummary { + /** Version of this additive MCP trust contract. */ + schemaVersion: 1; + + /** Relationship families relevant to the selected operation. */ + families: GraphEdgeKind[]; + + /** Provider/target rows for those families. */ + rows: ISamchonGraphCoverage[]; +} diff --git a/packages/graph/src/structures/ISamchonGraphDump.ts b/packages/graph/src/structures/ISamchonGraphDump.ts index 3f9921b4..b83caf49 100644 --- a/packages/graph/src/structures/ISamchonGraphDump.ts +++ b/packages/graph/src/structures/ISamchonGraphDump.ts @@ -3,8 +3,10 @@ import { GraphLanguage } from "../typings/GraphLanguage"; import { GraphProviderAuthority } from "../typings/GraphProviderAuthority"; import { ISamchonGraphDiagnostic } from "./ISamchonGraphDiagnostic"; import { ISamchonGraphEdge } from "./ISamchonGraphEdge"; +import { ISamchonGraphCoverage } from "./ISamchonGraphCoverage"; import { ISamchonGraphNode } from "./ISamchonGraphNode"; import { ISamchonGraphSpan } from "./ISamchonGraphSpan"; +import { ISamchonGraphUnresolved } from "./ISamchonGraphUnresolved"; /** * The whole-graph export `samchon-graph dump` writes and the MCP server loads — @@ -42,6 +44,18 @@ export interface ISamchonGraphDump { /** What each strict provider proved about the slice it contributed, one row per provider, ordered by provider name so an unchanged checkout stays byte-identical. Absent when no strict provider served the build, and absent from dumps written before this field existed. Computation mode is deliberately not here: it belongs to one refresh rather than to the facts, so recording it would make two dumps of the same unedited checkout differ. */ provenance?: ISamchonGraphDump.IProvenance[]; + /** + * Exhaustive per-provider, language, target and relationship-family + * completeness rows. Absent only on dumps written before protocol version 1. + */ + coverage?: ISamchonGraphCoverage[]; + + /** + * Structured relationship sites that a producer could not resolve exactly. + * An empty list is meaningful only together with exhaustive coverage. + */ + unresolved?: ISamchonGraphUnresolved[]; + /** Every node the build recorded. */ nodes: ISamchonGraphDump.INode[]; diff --git a/packages/graph/src/structures/ISamchonGraphUnresolved.ts b/packages/graph/src/structures/ISamchonGraphUnresolved.ts new file mode 100644 index 00000000..753c25aa --- /dev/null +++ b/packages/graph/src/structures/ISamchonGraphUnresolved.ts @@ -0,0 +1,44 @@ +import { GraphEdgeKind, GraphLanguage } from "../typings"; +import { ISamchonGraphEvidence } from "./ISamchonGraphEvidence"; + +/** + * One relationship site a semantic producer could not resolve exactly. + * + * Candidates remain evidence, not executed edges. In particular, a possible + * dynamic receiver target must not become `dispatches` until the selected + * universe proves it is the one runtime target. + */ +export interface ISamchonGraphUnresolved { + /** Stable registry identity of the producer that encountered the site. */ + provider: string; + + /** Source language of the unresolved expression or declaration. */ + language: GraphLanguage; + + /** Same semantic target/configuration coordinate used by coverage. */ + target: string; + + /** Exact build-universe digest in which this uncertainty was observed. */ + universe: string; + + /** Relationship family the producer could not settle. */ + family: GraphEdgeKind; + + /** Source location that grounds the uncertainty. */ + evidence: ISamchonGraphEvidence; + + /** Stable, closed reason understood by consumers. */ + reason: + | "dynamic" + | "reflection" + | "macro-or-generated" + | "conditional-build" + | "external-boundary" + | "analysis-error" + | "excluded-input" + | "identity-unstable" + | "provider-gap"; + + /** Compiler-proven possibilities, never guessed names. */ + candidates?: string[]; +} diff --git a/packages/graph/src/structures/ISamchonGraphUnresolvedSummary.ts b/packages/graph/src/structures/ISamchonGraphUnresolvedSummary.ts new file mode 100644 index 00000000..70ebad50 --- /dev/null +++ b/packages/graph/src/structures/ISamchonGraphUnresolvedSummary.ts @@ -0,0 +1,16 @@ +import { ISamchonGraphUnresolved } from "./ISamchonGraphUnresolved"; + +/** Bounded, operation-scoped uncertainty returned beside MCP audit. */ +export interface ISamchonGraphUnresolvedSummary { + /** Number of relevant unresolved sites in the complete resident graph. */ + count: number; + + /** Stable counts by machine-readable reason. */ + reasons: { + reason: ISamchonGraphUnresolved["reason"]; + count: number; + }[]; + + /** Deterministic first slice; `count` says whether more exist. */ + examples: ISamchonGraphUnresolved[]; +} diff --git a/packages/graph/src/structures/index.ts b/packages/graph/src/structures/index.ts index cf6ecbbf..0ba92693 100644 --- a/packages/graph/src/structures/index.ts +++ b/packages/graph/src/structures/index.ts @@ -20,4 +20,8 @@ export * from "./ISamchonGraphSpan"; export * from "./ISamchonGraphTour"; export * from "./ISamchonGraphTrace"; export * from "./ISamchonGraphApplication"; +export * from "./ISamchonGraphCoverage"; +export * from "./ISamchonGraphCoverageSummary"; +export * from "./ISamchonGraphUnresolved"; +export * from "./ISamchonGraphUnresolvedSummary"; export * from "./SamchonGraphNodeModifier"; diff --git a/packages/graph/src/typings/GRAPH_EDGE_KINDS.ts b/packages/graph/src/typings/GRAPH_EDGE_KINDS.ts new file mode 100644 index 00000000..ec5bac28 --- /dev/null +++ b/packages/graph/src/typings/GRAPH_EDGE_KINDS.ts @@ -0,0 +1,20 @@ +import { GraphEdgeKind } from "./GraphEdgeKind"; + +/** Every relationship family in deterministic protocol order. */ +export const GRAPH_EDGE_KINDS: readonly GraphEdgeKind[] = [ + "contains", + "exports", + "imports", + "calls", + "accesses", + "instantiates", + "type_ref", + "extends", + "implements", + "overrides", + "dispatches", + "decorates", + "renders", + "tests", + "references", +]; diff --git a/packages/graph/src/typings/index.ts b/packages/graph/src/typings/index.ts index 1ba0ae29..88b42fba 100644 --- a/packages/graph/src/typings/index.ts +++ b/packages/graph/src/typings/index.ts @@ -1,4 +1,5 @@ export * from "./GraphEdgeKind"; +export * from "./GRAPH_EDGE_KINDS"; export * from "./GraphLanguage"; export * from "./GraphNodeKind"; export * from "./GraphProviderAuthority"; diff --git a/sidecars/go/analyze.go b/sidecars/go/analyze.go index 3c3f3e4b..e6ae12f0 100644 --- a/sidecars/go/analyze.go +++ b/sidecars/go/analyze.go @@ -240,8 +240,9 @@ func (c *collector) units(loaded []*packages.Package) ([]unit, error) { } // `go help packages`: "The go tool will ignore a directory named // testdata". scip-go enumerates by pattern and so never indexes one, - // while the checker reaches it through an ordinary import — gin's - // tests import .../testdata/protoexample, which is legal because + // while the checker reaches it through an ordinary import — a + // package may explicitly import a sibling under `testdata`, which is + // legal because // testdata is skipped by pattern matching and not by the importer. // // The two therefore disagreed about what the project is, and the @@ -486,7 +487,7 @@ func (c *collector) addObjectNode( symbol := objectSymbol(object, qualified) // Go allows many `func init()` in one package and forbids referring to any // of them, so every one shares a FullName and they all derive one identity. - // gin has several and the second one failed the build. + // A real package can have several; the second used to collide. // // Unlike the blank identifier this cannot be skipped: an init body runs and // what it calls are edges worth having. So it is disambiguated by where it diff --git a/tests/test-graph/src/features/test_contract_fixture_covers_every_graph_node_and_edge_kind.ts b/tests/test-graph/src/features/test_contract_fixture_covers_every_graph_node_and_edge_kind.ts index 2c85640c..4e6835de 100644 --- a/tests/test-graph/src/features/test_contract_fixture_covers_every_graph_node_and_edge_kind.ts +++ b/tests/test-graph/src/features/test_contract_fixture_covers_every_graph_node_and_edge_kind.ts @@ -1,5 +1,5 @@ import { TestValidator } from "@nestia/e2e"; -import { SamchonGraphMemory } from "@samchon/graph"; +import { GRAPH_EDGE_KINDS, SamchonGraphMemory } from "@samchon/graph"; import { GraphFixtures } from "../internal/GraphFixtures"; @@ -12,6 +12,11 @@ export const test_contract_fixture_covers_every_graph_node_and_edge_kind = () => [...new Set(graph.nodes.map((node) => node.kind))].sort(), [...GraphFixtures.GRAPH_NODE_KINDS].sort(), ); + TestValidator.equals( + "the protocol coverage order contains the exact public edge-kind union", + GRAPH_EDGE_KINDS, + GraphFixtures.GRAPH_EDGE_KINDS, + ); // Every edge kind an index can store is in the fixture. `dispatches` is the // one it cannot: a forward walk synthesizes it when a call lands on a // declaration with no body, so it lives in a traversal and never in a graph. diff --git a/tests/test-graph/src/features/test_graph_dump_parser_closes_every_public_trust_boundary.ts b/tests/test-graph/src/features/test_graph_dump_parser_closes_every_public_trust_boundary.ts index b4344803..6dbca456 100644 --- a/tests/test-graph/src/features/test_graph_dump_parser_closes_every_public_trust_boundary.ts +++ b/tests/test-graph/src/features/test_graph_dump_parser_closes_every_public_trust_boundary.ts @@ -1,5 +1,10 @@ import { TestValidator } from "@nestia/e2e"; -import { parseGraphDump, semanticGraphNodeId } from "@samchon/graph"; +import { + GRAPH_EDGE_KINDS, + ISamchonGraphDump, + parseGraphDump, + semanticGraphNodeId, +} from "@samchon/graph"; import path from "node:path"; const valid = () => ({ @@ -92,6 +97,15 @@ export const test_graph_dump_parser_closes_every_public_trust_boundary = parseGraphDump(provenance).provenance?.[0]?.provider, "scip-go", ); + const trusted = withTrust(); + TestValidator.equals( + "exhaustive coverage and universe-bound uncertainty parse", + [ + parseGraphDump(trusted).coverage?.length, + parseGraphDump(trusted).unresolved?.[0]?.reason, + ], + [GRAPH_EDGE_KINDS.length, "dynamic"], + ); await rejected("duplicate node identities", (candidate) => { candidate.nodes.push({ ...candidate.nodes[1]! }); @@ -282,6 +296,74 @@ export const test_graph_dump_parser_closes_every_public_trust_boundary = candidate.provenance = [{ ...validProvenance(), [label]: "bad" }]; }); } + await rejectedTrust("empty coverage provider identities", (candidate) => { + candidate.coverage![0]!.provider = ""; + }); + await rejectedTrust( + "NUL-delimited coverage provider identities", + (candidate) => { + candidate.coverage![0]!.provider = "scip\0go"; + }, + ); + await rejectedTrust("empty coverage targets", (candidate) => { + candidate.coverage![0]!.target = ""; + }); + await rejectedTrust("NUL-delimited coverage targets", (candidate) => { + candidate.coverage![0]!.target = "fixture\0other"; + }); + await rejectedTrust("coverage languages absent from the dump", (candidate) => { + record(candidate.coverage![0]!).language = "rust"; + }); + await rejectedTrust("duplicate coverage rows", (candidate) => { + candidate.coverage!.push({ ...candidate.coverage![0]! }); + }); + await rejectedTrust("missing provider coverage", (candidate) => { + candidate.coverage = []; + candidate.unresolved = []; + }); + await rejectedTrust("non-exhaustive provider coverage", (candidate) => { + candidate.coverage!.pop(); + }); + await rejected( + "non-exhaustive fallback-only coverage", + (candidate) => { + candidate.coverage = GRAPH_EDGE_KINDS.slice(1).map((family) => ({ + provider: "@samchon/graph-lsp", + language: "go", + target: "fallback/default", + family, + state: "partial", + })); + candidate.unresolved = []; + }, + ); + await rejectedTrust("invalid unresolved evidence", (candidate) => { + candidate.unresolved![0]!.evidence.startLine = 0; + }); + await rejectedTrust("duplicate unresolved candidates", (candidate) => { + candidate.unresolved![0]!.candidates = ["candidate", "candidate"]; + }); + await rejectedTrust("malformed unresolved universes", (candidate) => { + candidate.unresolved![0]!.universe = "bad"; + }); + await rejectedTrust("unowned unresolved providers", (candidate) => { + candidate.unresolved![0]!.provider = "other"; + }); + await rejectedTrust("unresolved sites without provenance", (candidate) => { + candidate.provenance = undefined; + }); + await rejectedTrust("mismatched unresolved universes", (candidate) => { + candidate.unresolved![0]!.universe = "b".repeat(64); + }); + await rejectedTrust("unresolved sites without partial coverage", (candidate) => { + candidate.coverage!.find((row) => row.family === "calls")!.state = + "complete"; + }); + await rejectedTrust("duplicate unresolved sites", (candidate) => { + candidate.unresolved!.push( + structuredClone(candidate.unresolved![0]!), + ); + }); await rejected("semantic display suffix mismatches", (candidate) => { candidate.nodes[0]!.qualifiedName = "example.NotRun"; }); @@ -302,6 +384,8 @@ type Candidate = ReturnType & { message: string; }>; provenance?: Array>; + coverage?: NonNullable; + unresolved?: NonNullable; }; const rejected = async ( @@ -315,6 +399,17 @@ const rejected = async ( ); }; +const rejectedTrust = async ( + label: string, + mutate: (candidate: Candidate) => void, +): Promise => { + const candidate = withTrust(); + mutate(candidate); + await TestValidator.error(`${label} fail closed`, () => + parseGraphDump(candidate), + ); +}; + function withEdge(): Candidate { const candidate = valid(); candidate.edges.push({ @@ -325,6 +420,32 @@ function withEdge(): Candidate { return candidate as Candidate; } +function withTrust(): Candidate { + const candidate = withEdge(); + const provenance = validProvenance(); + candidate.provenance = [provenance]; + candidate.coverage = GRAPH_EDGE_KINDS.map((family) => ({ + provider: provenance.provider, + language: "go", + target: "fixture", + family, + state: family === "calls" ? "partial" : "unsupported", + })); + candidate.unresolved = [ + { + provider: provenance.provider, + language: "go", + target: "fixture", + universe: provenance.universe, + family: "calls", + evidence: { file: "src/run.go", startLine: 1, startCol: 1 }, + reason: "dynamic", + candidates: ["candidate"], + }, + ]; + return candidate; +} + function record(value: object): Record { return value as Record; } diff --git a/tests/test-graph/src/features/test_graph_snapshot_protocol_commits_atomic_shard_generations.ts b/tests/test-graph/src/features/test_graph_snapshot_protocol_commits_atomic_shard_generations.ts new file mode 100644 index 00000000..70b15231 --- /dev/null +++ b/tests/test-graph/src/features/test_graph_snapshot_protocol_commits_atomic_shard_generations.ts @@ -0,0 +1,1026 @@ +import { TestValidator } from "@nestia/e2e"; +import { + GRAPH_EDGE_KINDS, + GraphSnapshotProtocol, + IBulkGraphSession, + assertGraphSnapshotContract, + graphCoverageOf, + graphSnapshotDigests, + graphUnresolvedOf, +} from "@samchon/graph"; +import path from "node:path"; + +const digest = (letter: string): string => letter.repeat(64); + +/** + * Graph Snapshot Protocol publishes one validated complete generation or keeps + * the prior one byte-for-byte. The fixture is an external producer oracle: all + * digests are computed from the public protocol helpers, never copied from the + * store under test. + */ +export const test_graph_snapshot_protocol_commits_atomic_shard_generations = + async () => { + const store = new GraphSnapshotProtocol.Store(); + const initialFrames = transaction("generation-1"); + const ndjson = initialFrames.map(JSON.stringify).join("\n"); + const parsed = GraphSnapshotProtocol.parse(ndjson); + const initial = store.apply(parsed); + const provider = { + name: "fixture-compiler", + authority: "compiler" as const, + facts: ["calls" as const], + }; + assertGraphSnapshotContract( + initial, + provider, + ["typescript"], + process.cwd(), + ); + + TestValidator.equals( + "the committed generation reconstructs every protocol plane", + [ + initial.protocol?.generation, + initial.protocol?.manifest, + initial.protocol?.shards.map((shard) => shard.key), + initial.nodes.map((node) => node.name), + initial.coverage?.length, + initial.unresolved?.map((site) => site.reason), + ], + [ + "generation-1", + digest("b"), + ["coverage", "source"], + ["run"], + GRAPH_EDGE_KINDS.length, + ["dynamic", "reflection"], + ], + ); + TestValidator.equals( + "protocol-aware helpers preserve explicit coverage and uncertainty", + [ + graphCoverageOf(initial).length, + graphUnresolvedOf(initial).length, + graphSnapshotDigests.contentOf(initial).length, + GraphSnapshotProtocol.factDigest({ + languages: initial.languages, + nodes: initial.nodes, + edges: initial.edges, + diagnostics: initial.diagnostics, + provenance: initial.provenance, + }).length, + ], + [GRAPH_EDGE_KINDS.length, 2, 64, 64], + ); + for (const [label, expected, mutateSnapshot] of invalidProtocolSnapshots()) { + let message = ""; + try { + const candidate = cloneSnapshot(initial); + mutateSnapshot(candidate); + assertGraphSnapshotContract( + candidate, + provider, + ["typescript"], + process.cwd(), + ); + } catch (error) { + message = error instanceof Error ? error.message : String(error); + } + TestValidator.predicate( + `${label} fails at the protocol publication gate: ${message}`, + message.includes(expected), + ); + } + TestValidator.error("a published generation is deeply immutable", () => { + initial.nodes.push({ ...initial.nodes[0]! }); + }); + + const editedFrames = transaction("generation-2", { + baseGeneration: "generation-1", + nodeName: "edited", + }); + const edited = store.apply(editedFrames); + TestValidator.equals( + "a delta reuses unchanged shards and replaces only its upsert", + [ + edited.nodes.map((node) => node.name), + edited.protocol?.baseGeneration, + edited.protocol?.shards[0]?.digest === + initial.protocol?.shards[0]?.digest, + ], + [["edited"], "generation-1", true], + ); + + const deleted = store.apply( + transaction("generation-3", { + baseGeneration: "generation-2", + deleteSource: true, + }), + ); + TestValidator.equals( + "an explicit delete removes the shard without disturbing coverage", + [deleted.nodes, deleted.coverage?.length], + [[], GRAPH_EDGE_KINDS.length], + ); + + await rejectedWithoutMovement( + store, + transaction("stale", { baseGeneration: "generation-1" }), + "a stale base", + ); + await rejectedWithoutMovement( + store, + mutate( + transaction("changed-identity", { + baseGeneration: "generation-3", + }), + (frames) => { + (frames[0] as GraphSnapshotProtocol.IHello).producer = "other"; + }, + ), + "producer identity movement across a delta", + ); + await rejectedWithoutMovement( + store, + transaction("missing-delete", { + baseGeneration: "generation-3", + deleteSource: true, + }), + "deleting an absent shard", + ); + await rejectedWithoutMovement( + store, + mutate( + transaction("moved-universe", { + baseGeneration: "generation-3", + }), + (frames) => { + (frames[1] as GraphSnapshotProtocol.IBegin).universe = digest("d"); + }, + ), + "universe movement retaining an untouched shard", + ); + await rejectedWithoutMovement( + store, + mutate( + transaction("moved-target", { + baseGeneration: "generation-3", + }), + (frames) => { + (frames[1] as GraphSnapshotProtocol.IBegin).targets.push("other"); + }, + ), + "target movement retaining an untouched shard", + ); + await rejectedWithoutMovement( + store, + mutate(transaction("bad-shard", { baseGeneration: "generation-3" }), (frames) => { + upsert(frames, "source").digest = digest("f"); + }), + "a shard digest mismatch", + ); + await rejectedWithoutMovement( + store, + mutate(transaction("bad-facts", { baseGeneration: "generation-3" }), (frames) => { + commit(frames).factDigest = digest("f"); + }), + "a fact digest mismatch", + ); + await rejectedWithoutMovement( + store, + mutate(transaction("bad-manifest", { baseGeneration: "generation-3" }), (frames) => { + commit(frames).shards.reverse(); + }), + "a non-canonical manifest", + ); + await rejectedWithoutMovement( + store, + mutate(transaction("missing-coverage"), (frames) => { + coverageShard(frames).shard.coverage.pop(); + refreshDigests(frames); + }), + "a missing coverage family", + ); + await rejectedWithoutMovement( + store, + mutate(transaction("wrong-owner"), (frames) => { + coverageShard(frames).shard.coverage[0]!.provider = "other"; + refreshDigests(frames); + }), + "foreign coverage ownership", + ); + await rejectedWithoutMovement( + store, + mutate(transaction("wrong-uncertainty"), (frames) => { + coverageShard(frames).shard.coverage.find( + (row) => row.family === "calls", + )!.state = "complete"; + refreshDigests(frames); + }), + "an unresolved site without partial coverage", + ); + await rejectedWithoutMovement( + store, + mutate(transaction("missing-uncertainty"), (frames) => { + coverageShard(frames).shard.unresolved = []; + refreshDigests(frames); + }), + "partial coverage without unresolved evidence", + ); + await rejectedWithoutMovement( + store, + mutate(transaction("wrong-universe"), (frames) => { + coverageShard(frames).shard.unresolved[0]!.universe = digest("d"); + refreshDigests(frames); + }), + "an unresolved site from another universe", + ); + + const aborted = new AbortController(); + aborted.abort(); + await rejectedWithoutMovement( + store, + transaction("aborted"), + "an aborted transaction", + aborted.signal, + ); + TestValidator.error("an empty stream is rejected", () => + GraphSnapshotProtocol.parse(""), + ); + TestValidator.error("a blank NDJSON frame is rejected", () => + GraphSnapshotProtocol.parse("{}\n"), + ); + TestValidator.error("malformed NDJSON is rejected", () => + GraphSnapshotProtocol.parse("{"), + ); + + for (const [label, frames] of malformedTransactions()) { + await rejectedWithoutMovement(store, frames, label); + } + + const deleteStore = new GraphSnapshotProtocol.Store(); + deleteStore.apply(transaction("delete-generation-1")); + const duplicateDelete = transaction("delete-generation-2", { + baseGeneration: "delete-generation-1", + deleteSource: true, + }); + duplicateDelete.splice( + duplicateDelete.length - 1, + 0, + structuredClone(duplicateDelete[2]!), + ); + await rejectedWithoutMovement( + deleteStore, + duplicateDelete, + "a duplicate delete delta", + ); + + const bundledStore = new GraphSnapshotProtocol.Store(); + const bundledFrames = mutate( + transaction("bundled-generation"), + (frames) => { + const shard = upsert(frames, "source").shard; + const file = "bundled:///typescript/lib.d.ts"; + Object.assign(shard.nodes[0]!, { + id: file, + kind: "file", + name: "lib.d.ts", + file, + external: true, + }); + shard.sources[0]!.file = file; + refreshDigests(frames); + }, + ); + TestValidator.equals( + "a canonical bundled source identity commits", + [...bundledStore.apply(bundledFrames).sources.keys()], + ["bundled:///typescript/lib.d.ts"], + ); + }; + +interface ITransactionOptions { + baseGeneration?: string; + nodeName?: string; + deleteSource?: boolean; +} + +function transaction( + generation: string, + options: ITransactionOptions = {}, +): GraphSnapshotProtocol.Frame[] { + const hello = validHello(); + const begin: GraphSnapshotProtocol.IBegin = { + type: "begin", + generation, + ...(options.baseGeneration !== undefined + ? { baseGeneration: options.baseGeneration } + : {}), + universe: digest("a"), + manifest: digest("b"), + targets: ["app"], + }; + const coverage: GraphSnapshotProtocol.IShard = { + key: "coverage", + target: "app", + languages: ["typescript"], + nodes: [], + edges: [], + diagnostics: [], + coverage: GRAPH_EDGE_KINDS.map((family) => ({ + provider: hello.provider, + language: "typescript", + target: "app", + family, + state: family === "calls" ? "partial" : "unsupported", + })), + unresolved: [ + { + provider: hello.provider, + language: "typescript", + target: "app", + universe: begin.universe, + family: "calls", + evidence: { file: "src/main.ts", startLine: 1, startCol: 1 }, + reason: "dynamic", + candidates: ["src/main.ts#target:function"], + }, + { + provider: hello.provider, + language: "typescript", + target: "app", + universe: begin.universe, + family: "calls", + evidence: { file: "src/main.ts", startLine: 2, startCol: 1 }, + reason: "reflection", + }, + ], + sources: [], + }; + const source: GraphSnapshotProtocol.IShard = { + key: "source", + target: "app", + languages: ["typescript"], + nodes: [ + { + id: "src/main.ts#run:function", + kind: "function", + language: "typescript", + name: options.nodeName ?? "run", + file: "src/main.ts", + external: false, + }, + ], + edges: [], + diagnostics: [], + coverage: [], + unresolved: [], + sources: [ + { + file: path.resolve("src/main.ts"), + checkerDigest: digest("c"), + diskDigest: digest("c"), + }, + ], + }; + const upserts: GraphSnapshotProtocol.IUpsertShard[] = + options.baseGeneration === undefined + ? [upsertOf(coverage), ...(options.deleteSource === true ? [] : [upsertOf(source)])] + : options.deleteSource === true + ? [] + : [upsertOf(source)]; + const middle: GraphSnapshotProtocol.Frame[] = [ + ...upserts, + ...(options.deleteSource === true + ? [{ type: "deleteShard" as const, key: "source" }] + : []), + ]; + const retained = new Map(); + retained.set("coverage", coverage); + if (options.deleteSource !== true) retained.set("source", source); + const manifest = [...retained] + .sort(([left], [right]) => Number(left > right) - Number(left < right)) + .map(([key, shard]) => ({ + key, + digest: GraphSnapshotProtocol.shardDigest(shard), + })); + const snapshot = snapshotOf(hello, begin, [...retained.values()]); + return [ + hello, + begin, + ...middle, + { + type: "commit", + generation, + shards: manifest, + factDigest: GraphSnapshotProtocol.factDigest(snapshot), + }, + ]; +} + +function snapshotOf( + hello: GraphSnapshotProtocol.IHello, + begin: GraphSnapshotProtocol.IBegin, + shards: readonly GraphSnapshotProtocol.IShard[], +): Pick< + IBulkGraphSession.ISnapshot, + | "languages" + | "nodes" + | "edges" + | "diagnostics" + | "coverage" + | "unresolved" + | "provenance" +> { + return { + languages: [...hello.languages], + nodes: shards.flatMap((shard) => shard.nodes), + edges: shards.flatMap((shard) => shard.edges), + diagnostics: shards.flatMap((shard) => shard.diagnostics), + coverage: shards.flatMap((shard) => shard.coverage), + unresolved: shards.flatMap((shard) => shard.unresolved), + provenance: { + provider: hello.provider, + authority: hello.authority, + facts: [...hello.supportedFacts], + schemaVersion: hello.schemaVersion, + tool: hello.producer, + toolVersion: hello.producerVersion, + compilerVersion: hello.compilerVersion, + protocolVersion: hello.protocolVersion, + universe: begin.universe, + capabilities: [...hello.capabilities], + }, + }; +} + +function validHello(): GraphSnapshotProtocol.IHello { + return { + type: "hello", + protocolVersion: 1, + schemaVersion: GraphSnapshotProtocol.SCHEMA_VERSION, + provider: "fixture-compiler", + producer: "fixture-exporter", + producerVersion: "1.0.0", + compilerVersion: "fixture-1", + languages: ["typescript"], + authority: "compiler", + supportedFacts: ["calls"], + capabilities: [ + "universe", + "sourceDigests", + "diskDigests", + "shards", + "deltas", + ], + }; +} + +function upsertOf( + shard: GraphSnapshotProtocol.IShard, +): GraphSnapshotProtocol.IUpsertShard { + return { + type: "upsertShard", + digest: GraphSnapshotProtocol.shardDigest(shard), + shard, + }; +} + +function mutate( + frames: GraphSnapshotProtocol.Frame[], + operation: (frames: GraphSnapshotProtocol.Frame[]) => void, +): GraphSnapshotProtocol.Frame[] { + const cloned = structuredClone(frames); + operation(cloned); + return cloned; +} + +function refreshDigests(frames: GraphSnapshotProtocol.Frame[]): void { + const hello = frames[0] as GraphSnapshotProtocol.IHello; + const begin = frames[1] as GraphSnapshotProtocol.IBegin; + const shards = frames + .filter( + (frame): frame is GraphSnapshotProtocol.IUpsertShard => + frame.type === "upsertShard", + ) + .map((frame) => { + frame.digest = GraphSnapshotProtocol.shardDigest(frame.shard); + return frame.shard; + }); + const last = commit(frames); + last.shards = shards + .map((shard) => ({ + key: shard.key, + digest: GraphSnapshotProtocol.shardDigest(shard), + })) + .sort((left, right) => Number(left.key > right.key) - Number(left.key < right.key)); + last.factDigest = GraphSnapshotProtocol.factDigest( + snapshotOf(hello, begin, shards), + ); +} + +function commit( + frames: GraphSnapshotProtocol.Frame[], +): GraphSnapshotProtocol.ICommit { + return frames.at(-1) as GraphSnapshotProtocol.ICommit; +} + +function coverageShard( + frames: GraphSnapshotProtocol.Frame[], +): GraphSnapshotProtocol.IUpsertShard { + return upsert(frames, "coverage"); +} + +function upsert( + frames: GraphSnapshotProtocol.Frame[], + key: string, +): GraphSnapshotProtocol.IUpsertShard { + return frames.find( + (frame): frame is GraphSnapshotProtocol.IUpsertShard => + frame.type === "upsertShard" && frame.shard.key === key, + )!; +} + +function malformedTransactions(): Array< + [string, GraphSnapshotProtocol.Frame[]] +> { + const valid = transaction("malformed"); + return [ + ["an incomplete transaction", valid.slice(0, 2)], + [ + "a transaction not starting with hello", + [{ type: "deleteShard", key: "x" }, ...valid.slice(1)], + ], + [ + "a transaction without begin second", + [valid[0]!, { type: "deleteShard", key: "x" }, ...valid.slice(2)], + ], + [ + "a transaction not ending in commit", + [...valid.slice(0, -1), { type: "deleteShard", key: "x" }], + ], + [ + "an unknown protocol version", + mutate(valid, (frames) => { + record(frames[0]!).protocolVersion = 2; + }), + ], + [ + "an unknown schema version", + mutate(valid, (frames) => { + record(frames[0]!).schemaVersion = 2; + }), + ], + [ + "duplicate hello languages", + mutate(valid, (frames) => { + record(frames[0]!).languages = ["typescript", "typescript"]; + }), + ], + [ + "an empty hello language set", + mutate(valid, (frames) => { + record(frames[0]!).languages = []; + }), + ], + [ + "an unknown hello language", + mutate(valid, (frames) => { + record(frames[0]!).languages = ["future-language"]; + }), + ], + [ + "duplicate advertised facts", + mutate(valid, (frames) => { + record(frames[0]!).supportedFacts = ["calls", "calls"]; + }), + ], + [ + "an unknown advertised fact", + mutate(valid, (frames) => { + record(frames[0]!).supportedFacts = ["future-fact"]; + }), + ], + [ + "an unknown provider authority", + mutate(valid, (frames) => { + record(frames[0]!).authority = "future-authority"; + }), + ], + [ + "duplicate advertised capabilities", + mutate(valid, (frames) => { + record(frames[0]!).capabilities = ["universe", "universe"]; + }), + ], + [ + "an empty advertised capability", + mutate(valid, (frames) => { + record(frames[0]!).capabilities = ["universe", ""]; + }), + ], + [ + "an empty provider identity", + mutate(valid, (frames) => { + record(frames[0]!).provider = ""; + }), + ], + [ + "a NUL producer identity", + mutate(valid, (frames) => { + record(frames[0]!).producer = "bad\0producer"; + }), + ], + [ + "an empty base generation", + mutate(valid, (frames) => { + record(frames[1]!).baseGeneration = ""; + }), + ], + [ + "a malformed universe digest", + mutate(valid, (frames) => { + record(frames[1]!).universe = "bad"; + }), + ], + [ + "a malformed manifest digest", + mutate(valid, (frames) => { + record(frames[1]!).manifest = "bad"; + }), + ], + [ + "duplicate targets", + mutate(valid, (frames) => { + record(frames[1]!).targets = ["app", "app"]; + }), + ], + [ + "an empty target set", + mutate(valid, (frames) => { + record(frames[1]!).targets = []; + }), + ], + [ + "an empty target identity", + mutate(valid, (frames) => { + record(frames[1]!).targets = [""]; + }), + ], + [ + "a mismatched commit generation", + mutate(valid, (frames) => { + commit(frames).generation = "other"; + }), + ], + [ + "an unknown target", + mutate(valid, (frames) => { + coverageShard(frames).shard.target = "other"; + refreshDigests(frames); + }), + ], + [ + "an empty shard language set", + mutate(valid, (frames) => { + coverageShard(frames).shard.languages = []; + refreshDigests(frames); + }), + ], + [ + "a foreign shard language", + mutate(valid, (frames) => { + record(coverageShard(frames).shard).languages = ["go"]; + refreshDigests(frames); + }), + ], + [ + "a duplicated node inside one shard", + mutate(valid, (frames) => { + const shard = upsert(frames, "source").shard; + shard.nodes.push(structuredClone(shard.nodes[0]!)); + refreshDigests(frames); + }), + ], + [ + "a foreign-language node", + mutate(valid, (frames) => { + record(upsert(frames, "source").shard.nodes[0]!).language = "go"; + refreshDigests(frames); + }), + ], + [ + "a duplicated edge inside one shard", + mutate(valid, (frames) => { + const shard = upsert(frames, "source").shard; + const edge = { + kind: "calls" as const, + from: shard.nodes[0]!.id, + to: shard.nodes[0]!.id, + }; + shard.edges.push(edge, { ...edge }); + refreshDigests(frames); + }), + ], + [ + "a duplicated source inside one shard", + mutate(valid, (frames) => { + const shard = upsert(frames, "source").shard; + shard.sources.push({ ...shard.sources[0]! }); + refreshDigests(frames); + }), + ], + [ + "a malformed source digest", + mutate(valid, (frames) => { + upsert(frames, "source").shard.sources[0]!.checkerDigest = "bad"; + refreshDigests(frames); + }), + ], + [ + "a relative source identity", + mutate(valid, (frames) => { + upsert(frames, "source").shard.sources[0]!.file = "src/main.ts"; + refreshDigests(frames); + }), + ], + [ + "a non-canonical bundled source identity", + mutate(valid, (frames) => { + upsert(frames, "source").shard.sources[0]!.file = + "bundled:///typescript/../lib"; + refreshDigests(frames); + }), + ], + [ + "shards disagreeing about source bytes", + mutate(valid, (frames) => { + coverageShard(frames).shard.sources.push({ + file: path.resolve("src/main.ts"), + checkerDigest: digest("d"), + diskDigest: digest("d"), + }); + refreshDigests(frames); + }), + ], + [ + "shards disagreeing only about disk bytes", + mutate(valid, (frames) => { + coverageShard(frames).shard.sources.push({ + file: path.resolve("src/main.ts"), + checkerDigest: digest("c"), + diskDigest: digest("d"), + }); + refreshDigests(frames); + }), + ], + [ + "duplicate coverage rows", + mutate(valid, (frames) => { + const shard = coverageShard(frames).shard; + shard.coverage.push({ ...shard.coverage[0]! }); + refreshDigests(frames); + }), + ], + [ + "an unknown coverage family", + mutate(valid, (frames) => { + record(coverageShard(frames).shard.coverage[0]!).family = + "future-fact"; + refreshDigests(frames); + }), + ], + [ + "an unknown coverage state", + mutate(valid, (frames) => { + record(coverageShard(frames).shard.coverage[0]!).state = "unknown"; + refreshDigests(frames); + }), + ], + [ + "an unadvertised partial family", + mutate(valid, (frames) => { + coverageShard(frames).shard.coverage.find( + (row) => row.family === "contains", + )!.state = "partial"; + refreshDigests(frames); + }), + ], + [ + "duplicate unresolved sites", + mutate(valid, (frames) => { + const shard = coverageShard(frames).shard; + shard.unresolved.push(structuredClone(shard.unresolved[0]!)); + refreshDigests(frames); + }), + ], + [ + "an unknown unresolved reason", + mutate(valid, (frames) => { + record(coverageShard(frames).shard.unresolved[0]!).reason = "unknown"; + refreshDigests(frames); + }), + ], + [ + "duplicate unresolved candidates", + mutate(valid, (frames) => { + coverageShard(frames).shard.unresolved[0]!.candidates = [ + "candidate", + "candidate", + ]; + refreshDigests(frames); + }), + ], + [ + "a node duplicated across shards", + mutate(valid, (frames) => { + coverageShard(frames).shard.nodes.push( + structuredClone(upsert(frames, "source").shard.nodes[0]!), + ); + refreshDigests(frames); + }), + ], + [ + "an edge duplicated across shards", + mutate(valid, (frames) => { + const source = upsert(frames, "source").shard; + const edge = { + kind: "calls" as const, + from: source.nodes[0]!.id, + to: source.nodes[0]!.id, + }; + source.edges.push(edge); + coverageShard(frames).shard.edges.push({ ...edge }); + refreshDigests(frames); + }), + ], + [ + "an edge with an absent endpoint", + mutate(valid, (frames) => { + const source = upsert(frames, "source").shard; + source.edges.push({ + kind: "calls", + from: source.nodes[0]!.id, + to: "missing", + }); + refreshDigests(frames); + }), + ], + [ + "an edge with an absent source endpoint", + mutate(valid, (frames) => { + const source = upsert(frames, "source").shard; + source.edges.push({ + kind: "calls", + from: "missing", + to: source.nodes[0]!.id, + }); + refreshDigests(frames); + }), + ], + [ + "a duplicate shard delta", + mutate(valid, (frames) => { + frames.splice(3, 0, structuredClone(frames[2]!)); + }), + ], + [ + "an unexpected middle frame", + mutate(valid, (frames) => { + frames.splice(2, 0, structuredClone(frames[0]!)); + }), + ], + ]; +} + +function cloneSnapshot( + snapshot: IBulkGraphSession.ISnapshot, +): IBulkGraphSession.ISnapshot { + const { + sources: _sources, + ...plain + } = snapshot; + return { + ...structuredClone(plain), + sources: new Map( + [...snapshot.sources].map(([file, value]) => [file, { ...value }]), + ), + }; +} + +function invalidProtocolSnapshots(): Array< + [string, string, (snapshot: IBulkGraphSession.ISnapshot) => void] +> { + return [ + [ + "an unknown committed protocol version", + "invalid protocol generation", + (snapshot) => { + snapshot.protocol!.version = 2; + }, + ], + [ + "an empty committed generation", + "invalid protocol generation", + (snapshot) => { + snapshot.protocol!.generation = ""; + }, + ], + [ + "an empty committed target set", + "invalid protocol generation", + (snapshot) => { + snapshot.protocol!.targets = []; + }, + ], + [ + "duplicate committed targets", + "invalid protocol generation", + (snapshot) => { + snapshot.protocol!.targets.push(snapshot.protocol!.targets[0]!); + }, + ], + [ + "a malformed committed manifest digest", + "invalid protocol generation", + (snapshot) => { + snapshot.protocol!.manifest = "bad"; + }, + ], + [ + "a malformed committed fact digest", + "invalid protocol generation", + (snapshot) => { + snapshot.protocol!.factDigest = "bad"; + }, + ], + [ + "missing committed coverage", + "invalid protocol generation", + (snapshot) => { + snapshot.coverage = undefined; + snapshot.unresolved = []; + }, + ], + [ + "missing committed uncertainty", + "invalid protocol generation", + (snapshot) => { + snapshot.unresolved = undefined; + }, + ], + [ + "unresolved evidence absent from the source manifest", + "without binding that file to its source manifest", + (snapshot) => { + snapshot.unresolved![0]!.evidence.file = "src/missing.ts"; + }, + ], + [ + "an empty committed shard key", + "invalid protocol shard manifest", + (snapshot) => { + snapshot.protocol!.shards[0]!.key = ""; + }, + ], + [ + "duplicate committed shard keys", + "invalid protocol shard manifest", + (snapshot) => { + snapshot.protocol!.shards[1]!.key = + snapshot.protocol!.shards[0]!.key; + }, + ], + [ + "a malformed committed shard digest", + "invalid protocol shard manifest", + (snapshot) => { + snapshot.protocol!.shards[0]!.digest = "bad"; + }, + ], + [ + "a mismatched committed fact digest", + "mismatched protocol fact digest", + (snapshot) => { + snapshot.protocol!.factDigest = digest("f"); + }, + ], + ]; +} + +async function rejectedWithoutMovement( + store: GraphSnapshotProtocol.Store, + frames: readonly GraphSnapshotProtocol.Frame[], + label: string, + signal?: AbortSignal, +): Promise { + const before = store.current; + await TestValidator.error(`${label} rejects`, () => + store.apply(frames, { signal }), + ); + TestValidator.predicate(`${label} retains the prior generation`, store.current === before); +} + +function record(value: object): Record { + return value as Record; +} diff --git a/tests/test-graph/src/features/test_mcp_results_preserve_graph_coverage_and_uncertainty.ts b/tests/test-graph/src/features/test_mcp_results_preserve_graph_coverage_and_uncertainty.ts new file mode 100644 index 00000000..f59863b0 --- /dev/null +++ b/tests/test-graph/src/features/test_mcp_results_preserve_graph_coverage_and_uncertainty.ts @@ -0,0 +1,156 @@ +import { TestValidator } from "@nestia/e2e"; +import { + GRAPH_EDGE_KINDS, + ISamchonGraphDump, + SamchonGraphApplication, + SamchonGraphMemory, +} from "@samchon/graph"; +import path from "node:path"; + +/** Provenance, completeness and uncertainty survive dump, memory and MCP. */ +export const test_mcp_results_preserve_graph_coverage_and_uncertainty = + async () => { + const dump = fixture(); + const memory = SamchonGraphMemory.from(dump); + TestValidator.equals( + "memory retains the exact public trust planes", + [ + memory.provenance[0]?.universe, + memory.coverage.length, + memory.unresolved[0]?.reason, + ], + ["a".repeat(64), GRAPH_EDGE_KINDS.length, "dynamic"], + ); + + const application = new SamchonGraphApplication(memory); + const lookup = await application.inspect_code_graph({ + question: "where is run", + draft: { reason: "named symbol", type: "lookup" }, + review: "lookup is exact", + request: { type: "lookup", query: "run" }, + }); + TestValidator.equals( + "lookup returns only its relevant coverage families", + [ + lookup.provenance?.[0]?.provider, + lookup.coverage?.schemaVersion, + lookup.coverage?.families, + lookup.coverage?.rows.length, + lookup.unresolved, + ], + [ + "fixture-compiler", + 1, + ["contains", "exports", "references"], + 3, + { count: 0, reasons: [], examples: [] }, + ], + ); + + const trace = await application.inspect_code_graph({ + question: "what does run call", + draft: { reason: "dependency flow", type: "trace" }, + review: "trace is exact", + request: { type: "trace", from: "run" }, + }); + TestValidator.equals( + "trace carries all-family coverage and bounded structured uncertainty", + [ + trace.coverage?.families.length, + trace.unresolved?.count, + trace.unresolved?.reasons, + trace.unresolved?.examples[0]?.candidates, + trace.unresolved?.examples[1]?.candidates, + ], + [ + GRAPH_EDGE_KINDS.length, + 2, + [ + { reason: "dynamic", count: 1 }, + { reason: "reflection", count: 1 }, + ], + ["src/main.ts#target:function"], + undefined, + ], + ); + + const escaped = await application.inspect_code_graph({ + question: "read a body", + draft: { reason: "outside graph", type: "escape" }, + review: "escape", + request: { type: "escape", reason: "body text" }, + }); + TestValidator.equals( + "escape does not load or invent a graph trust envelope", + [escaped.provenance, escaped.coverage, escaped.unresolved], + [undefined, undefined, undefined], + ); + }; + +function fixture(): ISamchonGraphDump { + const universe = "a".repeat(64); + return { + project: path.resolve("fixture"), + languages: ["typescript"], + indexer: "lsp", + provenance: [ + { + provider: "fixture-compiler", + languages: ["typescript"], + authority: "compiler", + facts: ["calls"], + capabilities: ["universe"], + producer: { + tool: "fixture-exporter", + version: "1.0.0", + compiler: "fixture-1", + schemaVersion: 7, + protocolVersion: 1, + }, + universe, + manifest: "b".repeat(64), + content: "c".repeat(64), + }, + ], + coverage: GRAPH_EDGE_KINDS.map((family) => ({ + provider: "fixture-compiler", + language: "typescript", + target: "app", + family, + state: family === "calls" ? "partial" : "unsupported", + })), + unresolved: [ + { + provider: "fixture-compiler", + language: "typescript", + target: "app", + universe, + family: "calls", + evidence: { file: "src/main.ts", startLine: 1, startCol: 1 }, + reason: "dynamic", + candidates: ["src/main.ts#target:function"], + }, + { + provider: "fixture-compiler", + language: "typescript", + target: "app", + universe, + family: "calls", + evidence: { file: "src/main.ts", startLine: 2, startCol: 1 }, + reason: "reflection", + }, + ], + nodes: [ + { + id: "src/main.ts#run:function", + kind: "function", + language: "typescript", + name: "run", + file: "src/main.ts", + external: false, + evidence: { startLine: 1, startCol: 1 }, + }, + ], + edges: [], + }; +} diff --git a/tests/test-graph/src/features/test_mcp_server_exposes_inspect_code_graph.ts b/tests/test-graph/src/features/test_mcp_server_exposes_inspect_code_graph.ts index 3a2623fc..8dd0da1d 100644 --- a/tests/test-graph/src/features/test_mcp_server_exposes_inspect_code_graph.ts +++ b/tests/test-graph/src/features/test_mcp_server_exposes_inspect_code_graph.ts @@ -59,12 +59,12 @@ const overview = async (args: string[]) => { "the result arrives as structured content", payload !== undefined, ); - // `audit` serializes first, so what was checked precedes any fact a reader - // might second-guess; `next` says where the result leaves the question. + // `audit` serializes first, then the structured completeness evidence, + // before `next` says where the result leaves the question. TestValidator.equals( "audit leads, then where it leaves the question, then the facts", Object.keys(payload), - ["audit", "next", "result"], + ["audit", "coverage", "unresolved", "next", "result"], ); return payload; } finally { diff --git a/tests/test-graph/src/features/test_provider_commands_and_inputs_respect_project_boundaries.ts b/tests/test-graph/src/features/test_provider_commands_and_inputs_respect_project_boundaries.ts index b48f891c..98388759 100644 --- a/tests/test-graph/src/features/test_provider_commands_and_inputs_respect_project_boundaries.ts +++ b/tests/test-graph/src/features/test_provider_commands_and_inputs_respect_project_boundaries.ts @@ -321,29 +321,49 @@ export const test_provider_commands_and_inputs_respect_project_boundaries = expectedCommand(goCommand, ["--project", path.resolve(root)]), ); fs.rmSync(goCommand, { force: true }); - const bundledGo = goGraphProvider.resolve(root, process.env); - TestValidator.predicate( - "the packaged Go source sidecar runs through the available toolchain", - bundledGo !== undefined && - bundledGo.args.includes("-C") && - bundledGo.args.slice(-4).join(" ") === - `run . --project ${path.resolve(root)}`, - ); - if (bundledGo === undefined) { - throw new Error("the packaged Go source sidecar was not resolved"); - } - const sourceFlag = bundledGo.args.indexOf("-C"); - const bundledSource = bundledGo.args[sourceFlag + 1]; - if (sourceFlag < 0 || bundledSource === undefined) { - throw new Error("the packaged Go source directory was not resolved"); + + const hostGo = goGraphProvider.resolve(root, process.env); + if (hostGo !== undefined) { + TestValidator.predicate( + "an available host Go runs the packaged source sidecar", + hostGo.args.includes("-C") && + hostGo.args.slice(-4).join(" ") === + `run . --project ${path.resolve(root)}`, + ); } + + const bundledSource = path.join( + GraphPaths.graphPackageRoot, + "sidecars", + "go", + ); + const sourceGo = platformExecutable(privateBin, "go"); + writeExecutable(sourceGo); + const sourceRunner = goGraphProvider.resolve(root, { + ...emptyPath, + SAMCHON_GRAPH_GO_TOOLCHAIN: sourceGo, + }); + TestValidator.equals( + "a deterministically present Go runs the packaged source sidecar", + sourceRunner, + spawnableCommand.append(expectedCommand(sourceGo), [ + "-C", + bundledSource, + "run", + ".", + "--project", + path.resolve(root), + ]), + ); + fs.rmSync(sourceGo, { force: true }); + const bundledManifest = path.join(bundledSource, "go.mod"); const hiddenManifest = `${bundledManifest}.test-hidden`; fs.renameSync(bundledManifest, hiddenManifest); try { TestValidator.equals( "a malformed package without its Go source sidecar declines cleanly", - goGraphProvider.resolve(root, process.env), + goGraphProvider.resolve(root, emptyPath), undefined, ); } finally { diff --git a/tests/test-graph/src/features/test_result_audits_before_the_facts.ts b/tests/test-graph/src/features/test_result_audits_before_the_facts.ts index c5f7485e..4f53f2ae 100644 --- a/tests/test-graph/src/features/test_result_audits_before_the_facts.ts +++ b/tests/test-graph/src/features/test_result_audits_before_the_facts.ts @@ -19,7 +19,7 @@ export const test_result_audits_before_the_facts = async () => { TestValidator.equals( "audit leads, then where it leaves the question, then the facts", Object.keys(overview), - ["audit", "next", "result"], + ["audit", "coverage", "unresolved", "next", "result"], ); TestValidator.equals("the overview is the whole answer", overview.next.action, "answer"); diff --git a/tests/test-graph/src/features/test_rust_scip_provider_preserves_cargo_and_toolchain_boundaries.ts b/tests/test-graph/src/features/test_rust_scip_provider_preserves_cargo_and_toolchain_boundaries.ts index ca5d32e2..b1420bfe 100644 --- a/tests/test-graph/src/features/test_rust_scip_provider_preserves_cargo_and_toolchain_boundaries.ts +++ b/tests/test-graph/src/features/test_rust_scip_provider_preserves_cargo_and_toolchain_boundaries.ts @@ -234,7 +234,7 @@ async function assertProviderSnapshot(root: string): Promise { [ "rust-analyzer-scip", "semantic-index", - ["contains", "references", "type_ref"], + ["contains", "references"], "rustc=fixture rustc; cargo=fixture cargo", false, "", diff --git a/tests/test-graph/src/features/test_shipped_source_does_not_leak_benchmark_corpus_names.ts b/tests/test-graph/src/features/test_shipped_source_does_not_leak_benchmark_corpus_names.ts index e4fb8551..cc15ef60 100644 --- a/tests/test-graph/src/features/test_shipped_source_does_not_leak_benchmark_corpus_names.ts +++ b/tests/test-graph/src/features/test_shipped_source_does_not_leak_benchmark_corpus_names.ts @@ -22,14 +22,20 @@ const CORPUS_NAMES = [ ] as const; export const test_shipped_source_does_not_leak_benchmark_corpus_names = () => { - const sourceRoot = path.join(GraphPaths.graphPackageRoot, "src"); + const roots = [ + path.join(GraphPaths.graphPackageRoot, "src"), + path.join(GraphPaths.graphPackageRoot, "sidecars"), + ]; const leaked: string[] = []; - for (const file of walk(sourceRoot)) { - const source = fs.readFileSync(file, "utf8").toLowerCase(); - for (const name of CORPUS_NAMES) - if (new RegExp(`\\b${name}\\b`, "u").test(source)) - leaked.push(`${path.relative(sourceRoot, file).replaceAll("\\", "/")}: ${name}`); - } + for (const root of roots) + for (const file of walk(root)) { + const source = fs.readFileSync(file, "utf8").toLowerCase(); + for (const name of CORPUS_NAMES) + if (new RegExp(`\\b${name}\\b`, "u").test(source)) + leaked.push( + `${path.relative(GraphPaths.graphPackageRoot, file).replaceAll("\\", "/")}: ${name}`, + ); + } TestValidator.equals( "the published source carries no benchmark repository names", leaked, @@ -44,5 +50,7 @@ function walk(directory: string): string[] { const file = path.join(directory, entry.name); return entry.isDirectory() ? walk(file) : [file]; }) - .filter((file) => /\.(?:ts|js|mjs|cjs|json|html)$/u.test(file)); + .filter((file) => + /\.(?:ts|js|mjs|cjs|json|html|go|lua|mod|sum)$/u.test(file), + ); } diff --git a/tests/test-graph/src/features/test_ttscgraph_native_requests_recover_from_stalls.ts b/tests/test-graph/src/features/test_ttscgraph_native_requests_recover_from_stalls.ts index 3da9073c..4d7851c0 100644 --- a/tests/test-graph/src/features/test_ttscgraph_native_requests_recover_from_stalls.ts +++ b/tests/test-graph/src/features/test_ttscgraph_native_requests_recover_from_stalls.ts @@ -396,7 +396,7 @@ const delay = (milliseconds: number): Promise => const waitForFile = async (file: string): Promise => { const deadline = Date.now() + 5_000; - while (!fs.existsSync(file)) { + while (!hasContents(file)) { if (Date.now() >= deadline) { throw new Error(`timed out waiting for ${file}`); } @@ -404,6 +404,14 @@ const waitForFile = async (file: string): Promise => { } }; +function hasContents(file: string): boolean { + try { + return fs.statSync(file).size > 0; + } catch { + return false; + } +} + const isProcessAlive = (pid: number): boolean => { try { process.kill(pid, 0); diff --git a/tests/test-graph/src/internal/ContractParity.ts b/tests/test-graph/src/internal/ContractParity.ts index e309be64..6b600ff0 100644 --- a/tests/test-graph/src/internal/ContractParity.ts +++ b/tests/test-graph/src/internal/ContractParity.ts @@ -424,6 +424,26 @@ export namespace ContractParity { // no such authority — they only trim or reword an unchanged meaning; each says // so in its reason rather than borrowing an authority it does not have. Application: [ + { + reason: + "#63 adds the operation-scoped coverage summary and provider-universe provenance to the public application output, so the application imports their public structures.", + from: + 'import { ISamchonGraphOverview } from "./ISamchonGraphOverview";', + to: [ + 'import { ISamchonGraphOverview } from "./ISamchonGraphOverview";', + 'import { ISamchonGraphCoverageSummary } from "./ISamchonGraphCoverageSummary";', + 'import { ISamchonGraphDump } from "./ISamchonGraphDump";', + ].join("\n"), + }, + { + reason: + "#63 adds the bounded unresolved summary to the same versioned application output.", + from: 'import { ISamchonGraphTrace } from "./ISamchonGraphTrace";', + to: [ + 'import { ISamchonGraphTrace } from "./ISamchonGraphTrace";', + 'import { ISamchonGraphUnresolvedSummary } from "./ISamchonGraphUnresolvedSummary";', + ].join("\n"), + }, { reason: "The compiler resolves a fact and verifies it; the index checks it. The same guarantee, named for the authority that gives it.", @@ -520,6 +540,46 @@ export namespace ContractParity { from: "For the ranked operations (`lookup`, `entrypoints`, `tour`) it adds that the selection is heuristic — matched, scored, ranked, and limited against the question — so the facts are verified but the shortlist's coverage is the caller's to judge.", to: "For ranked operations (`lookup`, `entrypoints`, `tour`) it additionally says that selection was matched, scored, ranked, and limited against the question, so the facts are checked but shortlist coverage is yours to judge.", }, + { + reason: + "#63 replaces the compiler-completeness overclaim with the exact contract: returned facts are proved, while coverage and uncertainty say whether missing facts are meaningful.", + layer: "prose", + from: + "The graph holds every symbol, call, type, decorator and test, each with its file and line, resolved from the source on disk now. Submit exactly one request:", + to: + "The graph returns proved indexed facts plus structured coverage and uncertainty. Submit exactly one request:", + }, + { + reason: + "#63 version 1 adds provider/universe identity plus operation-scoped coverage and unresolved summaries beside `audit`; optionality preserves escape and legacy dump compatibility.", + from: "audit: string;", + to: [ + "audit: string;", + "provenance?: ISamchonGraphDump.IProvenance[];", + "coverage?: ISamchonGraphCoverageSummary;", + "unresolved?: ISamchonGraphUnresolvedSummary;", + ].join("\n"), + }, + { + reason: + "The structure rule above adds the versioned trust fields; this prose rule records their exact public semantics without hiding them behind the English audit.", + layer: "prose", + from: [ + "audit: string;", + "provenance?: ISamchonGraphDump.IProvenance[];", + "coverage?: ISamchonGraphCoverageSummary;", + "unresolved?: ISamchonGraphUnresolvedSummary;", + ].join("\n"), + to: [ + "audit: string;", + "/** Strict producer, authority, compiler and build-universe identity for the synchronized graph. Absent only for `escape` or a legacy/fallback-only dump with no strict producer. */", + "provenance?: ISamchonGraphDump.IProvenance[];", + "/** Machine-readable completeness for the relationship families relevant to this operation. Absent only for `escape`. */", + "coverage?: ISamchonGraphCoverageSummary;", + "/** Bounded structured uncertainty for the same operation-scoped families. Absent only for `escape`. */", + "unresolved?: ISamchonGraphUnresolvedSummary;", + ].join("\n"), + }, ], Details: [ { @@ -837,6 +897,51 @@ export namespace ContractParity { from: "/** Expression span; its file is the one embedded in `from`. */", to: "/** Expression span; its file is the source node's declaration file. */", }, + { + reason: + "#63 makes normalized completeness part of the public dump contract.", + from: 'import { ISamchonGraphEdge } from "./ISamchonGraphEdge";', + to: [ + 'import { ISamchonGraphEdge } from "./ISamchonGraphEdge";', + 'import { ISamchonGraphCoverage } from "./ISamchonGraphCoverage";', + ].join("\n"), + }, + { + reason: + "#63 preserves structured unresolved sites in the public dump.", + from: 'import { ISamchonGraphSpan } from "./ISamchonGraphSpan";', + to: [ + 'import { ISamchonGraphSpan } from "./ISamchonGraphSpan";', + 'import { ISamchonGraphUnresolved } from "./ISamchonGraphUnresolved";', + ].join("\n"), + }, + { + reason: + "#63 adds additive optional coverage and unresolved fields after provider provenance so older dumps remain loadable during protocol migration.", + from: "provenance?: ISamchonGraphDump.IProvenance[];", + to: [ + "provenance?: ISamchonGraphDump.IProvenance[];", + "coverage?: ISamchonGraphCoverage[];", + "unresolved?: ISamchonGraphUnresolved[];", + ].join("\n"), + }, + { + reason: + "The structure rule above adds dump trust fields; their prose distinguishes migration absence from explicit empty uncertainty.", + layer: "prose", + from: [ + "provenance?: ISamchonGraphDump.IProvenance[];", + "coverage?: ISamchonGraphCoverage[];", + "unresolved?: ISamchonGraphUnresolved[];", + ].join("\n"), + to: [ + "provenance?: ISamchonGraphDump.IProvenance[];", + "/** Exhaustive per-provider, language, target and relationship-family completeness rows. Absent only on dumps written before protocol version 1. */", + "coverage?: ISamchonGraphCoverage[];", + "/** Structured relationship sites that a producer could not resolve exactly. An empty list is meaningful only together with exhaustive coverage. */", + "unresolved?: ISamchonGraphUnresolved[];", + ].join("\n"), + }, ], Edge: [ { From 5db5830ea7340588d06babf687ec5dbc25fd54ba Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Thu, 30 Jul 2026 23:39:56 +0900 Subject: [PATCH 03/52] fix: enforce snapshot publication trust boundaries --- packages/graph/src/indexer/parseGraphDump.ts | 22 +- packages/graph/src/operations/graphTrust.ts | 9 +- .../src/provider/GraphSnapshotProtocol.ts | 35 ++- .../provider/assertGraphSnapshotContract.ts | 131 +---------- .../provider/assertGraphSnapshotPayload.ts | 174 ++++++++++++++ .../src/structures/ISamchonGraphCoverage.ts | 3 +- .../ISamchonGraphUnresolvedSummary.ts | 10 +- ...rser_closes_every_public_trust_boundary.ts | 34 +++ ...otocol_commits_atomic_shard_generations.ts | 216 +++++++++++++++--- ...preserve_graph_coverage_and_uncertainty.ts | 49 +++- ...s_and_inputs_respect_project_boundaries.ts | 7 +- 11 files changed, 509 insertions(+), 181 deletions(-) create mode 100644 packages/graph/src/provider/assertGraphSnapshotPayload.ts diff --git a/packages/graph/src/indexer/parseGraphDump.ts b/packages/graph/src/indexer/parseGraphDump.ts index b318c749..6ba66a9f 100644 --- a/packages/graph/src/indexer/parseGraphDump.ts +++ b/packages/graph/src/indexer/parseGraphDump.ts @@ -26,6 +26,18 @@ export function parseGraphDump(input: unknown): ISamchonGraphDump { const files = new Set(); const dumpLanguages = new Set(dump.languages); for (const node of dump.nodes) { + if ( + node.id === "" || + node.id.includes("\0") || + node.name === "" || + node.name.includes("\0") || + node.qualifiedName === "" || + node.qualifiedName?.includes("\0") === true + ) { + throw new Error( + "@samchon/graph: node identity and display names must be non-empty and NUL-free", + ); + } if (node.file === "") { if (!node.external || node.kind !== "external_symbol") { throw new Error( @@ -105,9 +117,13 @@ export function parseGraphDump(input: unknown): ISamchonGraphDump { const providers = new Set(); for (const row of dump.provenance ?? []) { - if (row.provider === "" || providers.has(row.provider)) { + if ( + row.provider === "" || + row.provider.includes("\0") || + providers.has(row.provider) + ) { throw new Error( - `@samchon/graph: duplicate or empty provenance provider: ${row.provider}`, + `@samchon/graph: duplicate, empty, or NUL-delimited provenance provider: ${row.provider}`, ); } providers.add(row.provider); @@ -312,6 +328,7 @@ function validateGraphPath(file: string, label: string): void { const relative = file.slice("bundled:///".length); if ( relative === "" || + relative.includes("\0") || relative.includes("\\") || path.posix.normalize(relative) !== relative || relative.split("/").some((part) => part === "" || part === "." || part === "..") @@ -323,6 +340,7 @@ function validateGraphPath(file: string, label: string): void { const parts = file.split("/"); if ( file === "" || + file.includes("\0") || file.includes("\\") || /^[A-Za-z]:\//.test(file) || path.posix.isAbsolute(file) || diff --git a/packages/graph/src/operations/graphTrust.ts b/packages/graph/src/operations/graphTrust.ts index 42a1b1d7..52db00cd 100644 --- a/packages/graph/src/operations/graphTrust.ts +++ b/packages/graph/src/operations/graphTrust.ts @@ -5,6 +5,11 @@ import { ISamchonGraphUnresolvedSummary, } from "../structures"; import { GRAPH_EDGE_KINDS, GraphEdgeKind } from "../typings"; +import { isStructural } from "./isStructural"; + +const LOOKUP_FAMILIES = GRAPH_EDGE_KINDS.filter( + (family) => family === "exports" || !isStructural(family), +); /** Structured trust envelope for one non-escape operation. */ export function graphTrust( @@ -62,11 +67,9 @@ function familiesOf( ): GraphEdgeKind[] { switch (type) { case "entrypoints": - return ["contains", "exports", "calls", "tests"]; case "lookup": - return ["contains", "exports", "references"]; + return [...LOOKUP_FAMILIES]; case "overview": - return ["contains", "exports", "imports"]; case "trace": case "details": case "tour": diff --git a/packages/graph/src/provider/GraphSnapshotProtocol.ts b/packages/graph/src/provider/GraphSnapshotProtocol.ts index 7758e849..3912476b 100644 --- a/packages/graph/src/provider/GraphSnapshotProtocol.ts +++ b/packages/graph/src/provider/GraphSnapshotProtocol.ts @@ -16,6 +16,7 @@ import { } from "../typings"; import { freezeDeep } from "../utils/freezeDeep"; import { sealedMap } from "../utils/sealedMap"; +import { assertGraphSnapshotPayload } from "./assertGraphSnapshotPayload"; import { IBulkGraphSession } from "./IBulkGraphSession"; /** @@ -230,8 +231,15 @@ export namespace GraphSnapshotProtocol { export class Store { private committed = new Map(); private identity: IHello | undefined; + private readonly generations = new Set(); + private readonly root: string; private snapshot: IBulkGraphSession.ISnapshot | undefined; + /** Project root used to bind relative fact evidence to source digests. */ + public constructor(root: string) { + this.root = path.resolve(root); + } + public get current(): IBulkGraphSession.ISnapshot | undefined { return this.snapshot; } @@ -261,6 +269,9 @@ export namespace GraphSnapshotProtocol { if (commit.generation !== begin.generation) { throw new Error("graph snapshot protocol: commit generation does not match begin"); } + if (this.generations.has(begin.generation)) { + throw new Error("graph snapshot protocol: generation token was reused"); + } const priorGeneration = this.snapshot?.protocol?.generation; if ( begin.baseGeneration !== undefined && @@ -322,6 +333,16 @@ export namespace GraphSnapshotProtocol { ); } } + if ( + begin.baseGeneration !== undefined && + this.snapshot !== undefined && + begin.manifest !== this.snapshot.protocol!.manifest && + changed.size === 0 + ) { + throw new Error( + "graph snapshot protocol: manifest movement reported no shard delta", + ); + } if ( begin.baseGeneration !== undefined && this.snapshot !== undefined && @@ -345,15 +366,21 @@ export namespace GraphSnapshotProtocol { throw new Error("graph snapshot protocol: commit shard manifest mismatch"); } const assembled = assemble(hello, begin, commit, expectedManifest, next); - assertAssembledFacts(assembled); + assertAssembledFacts(assembled, hello); if (factDigest(assembled) !== commit.factDigest) { throw new Error("graph snapshot protocol: commit fact digest mismatch"); } assertCompleteCoverage(assembled, hello, begin); + assertGraphSnapshotPayload( + assembled, + this.root, + `graph snapshot protocol: provider "${hello.provider}"`, + ); throwIfAborted(options.signal); freezeDeep(assembled, "the graph snapshot protocol generation"); this.committed = next; this.identity = clone(hello); + this.generations.add(begin.generation); this.snapshot = assembled; return assembled; } @@ -641,6 +668,7 @@ export namespace GraphSnapshotProtocol { function assertAssembledFacts( snapshot: IBulkGraphSession.ISnapshot, + hello: IHello, ): void { const nodeIds = new Set(); const files = new Set(snapshot.sources.keys()); @@ -655,6 +683,11 @@ export namespace GraphSnapshotProtocol { } const edgeKeys = new Set(); for (const edge of snapshot.edges) { + if (!hello.supportedFacts.includes(edge.kind)) { + throw new Error( + `graph snapshot protocol: assembled edge uses unadvertised family ${String(edge.kind)}`, + ); + } const key = `${edge.kind}\0${edge.from}\0${edge.to}`; if (edgeKeys.has(key)) { throw new Error( diff --git a/packages/graph/src/provider/assertGraphSnapshotContract.ts b/packages/graph/src/provider/assertGraphSnapshotContract.ts index 03f0921e..63b3f582 100644 --- a/packages/graph/src/provider/assertGraphSnapshotContract.ts +++ b/packages/graph/src/provider/assertGraphSnapshotContract.ts @@ -1,8 +1,7 @@ import path from "node:path"; -import { parseGraphDump } from "../indexer/parseGraphDump"; import { GraphLanguage } from "../typings"; -import { dumpProvenanceOf } from "./dumpProvenanceOf"; +import { assertGraphSnapshotPayload } from "./assertGraphSnapshotPayload"; import { GraphSnapshotProtocol } from "./GraphSnapshotProtocol"; import { IBulkGraphSession } from "./IBulkGraphSession"; import { IGraphProvider } from "./IGraphProvider"; @@ -31,23 +30,7 @@ export function assertGraphSnapshotContract( ): void { const label = `@samchon/graph: provider "${provider.name}"`; const project = path.resolve(root); - assertProvenance(snapshot, label); - parseGraphDump({ - project, - languages: snapshot.languages, - indexer: "lsp", - nodes: snapshot.nodes, - edges: snapshot.edges, - diagnostics: snapshot.diagnostics, - warnings: snapshot.warnings, - provenance: [dumpProvenanceOf(snapshot)], - ...(snapshot.coverage !== undefined - ? { coverage: snapshot.coverage } - : {}), - ...(snapshot.unresolved !== undefined - ? { unresolved: snapshot.unresolved } - : {}), - }); + assertGraphSnapshotPayload(snapshot, project, label); const claimed = new Set(languages); for (const language of snapshot.languages) { if (!claimed.has(language)) { @@ -101,7 +84,6 @@ export function assertGraphSnapshotContract( ); } - assertSourceManifest(snapshot, project, label, files); assertProtocol(snapshot, label); } @@ -139,115 +121,6 @@ function assertProtocol( } } -function assertSourceManifest( - snapshot: IBulkGraphSession.ISnapshot, - root: string, - label: string, - nodeFiles: ReadonlySet, -): void { - for (const file of snapshot.sources.keys()) { - if (file.startsWith("bundled:///")) { - const relative = file.slice("bundled:///".length); - if ( - relative === "" || - relative.includes("\\") || - path.posix.normalize(relative) !== relative || - relative - .split("/") - .some((part) => part === "" || part === "." || part === "..") - ) { - throw new Error( - `${label} published a non-canonical bundled source identity: ${file}`, - ); - } - } else if (!path.isAbsolute(file) || path.normalize(file) !== file) { - throw new Error( - `${label} published a source identity that is not normalized and absolute: ${file}`, - ); - } - } - - const required = new Set(); - for (const file of nodeFiles) requireHostSource(required, file); - for (const node of snapshot.nodes) { - if (node.evidence?.file !== undefined) { - requireHostSource(required, node.evidence.file); - } - if (node.implementation?.file !== undefined) { - requireHostSource(required, node.implementation.file); - } - } - for (const edge of snapshot.edges) { - if (edge.evidence?.file !== undefined) { - requireHostSource(required, edge.evidence.file); - } - } - for (const diagnostic of snapshot.diagnostics) { - if (diagnostic.file !== "") requireHostSource(required, diagnostic.file); - } - for (const unresolved of snapshot.unresolved ?? []) { - requireHostSource(required, unresolved.evidence.file); - } - - for (const file of required) { - const source = path.resolve(root, file); - if (!snapshot.sources.has(source)) { - throw new Error( - `${label} published facts for ${file} without binding that file to its source manifest`, - ); - } - } -} - -function requireHostSource(required: Set, file: string): void { - // A bundled identity is versioned with its provider/toolchain and has no - // coordinator-readable host file. Requiring it in the host source manifest - // rejects valid compiler builtins (Go universe nodes, TypeScript lib files) - // without adding a byte fence the coordinator could reproduce. - if (!file.startsWith("bundled:///")) required.add(file); -} - -function assertProvenance( - snapshot: IBulkGraphSession.ISnapshot, - label: string, -): void { - const provenance = snapshot.provenance; - if ( - !Number.isSafeInteger(provenance.schemaVersion) || - provenance.schemaVersion < 1 || - !Number.isSafeInteger(provenance.protocolVersion) || - provenance.protocolVersion < 0 || - provenance.tool === "" || - !SHA256.test(provenance.universe) - ) { - throw new Error(`${label} published an invalid provenance envelope`); - } - const capabilities = new Set(provenance.capabilities); - if ( - capabilities.size !== provenance.capabilities.length || - provenance.capabilities.some((capability) => capability === "") || - !capabilities.has("universe") - ) { - throw new Error( - `${label} published duplicate, empty, or unproven provenance capabilities`, - ); - } - const sourceDigests = capabilities.has("sourceDigests"); - const diskDigests = capabilities.has("diskDigests"); - for (const [file, digest] of snapshot.sources) { - if ( - (sourceDigests && !SHA256.test(digest.checkerDigest)) || - (!sourceDigests && digest.checkerDigest !== "") || - (digest.diskDigest !== "" && - (!diskDigests || !SHA256.test(digest.diskDigest))) - ) { - throw new Error( - `${label} published a source digest that contradicts its capabilities: ${file}`, - ); - } - } -} - const SHA256 = /^[0-9a-f]{64}$/; function sameFacts( diff --git a/packages/graph/src/provider/assertGraphSnapshotPayload.ts b/packages/graph/src/provider/assertGraphSnapshotPayload.ts new file mode 100644 index 00000000..68d0bf91 --- /dev/null +++ b/packages/graph/src/provider/assertGraphSnapshotPayload.ts @@ -0,0 +1,174 @@ +import path from "node:path"; + +import { parseGraphDump } from "../indexer/parseGraphDump"; +import { graphSnapshotDigests } from "./graphSnapshotDigests"; +import { IBulkGraphSession } from "./IBulkGraphSession"; + +/** + * Validate the complete semantic payload shared by protocol and legacy + * snapshot publication boundaries. + */ +export function assertGraphSnapshotPayload( + snapshot: IBulkGraphSession.ISnapshot, + root: string, + label: string, +): void { + const project = path.resolve(root); + assertProvenance(snapshot, label); + const provenance = snapshot.provenance; + parseGraphDump({ + project, + languages: snapshot.languages, + indexer: "lsp", + nodes: snapshot.nodes, + edges: snapshot.edges, + diagnostics: snapshot.diagnostics, + warnings: snapshot.warnings, + provenance: [ + { + provider: provenance.provider, + languages: [...snapshot.languages], + authority: provenance.authority, + facts: [...provenance.facts], + capabilities: [...provenance.capabilities], + producer: { + tool: provenance.tool, + version: provenance.toolVersion, + compiler: provenance.compilerVersion, + schemaVersion: provenance.schemaVersion, + protocolVersion: provenance.protocolVersion, + }, + universe: provenance.universe, + manifest: graphSnapshotDigests.manifestOf(snapshot), + content: graphSnapshotDigests.contentOf(snapshot), + }, + ], + ...(snapshot.coverage !== undefined + ? { coverage: snapshot.coverage } + : {}), + ...(snapshot.unresolved !== undefined + ? { unresolved: snapshot.unresolved } + : {}), + }); + + const nodeFiles = new Set(); + for (const node of snapshot.nodes) { + if (node.file !== "") nodeFiles.add(node.file); + } + assertSourceManifest(snapshot, project, label, nodeFiles); +} + +function assertSourceManifest( + snapshot: IBulkGraphSession.ISnapshot, + root: string, + label: string, + nodeFiles: ReadonlySet, +): void { + for (const file of snapshot.sources.keys()) { + if (file.startsWith("bundled:///")) { + const relative = file.slice("bundled:///".length); + if ( + relative === "" || + relative.includes("\0") || + relative.includes("\\") || + path.posix.normalize(relative) !== relative || + relative + .split("/") + .some((part) => part === "" || part === "." || part === "..") + ) { + throw new Error( + `${label} published a non-canonical bundled source identity: ${file}`, + ); + } + } else if ( + file.includes("\0") || + !path.isAbsolute(file) || + path.normalize(file) !== file + ) { + throw new Error( + `${label} published a source identity that is not normalized and absolute: ${file}`, + ); + } + } + + const required = new Set(); + for (const file of nodeFiles) requireHostSource(required, file); + for (const node of snapshot.nodes) { + if (node.evidence?.file !== undefined) { + requireHostSource(required, node.evidence.file); + } + if (node.implementation?.file !== undefined) { + requireHostSource(required, node.implementation.file); + } + } + for (const edge of snapshot.edges) { + if (edge.evidence?.file !== undefined) { + requireHostSource(required, edge.evidence.file); + } + } + for (const diagnostic of snapshot.diagnostics) { + if (diagnostic.file !== "") requireHostSource(required, diagnostic.file); + } + for (const unresolved of snapshot.unresolved ?? []) { + requireHostSource(required, unresolved.evidence.file); + } + + for (const file of required) { + const source = path.resolve(root, file); + if (!snapshot.sources.has(source)) { + throw new Error( + `${label} published facts for ${file} without binding that file to its source manifest`, + ); + } + } +} + +function requireHostSource(required: Set, file: string): void { + // A bundled identity is versioned with its provider/toolchain and has no + // coordinator-readable host file. Requiring it in the host source manifest + // rejects valid compiler builtins without adding a reproducible byte fence. + if (!file.startsWith("bundled:///")) required.add(file); +} + +function assertProvenance( + snapshot: IBulkGraphSession.ISnapshot, + label: string, +): void { + const provenance = snapshot.provenance; + if ( + !Number.isSafeInteger(provenance.schemaVersion) || + provenance.schemaVersion < 1 || + !Number.isSafeInteger(provenance.protocolVersion) || + provenance.protocolVersion < 0 || + provenance.tool === "" || + !SHA256.test(provenance.universe) + ) { + throw new Error(`${label} published an invalid provenance envelope`); + } + const capabilities = new Set(provenance.capabilities); + if ( + capabilities.size !== provenance.capabilities.length || + provenance.capabilities.some((capability) => capability === "") || + !capabilities.has("universe") + ) { + throw new Error( + `${label} published duplicate, empty, or unproven provenance capabilities`, + ); + } + const sourceDigests = capabilities.has("sourceDigests"); + const diskDigests = capabilities.has("diskDigests"); + for (const [file, digest] of snapshot.sources) { + if ( + (sourceDigests && !SHA256.test(digest.checkerDigest)) || + (!sourceDigests && digest.checkerDigest !== "") || + (digest.diskDigest !== "" && + (!diskDigests || !SHA256.test(digest.diskDigest))) + ) { + throw new Error( + `${label} published a source digest that contradicts its capabilities: ${file}`, + ); + } + } +} + +const SHA256 = /^[0-9a-f]{64}$/; diff --git a/packages/graph/src/structures/ISamchonGraphCoverage.ts b/packages/graph/src/structures/ISamchonGraphCoverage.ts index f1f0b7aa..62be181a 100644 --- a/packages/graph/src/structures/ISamchonGraphCoverage.ts +++ b/packages/graph/src/structures/ISamchonGraphCoverage.ts @@ -29,7 +29,8 @@ export interface ISamchonGraphCoverage { /** * `complete` makes absence meaningful in the named universe; `partial` - * publishes proven facts while unresolved/excluded sites remain; + * publishes proven facts while unresolved/excluded sites remain, whether or + * not a legacy or fallback producer can enumerate their exact locations; * `unsupported` says the producer cannot prove the family. */ state: "complete" | "partial" | "unsupported"; diff --git a/packages/graph/src/structures/ISamchonGraphUnresolvedSummary.ts b/packages/graph/src/structures/ISamchonGraphUnresolvedSummary.ts index 70ebad50..02392f0a 100644 --- a/packages/graph/src/structures/ISamchonGraphUnresolvedSummary.ts +++ b/packages/graph/src/structures/ISamchonGraphUnresolvedSummary.ts @@ -2,7 +2,13 @@ import { ISamchonGraphUnresolved } from "./ISamchonGraphUnresolved"; /** Bounded, operation-scoped uncertainty returned beside MCP audit. */ export interface ISamchonGraphUnresolvedSummary { - /** Number of relevant unresolved sites in the complete resident graph. */ + /** + * Number of relevant sites the producer explicitly published. + * + * Zero does not upgrade a `partial` coverage row to `complete`: legacy and + * fallback producers may know their analysis is partial without being able + * to enumerate the exact unresolved locations. + */ count: number; /** Stable counts by machine-readable reason. */ @@ -11,6 +17,6 @@ export interface ISamchonGraphUnresolvedSummary { count: number; }[]; - /** Deterministic first slice; `count` says whether more exist. */ + /** Deterministic first slice; `count` says whether more published sites exist. */ examples: ISamchonGraphUnresolved[]; } diff --git a/tests/test-graph/src/features/test_graph_dump_parser_closes_every_public_trust_boundary.ts b/tests/test-graph/src/features/test_graph_dump_parser_closes_every_public_trust_boundary.ts index 6dbca456..cfae02cb 100644 --- a/tests/test-graph/src/features/test_graph_dump_parser_closes_every_public_trust_boundary.ts +++ b/tests/test-graph/src/features/test_graph_dump_parser_closes_every_public_trust_boundary.ts @@ -110,6 +110,24 @@ export const test_graph_dump_parser_closes_every_public_trust_boundary = await rejected("duplicate node identities", (candidate) => { candidate.nodes.push({ ...candidate.nodes[1]! }); }); + await rejected("empty node identities", (candidate) => { + candidate.nodes[1]!.id = ""; + }); + await rejected("NUL-delimited node identities", (candidate) => { + candidate.nodes[1]!.id = "src/other.go\0#Other:function"; + }); + await rejected("empty node display names", (candidate) => { + candidate.nodes[1]!.name = ""; + }); + await rejected("NUL-delimited node display names", (candidate) => { + candidate.nodes[1]!.name = "Other\0Name"; + }); + await rejected("empty qualified names", (candidate) => { + candidate.nodes[0]!.qualifiedName = ""; + }); + await rejected("NUL-delimited qualified names", (candidate) => { + candidate.nodes[0]!.qualifiedName = "example\0Run"; + }); await rejected("relative project roots", (candidate) => { candidate.project = "fixture"; }); @@ -134,6 +152,10 @@ export const test_graph_dump_parser_closes_every_public_trust_boundary = await rejected("raw absolute graph paths", (candidate) => { record(candidate.nodes[1]!).file = "C:/machine/other.go"; }); + await rejected("NUL-delimited graph paths", (candidate) => { + candidate.nodes[1]!.id = "src/other\0name.go#Other:function"; + candidate.nodes[1]!.file = "src/other\0name.go"; + }); await rejected("terminal parent graph paths", (candidate) => { record(candidate.nodes[1]!).file = "../.."; }); @@ -143,6 +165,13 @@ export const test_graph_dump_parser_closes_every_public_trust_boundary = await rejected("backslashed bundled graph paths", (candidate) => { record(candidate.nodes[1]!).file = "bundled:///go\\..\\escape"; }); + await rejected("NUL-delimited bundled graph paths", (candidate) => { + candidate.nodes[1]!.id = "bundled:///go/\0builtin"; + candidate.nodes[1]!.kind = "file"; + candidate.nodes[1]!.name = "builtin"; + candidate.nodes[1]!.file = "bundled:///go/\0builtin"; + candidate.nodes[1]!.external = true; + }); await rejected("invalid source ranges", (candidate) => { candidate.nodes[0]!.evidence!.endLine = 0; }); @@ -244,6 +273,11 @@ export const test_graph_dump_parser_closes_every_public_trust_boundary = await rejected("empty provenance provider names", (candidate) => { candidate.provenance = [{ ...validProvenance(), provider: "" }]; }); + await rejected("NUL-delimited provenance provider names", (candidate) => { + candidate.provenance = [ + { ...validProvenance(), provider: "scip\0go" }, + ]; + }); await rejected("invalid provenance producer revisions", (candidate) => { candidate.provenance = [ { diff --git a/tests/test-graph/src/features/test_graph_snapshot_protocol_commits_atomic_shard_generations.ts b/tests/test-graph/src/features/test_graph_snapshot_protocol_commits_atomic_shard_generations.ts index 70b15231..809a1b94 100644 --- a/tests/test-graph/src/features/test_graph_snapshot_protocol_commits_atomic_shard_generations.ts +++ b/tests/test-graph/src/features/test_graph_snapshot_protocol_commits_atomic_shard_generations.ts @@ -20,7 +20,7 @@ const digest = (letter: string): string => letter.repeat(64); */ export const test_graph_snapshot_protocol_commits_atomic_shard_generations = async () => { - const store = new GraphSnapshotProtocol.Store(); + const store = new GraphSnapshotProtocol.Store(process.cwd()); const initialFrames = transaction("generation-1"); const ndjson = initialFrames.map(JSON.stringify).join("\n"); const parsed = GraphSnapshotProtocol.parse(ndjson); @@ -133,6 +133,7 @@ export const test_graph_snapshot_protocol_commits_atomic_shard_generations = mutate( transaction("changed-identity", { baseGeneration: "generation-3", + coverageState: "complete", }), (frames) => { (frames[0] as GraphSnapshotProtocol.IHello).producer = "other"; @@ -148,11 +149,35 @@ export const test_graph_snapshot_protocol_commits_atomic_shard_generations = }), "deleting an absent shard", ); + const manifestWithoutDelta = mutate( + transaction("manifest-without-delta", { + baseGeneration: "generation-3", + deleteSource: true, + }), + (frames) => { + (frames[1] as GraphSnapshotProtocol.IBegin).manifest = digest("d"); + frames.splice(2, 2); + }, + ); + await rejectedWithoutMovement( + store, + manifestWithoutDelta, + "manifest movement without a shard delta", + ); + await rejectedWithoutMovement( + store, + transaction("generation-3", { + baseGeneration: "generation-3", + coverageState: "complete", + }), + "a reused generation token", + ); await rejectedWithoutMovement( store, mutate( transaction("moved-universe", { baseGeneration: "generation-3", + coverageState: "complete", }), (frames) => { (frames[1] as GraphSnapshotProtocol.IBegin).universe = digest("d"); @@ -165,6 +190,7 @@ export const test_graph_snapshot_protocol_commits_atomic_shard_generations = mutate( transaction("moved-target", { baseGeneration: "generation-3", + coverageState: "complete", }), (frames) => { (frames[1] as GraphSnapshotProtocol.IBegin).targets.push("other"); @@ -174,23 +200,41 @@ export const test_graph_snapshot_protocol_commits_atomic_shard_generations = ); await rejectedWithoutMovement( store, - mutate(transaction("bad-shard", { baseGeneration: "generation-3" }), (frames) => { - upsert(frames, "source").digest = digest("f"); - }), + mutate( + transaction("bad-shard", { + baseGeneration: "generation-3", + coverageState: "complete", + }), + (frames) => { + upsert(frames, "source").digest = digest("f"); + }, + ), "a shard digest mismatch", ); await rejectedWithoutMovement( store, - mutate(transaction("bad-facts", { baseGeneration: "generation-3" }), (frames) => { - commit(frames).factDigest = digest("f"); - }), + mutate( + transaction("bad-facts", { + baseGeneration: "generation-3", + coverageState: "complete", + }), + (frames) => { + commit(frames).factDigest = digest("f"); + }, + ), "a fact digest mismatch", ); await rejectedWithoutMovement( store, - mutate(transaction("bad-manifest", { baseGeneration: "generation-3" }), (frames) => { - commit(frames).shards.reverse(); - }), + mutate( + transaction("bad-manifest", { + baseGeneration: "generation-3", + coverageState: "complete", + }), + (frames) => { + commit(frames).shards.reverse(); + }, + ), "a non-canonical manifest", ); await rejectedWithoutMovement( @@ -258,16 +302,37 @@ export const test_graph_snapshot_protocol_commits_atomic_shard_generations = await rejectedWithoutMovement(store, frames, label); } - const deleteStore = new GraphSnapshotProtocol.Store(); + const manifestStore = new GraphSnapshotProtocol.Store(process.cwd()); + manifestStore.apply(transaction("manifest-generation-1")); + const manifestEdit = mutate( + transaction("manifest-generation-2", { + baseGeneration: "manifest-generation-1", + nodeName: "manifest-edited", + }), + (frames) => { + (frames[1] as GraphSnapshotProtocol.IBegin).manifest = digest("d"); + }, + ); + TestValidator.equals( + "manifest movement commits when a shard delta carries the affected facts", + manifestStore.apply(manifestEdit).nodes.map((node) => node.name), + ["manifest-edited"], + ); + + const deleteStore = new GraphSnapshotProtocol.Store(process.cwd()); deleteStore.apply(transaction("delete-generation-1")); const duplicateDelete = transaction("delete-generation-2", { baseGeneration: "delete-generation-1", deleteSource: true, }); + const deleteFrame = duplicateDelete.find( + (frame): frame is GraphSnapshotProtocol.IDeleteShard => + frame.type === "deleteShard", + )!; duplicateDelete.splice( - duplicateDelete.length - 1, + -1, 0, - structuredClone(duplicateDelete[2]!), + structuredClone(deleteFrame), ); await rejectedWithoutMovement( deleteStore, @@ -275,7 +340,7 @@ export const test_graph_snapshot_protocol_commits_atomic_shard_generations = "a duplicate delete delta", ); - const bundledStore = new GraphSnapshotProtocol.Store(); + const bundledStore = new GraphSnapshotProtocol.Store(process.cwd()); const bundledFrames = mutate( transaction("bundled-generation"), (frames) => { @@ -289,6 +354,10 @@ export const test_graph_snapshot_protocol_commits_atomic_shard_generations = external: true, }); shard.sources[0]!.file = file; + for (const site of coverageShard(frames).shard.unresolved) { + site.evidence.file = file; + if (site.candidates !== undefined) site.candidates = [file]; + } refreshDigests(frames); }, ); @@ -301,6 +370,7 @@ export const test_graph_snapshot_protocol_commits_atomic_shard_generations = interface ITransactionOptions { baseGeneration?: string; + coverageState?: "complete" | "partial"; nodeName?: string; deleteSource?: boolean; } @@ -332,29 +402,38 @@ function transaction( language: "typescript", target: "app", family, - state: family === "calls" ? "partial" : "unsupported", + state: + family === "calls" + ? options.coverageState ?? + (options.deleteSource === true ? "complete" : "partial") + : "unsupported", })), - unresolved: [ - { - provider: hello.provider, - language: "typescript", - target: "app", - universe: begin.universe, - family: "calls", - evidence: { file: "src/main.ts", startLine: 1, startCol: 1 }, - reason: "dynamic", - candidates: ["src/main.ts#target:function"], - }, - { - provider: hello.provider, - language: "typescript", - target: "app", - universe: begin.universe, - family: "calls", - evidence: { file: "src/main.ts", startLine: 2, startCol: 1 }, - reason: "reflection", - }, - ], + unresolved: + (options.coverageState ?? + (options.deleteSource === true ? "complete" : "partial")) === + "complete" + ? [] + : [ + { + provider: hello.provider, + language: "typescript", + target: "app", + universe: begin.universe, + family: "calls", + evidence: { file: "src/main.ts", startLine: 1, startCol: 1 }, + reason: "dynamic", + candidates: ["src/main.ts#target:function"], + }, + { + provider: hello.provider, + language: "typescript", + target: "app", + universe: begin.universe, + family: "calls", + evidence: { file: "src/main.ts", startLine: 2, startCol: 1 }, + reason: "reflection", + }, + ], sources: [], }; const source: GraphSnapshotProtocol.IShard = { @@ -387,7 +466,7 @@ function transaction( options.baseGeneration === undefined ? [upsertOf(coverage), ...(options.deleteSource === true ? [] : [upsertOf(source)])] : options.deleteSource === true - ? [] + ? [upsertOf(coverage)] : [upsertOf(source)]; const middle: GraphSnapshotProtocol.Frame[] = [ ...upserts, @@ -709,6 +788,57 @@ function malformedTransactions(): Array< refreshDigests(frames); }), ], + [ + "an empty node display name", + mutate(valid, (frames) => { + upsert(frames, "source").shard.nodes[0]!.name = ""; + refreshDigests(frames); + }), + ], + [ + "an invalid node evidence span", + mutate(valid, (frames) => { + upsert(frames, "source").shard.nodes[0]!.evidence = { + startLine: 0, + }; + refreshDigests(frames); + }), + ], + [ + "node evidence absent from the source manifest", + mutate(valid, (frames) => { + upsert(frames, "source").shard.nodes[0]!.evidence = { + file: "src/missing.ts", + startLine: 1, + }; + refreshDigests(frames); + }), + ], + [ + "an edge from an unadvertised family", + mutate(valid, (frames) => { + const shard = upsert(frames, "source").shard; + shard.edges.push({ + kind: "type_ref", + from: shard.nodes[0]!.id, + to: shard.nodes[0]!.id, + }); + refreshDigests(frames); + }), + ], + [ + "an unknown edge family", + mutate(valid, (frames) => { + const shard = upsert(frames, "source").shard; + shard.edges.push({ + kind: "calls", + from: shard.nodes[0]!.id, + to: shard.nodes[0]!.id, + }); + record(shard.edges[0]!).kind = "future-fact"; + refreshDigests(frames); + }), + ], [ "a duplicated edge inside one shard", mutate(valid, (frames) => { @@ -976,6 +1106,18 @@ function invalidProtocolSnapshots(): Array< snapshot.unresolved![0]!.evidence.file = "src/missing.ts"; }, ], + [ + "a NUL-delimited committed source identity", + "source identity that is not normalized and absolute", + (snapshot) => { + snapshot.sources = new Map( + [...snapshot.sources].map(([file, value]) => [ + `${file}\0other`, + value, + ]), + ); + }, + ], [ "an empty committed shard key", "invalid protocol shard manifest", diff --git a/tests/test-graph/src/features/test_mcp_results_preserve_graph_coverage_and_uncertainty.ts b/tests/test-graph/src/features/test_mcp_results_preserve_graph_coverage_and_uncertainty.ts index f59863b0..e45691e7 100644 --- a/tests/test-graph/src/features/test_mcp_results_preserve_graph_coverage_and_uncertainty.ts +++ b/tests/test-graph/src/features/test_mcp_results_preserve_graph_coverage_and_uncertainty.ts @@ -30,7 +30,7 @@ export const test_mcp_results_preserve_graph_coverage_and_uncertainty = request: { type: "lookup", query: "run" }, }); TestValidator.equals( - "lookup returns only its relevant coverage families", + "lookup reports every family that can affect ranking", [ lookup.provenance?.[0]?.provider, lookup.coverage?.schemaVersion, @@ -41,10 +41,51 @@ export const test_mcp_results_preserve_graph_coverage_and_uncertainty = [ "fixture-compiler", 1, - ["contains", "exports", "references"], - 3, - { count: 0, reasons: [], examples: [] }, + GRAPH_EDGE_KINDS.filter( + (family) => + family === "exports" || + !["contains", "exports", "imports"].includes(family), + ), + GRAPH_EDGE_KINDS.length - 2, + { + count: 2, + reasons: [ + { reason: "dynamic", count: 1 }, + { reason: "reflection", count: 1 }, + ], + examples: dump.unresolved, + }, + ], + ); + + const entrypoints = await application.inspect_code_graph({ + question: "where does run begin", + draft: { reason: "first handles", type: "entrypoints" }, + review: "entrypoints is exact", + request: { type: "entrypoints", query: "run" }, + }); + TestValidator.equals( + "entrypoints includes the lookup and neighborhood trust families", + [ + entrypoints.coverage?.families, + entrypoints.unresolved?.count, + ], + [lookup.coverage?.families, 2], + ); + + const overview = await application.inspect_code_graph({ + question: "what are the architectural hotspots", + draft: { reason: "dependency ranking", type: "overview" }, + review: "overview is exact", + request: { type: "overview", aspect: "hotspots" }, + }); + TestValidator.equals( + "overview reports every family counted or used for ranking", + [ + overview.coverage?.families, + overview.unresolved?.count, ], + [GRAPH_EDGE_KINDS, 2], ); const trace = await application.inspect_code_graph({ diff --git a/tests/test-graph/src/features/test_provider_commands_and_inputs_respect_project_boundaries.ts b/tests/test-graph/src/features/test_provider_commands_and_inputs_respect_project_boundaries.ts index 98388759..8ccc23ba 100644 --- a/tests/test-graph/src/features/test_provider_commands_and_inputs_respect_project_boundaries.ts +++ b/tests/test-graph/src/features/test_provider_commands_and_inputs_respect_project_boundaries.ts @@ -355,7 +355,6 @@ export const test_provider_commands_and_inputs_respect_project_boundaries = path.resolve(root), ]), ); - fs.rmSync(sourceGo, { force: true }); const bundledManifest = path.join(bundledSource, "go.mod"); const hiddenManifest = `${bundledManifest}.test-hidden`; @@ -363,12 +362,16 @@ export const test_provider_commands_and_inputs_respect_project_boundaries = try { TestValidator.equals( "a malformed package without its Go source sidecar declines cleanly", - goGraphProvider.resolve(root, emptyPath), + goGraphProvider.resolve(root, { + ...emptyPath, + SAMCHON_GRAPH_GO_TOOLCHAIN: sourceGo, + }), undefined, ); } finally { fs.renameSync(hiddenManifest, bundledManifest); } + fs.rmSync(sourceGo, { force: true }); TestValidator.equals( "the packaged Go source sidecar declines without a Go toolchain", goGraphProvider.resolve(root, emptyPath), From f011c03a0e857a493445775800e6b4448a08abdb Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 31 Jul 2026 00:48:56 +0900 Subject: [PATCH 04/52] fix: stabilize snapshot generations and Go facts --- .../src/provider/GraphSnapshotProtocol.ts | 72 ++++- .../graph/src/provider/IBulkGraphSession.ts | 9 + .../provider/assertGraphSnapshotContract.ts | 18 ++ sidecars/go/analyze.go | 8 +- sidecars/go/main_test.go | 36 +++ sidecars/go/model.go | 22 ++ tests/experiment/src/catalog.mjs | 10 +- tests/experiment/src/strict-lifecycle.mjs | 51 ++- ...st_experiment_corpora_are_commit_pinned.ts | 36 ++- ...otocol_commits_atomic_shard_generations.ts | 298 +++++++++++++++++- 10 files changed, 514 insertions(+), 46 deletions(-) diff --git a/packages/graph/src/provider/GraphSnapshotProtocol.ts b/packages/graph/src/provider/GraphSnapshotProtocol.ts index 3912476b..ee05ccd3 100644 --- a/packages/graph/src/provider/GraphSnapshotProtocol.ts +++ b/packages/graph/src/provider/GraphSnapshotProtocol.ts @@ -91,7 +91,9 @@ export namespace GraphSnapshotProtocol { export interface IBegin { type: "begin"; + sequence: number; generation: string; + baseSequence?: number; baseGeneration?: string; universe: string; manifest: string; @@ -129,6 +131,7 @@ export namespace GraphSnapshotProtocol { export interface ICommit { type: "commit"; + sequence: number; generation: string; shards: IBulkGraphSession.IShard[]; factDigest: string; @@ -231,7 +234,6 @@ export namespace GraphSnapshotProtocol { export class Store { private committed = new Map(); private identity: IHello | undefined; - private readonly generations = new Set(); private readonly root: string; private snapshot: IBulkGraphSession.ISnapshot | undefined; @@ -266,19 +268,28 @@ export namespace GraphSnapshotProtocol { } assertHello(hello); assertBegin(begin); - if (commit.generation !== begin.generation) { - throw new Error("graph snapshot protocol: commit generation does not match begin"); - } - if (this.generations.has(begin.generation)) { - throw new Error("graph snapshot protocol: generation token was reused"); + if ( + commit.sequence !== begin.sequence || + commit.generation !== begin.generation + ) { + throw new Error( + "graph snapshot protocol: commit generation does not match begin", + ); } const priorGeneration = this.snapshot?.protocol?.generation; + const priorSequence = this.snapshot?.protocol?.sequence; if ( begin.baseGeneration !== undefined && - begin.baseGeneration !== priorGeneration + (begin.baseSequence !== priorSequence || + begin.baseGeneration !== priorGeneration) ) { throw new Error("graph snapshot protocol: stale base generation"); } + if (priorSequence !== undefined && begin.sequence <= priorSequence) { + throw new Error( + "graph snapshot protocol: generation sequence did not advance", + ); + } if ( begin.baseGeneration !== undefined && this.identity !== undefined && @@ -293,16 +304,17 @@ export namespace GraphSnapshotProtocol { begin.baseGeneration === undefined ? new Map() : new Map(this.committed); - const changed = new Set(); + const touched = new Set(); + const invalidated = new Set(); for (const frame of frames.slice(2, -1)) { throwIfAborted(options.signal); if (frame.type === "upsertShard") { - if (changed.has(frame.shard.key)) { + if (touched.has(frame.shard.key)) { throw new Error( `graph snapshot protocol: duplicate shard delta: ${frame.shard.key}`, ); } - changed.add(frame.shard.key); + touched.add(frame.shard.key); assertShard(frame.shard, hello, begin); const digest = shardDigest(frame.shard); if (frame.digest !== digest) { @@ -310,23 +322,27 @@ export namespace GraphSnapshotProtocol { `graph snapshot protocol: shard digest mismatch: ${frame.shard.key}`, ); } + if (this.committed.get(frame.shard.key)?.digest !== digest) { + invalidated.add(frame.shard.key); + } next.set(frame.shard.key, { digest, shard: clone(frame.shard), }); } else if (frame.type === "deleteShard") { assertString(frame.key, "deleteShard.key"); - if (changed.has(frame.key)) { + if (touched.has(frame.key)) { throw new Error( `graph snapshot protocol: duplicate shard delta: ${frame.key}`, ); } - changed.add(frame.key); + touched.add(frame.key); if (!next.delete(frame.key)) { throw new Error( `graph snapshot protocol: deleted shard does not exist: ${frame.key}`, ); } + invalidated.add(frame.key); } else { throw new Error( `graph snapshot protocol: unexpected ${frame.type} inside transaction`, @@ -337,7 +353,7 @@ export namespace GraphSnapshotProtocol { begin.baseGeneration !== undefined && this.snapshot !== undefined && begin.manifest !== this.snapshot.protocol!.manifest && - changed.size === 0 + invalidated.size === 0 ) { throw new Error( "graph snapshot protocol: manifest movement reported no shard delta", @@ -350,7 +366,7 @@ export namespace GraphSnapshotProtocol { !sameList(begin.targets, this.snapshot.protocol!.targets)) ) { const retained = [...this.committed.keys()].find( - (key) => !changed.has(key), + (key) => !invalidated.has(key), ); if (retained !== undefined) { throw new Error( @@ -380,7 +396,6 @@ export namespace GraphSnapshotProtocol { freezeDeep(assembled, "the graph snapshot protocol generation"); this.committed = next; this.identity = clone(hello); - this.generations.add(begin.generation); this.snapshot = assembled; return assembled; } @@ -451,9 +466,13 @@ export namespace GraphSnapshotProtocol { unresolved, protocol: { version: VERSION, + sequence: begin.sequence, generation: begin.generation, ...(begin.baseGeneration !== undefined - ? { baseGeneration: begin.baseGeneration } + ? { + baseSequence: begin.baseSequence, + baseGeneration: begin.baseGeneration, + } : {}), manifest: begin.manifest, targets: [...begin.targets], @@ -500,9 +519,28 @@ export namespace GraphSnapshotProtocol { } function assertBegin(begin: IBegin): void { + if (!Number.isSafeInteger(begin.sequence) || begin.sequence < 1) { + throw new Error("graph snapshot protocol: invalid begin.sequence"); + } assertString(begin.generation, "begin.generation"); - if (begin.baseGeneration !== undefined) + if ( + (begin.baseSequence === undefined) !== + (begin.baseGeneration === undefined) + ) { + throw new Error( + "graph snapshot protocol: base sequence and generation must appear together", + ); + } + if (begin.baseGeneration !== undefined) { + if ( + !Number.isSafeInteger(begin.baseSequence) || + begin.baseSequence! < 1 || + begin.baseSequence! >= begin.sequence + ) { + throw new Error("graph snapshot protocol: invalid begin.baseSequence"); + } assertString(begin.baseGeneration, "begin.baseGeneration"); + } assertDigest(begin.universe, "begin.universe"); assertDigest(begin.manifest, "begin.manifest"); assertUnique(begin.targets, "begin.targets"); diff --git a/packages/graph/src/provider/IBulkGraphSession.ts b/packages/graph/src/provider/IBulkGraphSession.ts index b5c35ea3..86b2e7c8 100644 --- a/packages/graph/src/provider/IBulkGraphSession.ts +++ b/packages/graph/src/provider/IBulkGraphSession.ts @@ -126,7 +126,16 @@ export namespace IBulkGraphSession { /** Public identity of one committed Graph Snapshot Protocol generation. */ export interface IProtocolGeneration { version: number; + /** + * Strictly increasing serial for this resident store. + * + * The serial makes a generation identity the bounded pair + * `(sequence, generation)`: stale ABA transactions can be rejected while + * the store retains only the current pair rather than every obsolete token. + */ + sequence: number; generation: string; + baseSequence?: number; baseGeneration?: string; /** Ordered source/configuration/dependency manifest digest. */ manifest: string; diff --git a/packages/graph/src/provider/assertGraphSnapshotContract.ts b/packages/graph/src/provider/assertGraphSnapshotContract.ts index 63b3f582..ad444666 100644 --- a/packages/graph/src/provider/assertGraphSnapshotContract.ts +++ b/packages/graph/src/provider/assertGraphSnapshotContract.ts @@ -95,9 +95,26 @@ function assertProtocol( if (protocol === undefined) return; if ( protocol.version !== GraphSnapshotProtocol.VERSION || + !Number.isSafeInteger(protocol.sequence) || + protocol.sequence < 1 || + typeof protocol.generation !== "string" || protocol.generation === "" || + protocol.generation.includes("\0") || + (protocol.baseSequence === undefined) !== + (protocol.baseGeneration === undefined) || + (protocol.baseSequence !== undefined && + (!Number.isSafeInteger(protocol.baseSequence) || + protocol.baseSequence < 1 || + protocol.baseSequence >= protocol.sequence || + typeof protocol.baseGeneration !== "string" || + protocol.baseGeneration === "" || + protocol.baseGeneration.includes("\0"))) || protocol.targets.length === 0 || new Set(protocol.targets).size !== protocol.targets.length || + protocol.targets.some( + (target) => + typeof target !== "string" || target === "" || target.includes("\0"), + ) || !SHA256.test(protocol.manifest) || !SHA256.test(protocol.factDigest) || snapshot.coverage === undefined || @@ -109,6 +126,7 @@ function assertProtocol( for (const shard of protocol.shards) { if ( shard.key === "" || + shard.key.includes("\0") || shards.has(shard.key) || !SHA256.test(shard.digest) ) { diff --git a/sidecars/go/analyze.go b/sidecars/go/analyze.go index e6ae12f0..c52086e7 100644 --- a/sidecars/go/analyze.go +++ b/sidecars/go/analyze.go @@ -873,7 +873,13 @@ func (c *collector) addEdge(value edge) { return } key := value.Kind + "\x00" + value.From + "\x00" + value.To - if _, exists := c.edges[key]; !exists { + // One semantic relation may be observed at several source sites and through + // several go/packages variants. The relation is unique by kind/endpoints, + // but retaining whichever evidence arrived first makes the published fact + // depend on package traversal order. Keep the canonical proof instead: + // evidence beats no evidence, then the earliest complete source span wins. + if existing, exists := c.edges[key]; !exists || + evidenceLess(value.Evidence, existing.Evidence) { c.edges[key] = value } } diff --git a/sidecars/go/main_test.go b/sidecars/go/main_test.go index 5d65ba2a..fcfcd311 100644 --- a/sidecars/go/main_test.go +++ b/sidecars/go/main_test.go @@ -444,6 +444,42 @@ func TestSidecarRejectsConflictingNodesAndPreservesFileAuthorities(t *testing.T) } } +func TestSidecarCanonicalizesDuplicateEdgeEvidence(t *testing.T) { + earlier := edge{ + From: "from", To: "to", Kind: "calls", + Evidence: &evidence{ + File: "a.go", StartLine: 2, StartCol: 3, EndLine: 2, EndCol: 9, + }, + } + later := edge{ + From: "from", To: "to", Kind: "calls", + Evidence: &evidence{ + File: "z.go", StartLine: 8, StartCol: 1, EndLine: 8, EndCol: 7, + }, + } + withoutEvidence := edge{From: "from", To: "to", Kind: "calls"} + for name, values := range map[string][]edge{ + "later-first": {later, earlier, withoutEvidence}, + "earlier-first": {earlier, withoutEvidence, later}, + "no-evidence-first": {withoutEvidence, later, earlier}, + } { + t.Run(name, func(t *testing.T) { + graph := &collector{edges: map[string]edge{}} + for _, value := range values { + graph.addEdge(value) + } + if len(graph.edges) != 1 { + t.Fatalf("duplicate semantic relation produced %d edges", len(graph.edges)) + } + for _, actual := range graph.edges { + if !reflect.DeepEqual(actual, earlier) { + t.Fatalf("duplicate relation retained non-canonical evidence: %#v", actual) + } + } + }) + } +} + func TestSemanticIdentityMatchesTheSharedProviderV2Codec(t *testing.T) { if got, want := semanticID( "function", diff --git a/sidecars/go/model.go b/sidecars/go/model.go index bfee2a3c..bb067569 100644 --- a/sidecars/go/model.go +++ b/sidecars/go/model.go @@ -172,6 +172,28 @@ func edgeKey(value edge) string { return value.Kind + "\x00" + value.From + "\x00" + value.To + "\x00" + position } +func evidenceLess(left, right *evidence) bool { + if left == nil { + return false + } + if right == nil { + return true + } + if left.File != right.File { + return left.File < right.File + } + if left.StartLine != right.StartLine { + return left.StartLine < right.StartLine + } + if left.StartCol != right.StartCol { + return left.StartCol < right.StartCol + } + if left.EndLine != right.EndLine { + return left.EndLine < right.EndLine + } + return left.EndCol < right.EndCol +} + func diagnosticKey(value diagnostic) string { return value.File + "\x00" + strconv.Itoa(value.Line) + "\x00" + strconv.Itoa(value.Column) + "\x00" + value.Message diff --git a/tests/experiment/src/catalog.mjs b/tests/experiment/src/catalog.mjs index 69f3fa9c..8ae3d6a2 100644 --- a/tests/experiment/src/catalog.mjs +++ b/tests/experiment/src/catalog.mjs @@ -399,12 +399,12 @@ export const LANGUAGE_EXPERIMENTS = [ // `_attemptParseFile`, which retries the parse six times, logs // `Config file "..." could not be parsed`, and returns `undefined`. // Configuration then falls through to defaults and the index is written - // and published with exit code 0. The bundle constructs no SCIP - // `Diagnostic` either, so neither `reject` nor `diagnostic` describes - // this producer; claiming one would pin the harness to a fiction. - failurePolicy: "tolerated", + // and published with exit code 0. That default program covers a different + // file set from the declared Pyright configuration, so this is a changed, + // degraded publication rather than an ignored input. + failurePolicy: "published", failureLimitation: - "scip-python 0.6.6 recovers from a malformed pyproject.toml and publishes an index; a broken Python build configuration is not a fail-closed boundary for this producer", + "scip-python 0.6.6 recovers from a malformed pyproject.toml by falling back to Pyright defaults and publishes a changed, degraded index; a broken Python build configuration is not a fail-closed boundary for this producer", }, }, { diff --git a/tests/experiment/src/strict-lifecycle.mjs b/tests/experiment/src/strict-lifecycle.mjs index fa2cebdc..8d01bffb 100644 --- a/tests/experiment/src/strict-lifecycle.mjs +++ b/tests/experiment/src/strict-lifecycle.mjs @@ -504,12 +504,14 @@ export const runStrictLifecycle = async (experiment, pinnedRoot) => { const reproduced = reproducedManifest && reproducedContent; const limitation = experiment.regenerationLimitation; if (!reproduced && limitation === undefined) { + const difference = firstGenerationDifference(cold, retried); throw new Error( `${experiment.language}: restoring the original sources did not reproduce the generation ` + `(manifest ${reproducedManifest ? "unchanged" : "moved"}, ` + `facts ${reproducedContent ? "unchanged" : "moved"}; ` + `cold ${String(cold.nodes.length)} nodes/${String(cold.edges.length)} edges, ` + - `retry ${String(retried.nodes.length)} nodes/${String(retried.edges.length)} edges)`, + `retry ${String(retried.nodes.length)} nodes/${String(retried.edges.length)} edges; ` + + `first difference: ${difference})`, ); } rows.push({ @@ -546,6 +548,53 @@ function strictProvenance(dump, experiment) { return provenance; } +function firstGenerationDifference(left, right) { + for (const plane of [ + "languages", + "nodes", + "edges", + "diagnostics", + "coverage", + "unresolved", + ]) { + const before = left[plane] ?? []; + const after = right[plane] ?? []; + if (before.length !== after.length) { + return `${plane}.length ${String(before.length)} -> ${String(after.length)}`; + } + for (let index = 0; index < before.length; index++) { + const prior = canonicalGenerationValue(before[index]); + const next = canonicalGenerationValue(after[index]); + if (prior !== next) { + return `${plane}[${String(index)}] ${boundedDifference(prior)} -> ${boundedDifference(next)}`; + } + } + } + return "normalized dump fact planes are equal; the strict slice moved before merge"; +} + +function canonicalGenerationValue(value) { + if (value === undefined) return "undefined"; + if (value === null || typeof value !== "object") { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map(canonicalGenerationValue).join(",")}]`; + } + return `{${Object.entries(value) + .filter(([, nested]) => nested !== undefined) + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + .map( + ([key, nested]) => + `${JSON.stringify(key)}:${canonicalGenerationValue(nested)}`, + ) + .join(",")}}`; +} + +function boundedDifference(value) { + return value.length <= 320 ? value : `${value.slice(0, 317)}...`; +} + function assertCreatedSymbol( dump, language, diff --git a/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts b/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts index 97393a82..f28b6819 100644 --- a/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts +++ b/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts @@ -160,30 +160,38 @@ export const test_experiment_corpora_are_commit_pinned = () => { runner.includes("crossFileEdge !== undefined") && runner.includes("semanticLimitation.trim() ==="), ); - // scip-python 0.6.6 recovers from a malformed `pyproject.toml` and emits no - // SCIP diagnostics, so a row claiming either boundary would assert behaviour - // the pinned producer does not have. A tolerated row earns its place only by - // proving the exact upstream claim instead — the producer ignored the input, - // so the build universe moved and the published facts did not — and by saying - // what it gave up rather than leaving a reader to infer it from a green lane. + // scip-python 0.6.6 recovers from a malformed `pyproject.toml`, falls back to + // Pyright defaults and emits no SCIP diagnostics. The pinned Click run proved + // that the fallback changes the analyzed program, so the row must exercise + // the degraded-publication branch rather than claim reject, diagnostic, or + // ignored-input behavior. TestValidator.predicate( - "a failure boundary the producer does not have is published as a limitation", - python.includes('failurePolicy: "tolerated"') && + "Python's malformed configuration is a changed degraded publication", + python.includes('failurePolicy: "published"') && declares(python, "failureLimitation") && - lifecycle.includes('fixture.failurePolicy === "tolerated"') && - lifecycle.includes('fixture.failureLimitation === ""') && + python.includes("falling back to Pyright defaults") && + lifecycle.includes('fixture.failurePolicy === "published"') && lifecycle.includes("provenance.universe === prior.universe") && - lifecycle.includes("provenance.content !== prior.content") && - lifecycle.includes("diagnosticCount !== previousDiagnostics"), + lifecycle.includes("publicationChanges(") && + lifecycle.includes("changed.length === 0"), ); TestValidator.predicate( "a degraded publication is distinct from an input the producer ignored", - lua.includes('failurePolicy: "published"') && - declares(lua, "failureLimitation") && + [python, lua].every( + (row) => + row.includes('failurePolicy: "published"') && + declares(row, "failureLimitation"), + ) && lifecycle.includes('fixture.failurePolicy === "published"') && lifecycle.includes('status: "published-with-limitation"') && lifecycle.includes("publicationChanges("), ); + TestValidator.predicate( + "a regeneration failure names its first differing fact", + lifecycle.includes("firstGenerationDifference(cold, retried)") && + lifecycle.includes("first difference:") && + lifecycle.includes("normalized dump fact planes are equal"), + ); TestValidator.predicate( "a malformed compilation database proves strict decline and warned fallback", [cpp, c].every( diff --git a/tests/test-graph/src/features/test_graph_snapshot_protocol_commits_atomic_shard_generations.ts b/tests/test-graph/src/features/test_graph_snapshot_protocol_commits_atomic_shard_generations.ts index 809a1b94..c80b637e 100644 --- a/tests/test-graph/src/features/test_graph_snapshot_protocol_commits_atomic_shard_generations.ts +++ b/tests/test-graph/src/features/test_graph_snapshot_protocol_commits_atomic_shard_generations.ts @@ -12,6 +12,11 @@ import path from "node:path"; const digest = (letter: string): string => letter.repeat(64); +function sequenceOf(generation: string): number { + const suffix = /-(\d+)$/u.exec(generation); + return suffix === null ? 4 : Number(suffix[1]); +} + /** * Graph Snapshot Protocol publishes one validated complete generation or keeps * the prior one byte-for-byte. The fixture is an external producer oracle: all @@ -40,6 +45,7 @@ export const test_graph_snapshot_protocol_commits_atomic_shard_generations = TestValidator.equals( "the committed generation reconstructs every protocol plane", [ + initial.protocol?.sequence, initial.protocol?.generation, initial.protocol?.manifest, initial.protocol?.shards.map((shard) => shard.key), @@ -48,6 +54,7 @@ export const test_graph_snapshot_protocol_commits_atomic_shard_generations = initial.unresolved?.map((site) => site.reason), ], [ + 1, "generation-1", digest("b"), ["coverage", "source"], @@ -97,6 +104,7 @@ export const test_graph_snapshot_protocol_commits_atomic_shard_generations = const editedFrames = transaction("generation-2", { baseGeneration: "generation-1", + baseSequence: 1, nodeName: "edited", }); const edited = store.apply(editedFrames); @@ -104,16 +112,18 @@ export const test_graph_snapshot_protocol_commits_atomic_shard_generations = "a delta reuses unchanged shards and replaces only its upsert", [ edited.nodes.map((node) => node.name), + edited.protocol?.baseSequence, edited.protocol?.baseGeneration, edited.protocol?.shards[0]?.digest === initial.protocol?.shards[0]?.digest, ], - [["edited"], "generation-1", true], + [["edited"], 1, "generation-1", true], ); const deleted = store.apply( transaction("generation-3", { baseGeneration: "generation-2", + baseSequence: 2, deleteSource: true, }), ); @@ -125,7 +135,11 @@ export const test_graph_snapshot_protocol_commits_atomic_shard_generations = await rejectedWithoutMovement( store, - transaction("stale", { baseGeneration: "generation-1" }), + transaction("stale", { + sequence: 4, + baseSequence: 1, + baseGeneration: "generation-1", + }), "a stale base", ); await rejectedWithoutMovement( @@ -133,6 +147,7 @@ export const test_graph_snapshot_protocol_commits_atomic_shard_generations = mutate( transaction("changed-identity", { baseGeneration: "generation-3", + baseSequence: 3, coverageState: "complete", }), (frames) => { @@ -145,6 +160,7 @@ export const test_graph_snapshot_protocol_commits_atomic_shard_generations = store, transaction("missing-delete", { baseGeneration: "generation-3", + baseSequence: 3, deleteSource: true, }), "deleting an absent shard", @@ -152,6 +168,7 @@ export const test_graph_snapshot_protocol_commits_atomic_shard_generations = const manifestWithoutDelta = mutate( transaction("manifest-without-delta", { baseGeneration: "generation-3", + baseSequence: 3, deleteSource: true, }), (frames) => { @@ -166,17 +183,15 @@ export const test_graph_snapshot_protocol_commits_atomic_shard_generations = ); await rejectedWithoutMovement( store, - transaction("generation-3", { - baseGeneration: "generation-3", - coverageState: "complete", - }), - "a reused generation token", + transaction("generation-3", { sequence: 3 }), + "a non-advancing generation sequence", ); await rejectedWithoutMovement( store, mutate( transaction("moved-universe", { baseGeneration: "generation-3", + baseSequence: 3, coverageState: "complete", }), (frames) => { @@ -190,6 +205,7 @@ export const test_graph_snapshot_protocol_commits_atomic_shard_generations = mutate( transaction("moved-target", { baseGeneration: "generation-3", + baseSequence: 3, coverageState: "complete", }), (frames) => { @@ -203,6 +219,7 @@ export const test_graph_snapshot_protocol_commits_atomic_shard_generations = mutate( transaction("bad-shard", { baseGeneration: "generation-3", + baseSequence: 3, coverageState: "complete", }), (frames) => { @@ -216,6 +233,7 @@ export const test_graph_snapshot_protocol_commits_atomic_shard_generations = mutate( transaction("bad-facts", { baseGeneration: "generation-3", + baseSequence: 3, coverageState: "complete", }), (frames) => { @@ -229,6 +247,7 @@ export const test_graph_snapshot_protocol_commits_atomic_shard_generations = mutate( transaction("bad-manifest", { baseGeneration: "generation-3", + baseSequence: 3, coverageState: "complete", }), (frames) => { @@ -307,6 +326,7 @@ export const test_graph_snapshot_protocol_commits_atomic_shard_generations = const manifestEdit = mutate( transaction("manifest-generation-2", { baseGeneration: "manifest-generation-1", + baseSequence: 1, nodeName: "manifest-edited", }), (frames) => { @@ -318,11 +338,75 @@ export const test_graph_snapshot_protocol_commits_atomic_shard_generations = manifestStore.apply(manifestEdit).nodes.map((node) => node.name), ["manifest-edited"], ); + const replayedManifestShard = mutate( + transaction("manifest-generation-3", { + baseGeneration: "manifest-generation-2", + baseSequence: 2, + nodeName: "manifest-edited", + }), + (frames) => { + (frames[1] as GraphSnapshotProtocol.IBegin).manifest = digest("e"); + }, + ); + await rejectedWithoutMovement( + manifestStore, + replayedManifestShard, + "manifest movement disguised as a byte-identical shard upsert", + ); + const replayedUniverseShard = mutate( + transaction("manifest-generation-3", { + baseGeneration: "manifest-generation-2", + baseSequence: 2, + nodeName: "manifest-edited", + }), + (frames) => { + (frames[1] as GraphSnapshotProtocol.IBegin).universe = digest("d"); + }, + ); + await rejectedWithoutMovement( + manifestStore, + replayedUniverseShard, + "universe movement disguised as a byte-identical shard upsert", + ); + + const boundedGenerationStore = new GraphSnapshotProtocol.Store(process.cwd()); + boundedGenerationStore.apply(transaction("bounded-generation-1")); + boundedGenerationStore.apply( + transaction("bounded-generation-2", { + baseGeneration: "bounded-generation-1", + baseSequence: 1, + nodeName: "second", + }), + ); + const returnedToken = boundedGenerationStore.apply( + transaction("bounded-generation-1", { + sequence: 3, + baseGeneration: "bounded-generation-2", + baseSequence: 2, + nodeName: "third", + }), + ); + TestValidator.equals( + "a bounded generation pair can reuse an old spelling without retaining token history", + [returnedToken.protocol?.sequence, returnedToken.protocol?.generation], + [3, "bounded-generation-1"], + ); + await rejectedWithoutMovement( + boundedGenerationStore, + transaction("stale-after-aba", { + sequence: 2, + baseGeneration: "bounded-generation-1", + baseSequence: 1, + nodeName: "stale", + }), + "an obsolete sequence cannot exploit a repeated generation spelling", + ); const deleteStore = new GraphSnapshotProtocol.Store(process.cwd()); deleteStore.apply(transaction("delete-generation-1")); const duplicateDelete = transaction("delete-generation-2", { baseGeneration: "delete-generation-1", + baseSequence: 1, deleteSource: true, }); const deleteFrame = duplicateDelete.find( @@ -369,6 +453,8 @@ export const test_graph_snapshot_protocol_commits_atomic_shard_generations = }; interface ITransactionOptions { + sequence?: number; + baseSequence?: number; baseGeneration?: string; coverageState?: "complete" | "partial"; nodeName?: string; @@ -382,9 +468,14 @@ function transaction( const hello = validHello(); const begin: GraphSnapshotProtocol.IBegin = { type: "begin", + sequence: options.sequence ?? sequenceOf(generation), generation, ...(options.baseGeneration !== undefined - ? { baseGeneration: options.baseGeneration } + ? { + baseSequence: + options.baseSequence ?? sequenceOf(options.baseGeneration), + baseGeneration: options.baseGeneration, + } : {}), universe: digest("a"), manifest: digest("b"), @@ -490,6 +581,7 @@ function transaction( ...middle, { type: "commit", + sequence: begin.sequence, generation, shards: manifest, factDigest: GraphSnapshotProtocol.factDigest(snapshot), @@ -710,9 +802,64 @@ function malformedTransactions(): Array< record(frames[0]!).producer = "bad\0producer"; }), ], + [ + "a fractional begin sequence", + mutate(valid, (frames) => { + record(frames[1]!).sequence = 1.5; + record(commit(frames)).sequence = 1.5; + }), + ], + [ + "a non-positive begin sequence", + mutate(valid, (frames) => { + record(frames[1]!).sequence = 0; + record(commit(frames)).sequence = 0; + }), + ], + [ + "a base generation without its sequence", + mutate(valid, (frames) => { + record(frames[1]!).baseGeneration = "base"; + }), + ], + [ + "a base sequence without its generation", + mutate(valid, (frames) => { + record(frames[1]!).baseSequence = 3; + }), + ], + [ + "a fractional base sequence", + mutate(valid, (frames) => { + record(frames[1]!).baseSequence = 1.5; + record(frames[1]!).baseGeneration = "base"; + }), + ], + [ + "a non-positive base sequence", + mutate(valid, (frames) => { + record(frames[1]!).baseSequence = 0; + record(frames[1]!).baseGeneration = "base"; + }), + ], + [ + "a base sequence not older than its generation", + mutate(valid, (frames) => { + record(frames[1]!).baseSequence = 4; + record(frames[1]!).baseGeneration = "base"; + }), + ], + [ + "a NUL base generation", + mutate(valid, (frames) => { + record(frames[1]!).baseSequence = 3; + record(frames[1]!).baseGeneration = "bad\0base"; + }), + ], [ "an empty base generation", mutate(valid, (frames) => { + record(frames[1]!).baseSequence = 3; record(frames[1]!).baseGeneration = ""; }), ], @@ -746,6 +893,12 @@ function malformedTransactions(): Array< record(frames[1]!).targets = [""]; }), ], + [ + "a mismatched commit sequence", + mutate(valid, (frames) => { + commit(frames).sequence += 1; + }), + ], [ "a mismatched commit generation", mutate(valid, (frames) => { @@ -1118,6 +1271,128 @@ function invalidProtocolSnapshots(): Array< ); }, ], + [ + "a fractional protocol generation sequence", + "invalid protocol generation", + (snapshot) => { + snapshot.protocol!.sequence = 1.5; + }, + ], + [ + "a non-positive protocol generation sequence", + "invalid protocol generation", + (snapshot) => { + snapshot.protocol!.sequence = 0; + }, + ], + [ + "a fractional protocol base sequence", + "invalid protocol generation", + (snapshot) => { + Object.assign(snapshot.protocol!, { + sequence: 2, + baseSequence: 1.5, + baseGeneration: "base", + }); + }, + ], + [ + "a non-positive protocol base sequence", + "invalid protocol generation", + (snapshot) => { + Object.assign(snapshot.protocol!, { + sequence: 2, + baseSequence: 0, + baseGeneration: "base", + }); + }, + ], + [ + "a protocol base sequence not older than its generation", + "invalid protocol generation", + (snapshot) => { + Object.assign(snapshot.protocol!, { + sequence: 2, + baseSequence: 2, + baseGeneration: "base", + }); + }, + ], + [ + "a non-string protocol base generation", + "invalid protocol generation", + (snapshot) => { + Object.assign(snapshot.protocol!, { + sequence: 2, + baseSequence: 1, + baseGeneration: 1, + }); + }, + ], + [ + "an empty protocol base generation", + "invalid protocol generation", + (snapshot) => { + Object.assign(snapshot.protocol!, { + sequence: 2, + baseSequence: 1, + baseGeneration: "", + }); + }, + ], + [ + "a NUL protocol base generation", + "invalid protocol generation", + (snapshot) => { + Object.assign(snapshot.protocol!, { + sequence: 2, + baseSequence: 1, + baseGeneration: "bad\0base", + }); + }, + ], + [ + "a protocol base token without its sequence", + "invalid protocol generation", + (snapshot) => { + snapshot.protocol!.baseGeneration = "orphan"; + }, + ], + [ + "a non-string protocol generation", + "invalid protocol generation", + (snapshot) => { + record(snapshot.protocol!).generation = 1; + }, + ], + [ + "a NUL protocol generation", + "invalid protocol generation", + (snapshot) => { + snapshot.protocol!.generation = "bad\0generation"; + }, + ], + [ + "a non-string protocol target", + "invalid protocol generation", + (snapshot) => { + record(snapshot.protocol!).targets = [1]; + }, + ], + [ + "an empty protocol target", + "invalid protocol generation", + (snapshot) => { + snapshot.protocol!.targets[0] = ""; + }, + ], + [ + "a NUL protocol target", + "invalid protocol generation", + (snapshot) => { + snapshot.protocol!.targets[0] = "bad\0target"; + }, + ], [ "an empty committed shard key", "invalid protocol shard manifest", @@ -1125,6 +1400,13 @@ function invalidProtocolSnapshots(): Array< snapshot.protocol!.shards[0]!.key = ""; }, ], + [ + "a NUL committed shard key", + "invalid protocol shard manifest", + (snapshot) => { + snapshot.protocol!.shards[0]!.key = "bad\0key"; + }, + ], [ "duplicate committed shard keys", "invalid protocol shard manifest", From a5fcad19859cd6a1af41653cbc6f4c5a3779800d Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 31 Jul 2026 01:18:20 +0900 Subject: [PATCH 05/52] fix: separate Go universe from derived artifacts --- sidecars/go/main.go | 9 +- sidecars/go/main_test.go | 46 +++++----- sidecars/go/scip.go | 15 +--- tests/experiment/src/catalog.mjs | 11 +-- tests/experiment/src/strict-lifecycle.mjs | 84 +++++++++++-------- ...st_experiment_corpora_are_commit_pinned.ts | 24 +++--- 6 files changed, 101 insertions(+), 88 deletions(-) diff --git a/sidecars/go/main.go b/sidecars/go/main.go index 46078533..2a192756 100644 --- a/sidecars/go/main.go +++ b/sidecars/go/main.go @@ -128,12 +128,17 @@ func buildSnapshot( for _, key := range keys { universeParts = append(universeParts, key, normalizedEnvironmentValue(root, key, environment[key])) } - for index, moduleRoot := range roots { + for _, moduleRoot := range roots { identity, relativeErr := filepath.Rel(root, moduleRoot) if relativeErr != nil { return snapshot{}, fmt.Errorf("name Go module root %s: %w", moduleRoot, relativeErr) } - universeParts = append(universeParts, filepath.ToSlash(identity), artifacts[index].Digest) + // The SCIP index is derived corroboration, not an input coordinate. + // Its protobuf bytes can move while the selected Go build universe and + // the compiler-owned facts remain identical. Keep the module identity + // in the universe and validate the artifact below, but do not turn a + // navigation artifact digest into a public coverage target. + universeParts = append(universeParts, filepath.ToSlash(identity)) } for _, input := range inputs { body, readErr := os.ReadFile(input) diff --git a/sidecars/go/main_test.go b/sidecars/go/main_test.go index fcfcd311..2ddbf365 100644 --- a/sidecars/go/main_test.go +++ b/sidecars/go/main_test.go @@ -233,7 +233,9 @@ func TestScipBoundaryRejectsForeignAndMalformedArtifacts(t *testing.T) { t.Fatal(err) } filtered, err := validateScipIndex(root, withExternalBody) - if err != nil || len(filtered.Documents) != 1 || filtered.Digest != artifact.Digest { + if err != nil || + len(filtered.Documents) != 1 || + filtered.Documents[0] != artifact.Documents[0] { t.Fatalf("external SCIP cache document was not excluded canonically: artifact=%#v err=%v", filtered, err) } otherRoot := t.TempDir() @@ -244,9 +246,8 @@ func TestScipBoundaryRejectsForeignAndMalformedArtifacts(t *testing.T) { if err != nil { t.Fatal(err) } - otherArtifact, err := validateScipIndex(otherRoot, otherBody) - if err != nil || artifact.Digest != otherArtifact.Digest { - t.Error("SCIP digest retained checkout or invocation paths") + if _, err := validateScipIndex(otherRoot, otherBody); err != nil { + t.Error("equivalent SCIP artifact failed in another checkout") } invalid := []*scip.Index{ {}, @@ -269,7 +270,7 @@ func TestScipBoundaryRejectsForeignAndMalformedArtifacts(t *testing.T) { } } -func TestScipDigestAndUniverseIgnoreCheckoutLocations(t *testing.T) { +func TestScipBoundaryAndUniverseIgnoreCheckoutLocations(t *testing.T) { left := copyFixture(t) right := copyFixture(t) leftSnapshot, err := buildSnapshot(context.Background(), left, fixtureScipIndexer{}, fixtureEnvironment(left)) @@ -283,6 +284,18 @@ func TestScipDigestAndUniverseIgnoreCheckoutLocations(t *testing.T) { if leftSnapshot.Universe != rightSnapshot.Universe { t.Error("equivalent checkouts produced location-dependent Go universes") } + reorderedArtifactSnapshot, err := buildSnapshot( + context.Background(), + left, + fixtureScipIndexer{reverseDocuments: true}, + fixtureEnvironment(left), + ) + if err != nil { + t.Fatal(err) + } + if leftSnapshot.Universe != reorderedArtifactSnapshot.Universe { + t.Error("derived SCIP artifact ordering changed the Go build universe") + } if _, err := buildSnapshot( context.Background(), left, @@ -649,7 +662,8 @@ func TestProjectBoundaryAllowsOnlySharedSymlinkPrefixes(t *testing.T) { } type fixtureScipIndexer struct { - version string + version string + reverseDocuments bool } func (indexer fixtureScipIndexer) Version(context.Context) (string, error) { @@ -660,7 +674,6 @@ func (indexer fixtureScipIndexer) Version(context.Context) (string, error) { } func (fixtureScipIndexer) Index(_ context.Context, moduleRoot string) (scipArtifact, error) { - var parts []string var documents []string definitions := map[string]int{} err := filepath.WalkDir(moduleRoot, func(file string, entry os.DirEntry, walkErr error) error { @@ -668,15 +681,6 @@ func (fixtureScipIndexer) Index(_ context.Context, moduleRoot string) (scipArtif return walkErr } if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".go") { - body, err := os.ReadFile(file) - if err != nil { - return err - } - relative, err := filepath.Rel(moduleRoot, file) - if err != nil { - return err - } - parts = append(parts, filepath.ToSlash(relative), digestBytes(body)) documents = append(documents, file) definitions[pathKey(file)] = 1 } @@ -685,11 +689,13 @@ func (fixtureScipIndexer) Index(_ context.Context, moduleRoot string) (scipArtif if err != nil { return scipArtifact{}, err } - sort.Strings(parts) sort.Strings(documents) - return scipArtifact{ - Digest: digestStrings(parts...), Documents: documents, Definitions: definitions, - }, nil + if indexer.reverseDocuments { + for left, right := 0, len(documents)-1; left < right; left, right = left+1, right-1 { + documents[left], documents[right] = documents[right], documents[left] + } + } + return scipArtifact{Documents: documents, Definitions: definitions}, nil } type missingScipDocumentIndexer struct{ fixtureScipIndexer } diff --git a/sidecars/go/scip.go b/sidecars/go/scip.go index 1536aab2..ea644bf8 100644 --- a/sidecars/go/scip.go +++ b/sidecars/go/scip.go @@ -19,7 +19,6 @@ import ( ) type scipArtifact struct { - Digest string Documents []string Definitions map[string]int } @@ -160,7 +159,6 @@ func validateScipIndex(moduleRoot string, body []byte) (scipArtifact, error) { seen := make(map[string]bool, len(index.Documents)) documents := make([]string, 0, len(index.Documents)) definitions := make(map[string]int, len(index.Documents)) - keptDocuments := make([]*scip.Document, 0, len(index.Documents)) for _, document := range index.Documents { relative := filepath.FromSlash(document.RelativePath) if document.RelativePath == "" { @@ -183,7 +181,6 @@ func validateScipIndex(moduleRoot string, body []byte) (scipArtifact, error) { return scipArtifact{}, fmt.Errorf("scip-go emitted a %s document: %s", document.Language, document.RelativePath) } absolute := filepath.Join(moduleRoot, cleaned) - keptDocuments = append(keptDocuments, document) documents = append(documents, absolute) for _, occurrence := range document.Occurrences { if occurrence.SymbolRoles&int32(scip.SymbolRole_Definition) != 0 { @@ -191,19 +188,9 @@ func validateScipIndex(moduleRoot string, body []byte) (scipArtifact, error) { } } } - canonical := proto.Clone(index).(*scip.Index) - canonical.Metadata.ProjectRoot = "" - canonical.Documents = keptDocuments - if canonical.Metadata.ToolInfo != nil { - canonical.Metadata.ToolInfo.Arguments = nil - } - canonicalBody, err := proto.MarshalOptions{Deterministic: true}.Marshal(canonical) - if err != nil { - return scipArtifact{}, fmt.Errorf("canonicalize scip-go artifact: %w", err) - } sort.Strings(documents) return scipArtifact{ - Digest: digestBytes(canonicalBody), Documents: documents, Definitions: definitions, + Documents: documents, Definitions: definitions, }, nil } diff --git a/tests/experiment/src/catalog.mjs b/tests/experiment/src/catalog.mjs index 8ae3d6a2..31f77a8a 100644 --- a/tests/experiment/src/catalog.mjs +++ b/tests/experiment/src/catalog.mjs @@ -399,12 +399,13 @@ export const LANGUAGE_EXPERIMENTS = [ // `_attemptParseFile`, which retries the parse six times, logs // `Config file "..." could not be parsed`, and returns `undefined`. // Configuration then falls through to defaults and the index is written - // and published with exit code 0. That default program covers a different - // file set from the declared Pyright configuration, so this is a changed, - // degraded publication rather than an ignored input. - failurePolicy: "published", + // and published with exit code 0. On the pinned Click fixture the + // normalized source and fact planes remain unchanged; only the declared + // configuration coordinate moves. This is tolerated upstream behavior, + // not rejection, a diagnostic, or proof of a changed analyzed program. + failurePolicy: "tolerated", failureLimitation: - "scip-python 0.6.6 recovers from a malformed pyproject.toml by falling back to Pyright defaults and publishes a changed, degraded index; a broken Python build configuration is not a fail-closed boundary for this producer", + "scip-python 0.6.6 recovers from a malformed pyproject.toml by falling back to Pyright defaults and exits successfully; on the pinned Click fixture its normalized source and fact planes remain unchanged, so a broken Python build configuration is neither rejected nor diagnosed", }, }, { diff --git a/tests/experiment/src/strict-lifecycle.mjs b/tests/experiment/src/strict-lifecycle.mjs index 8d01bffb..a39a23cf 100644 --- a/tests/experiment/src/strict-lifecycle.mjs +++ b/tests/experiment/src/strict-lifecycle.mjs @@ -251,9 +251,10 @@ export const runStrictLifecycle = async (experiment, pinnedRoot) => { // itself, and so would asserting that provenance moved: this step edits a // declared build input, so the build universe cannot help but move. What // is worth proving is the precise claim the catalog makes about upstream — - // that the producer ignored the input completely. The universe moves, the - // facts and the source manifest do not, and the row publishes all three so - // a reader can see which one carried the change. + // it tolerates the invalid input without an observable publication-plane + // change. The universe moves, the facts and source manifest do not, and + // the row publishes all three so a reader can see which one carried the + // change. if ( typeof fixture.failureLimitation !== "string" || fixture.failureLimitation === "" @@ -288,37 +289,24 @@ export const runStrictLifecycle = async (experiment, pinnedRoot) => { `${experiment.language}: the malformed input did not move the build universe, so this step compared a generation to itself`, ); } - // The claim itself. Content and manifest cover the facts and the source - // evidence; capabilities and this provider's warnings are the rest of what - // a reader can observe, and neither digest carries them. A producer that - // quietly gave up a capability, or started explaining itself, has not - // ignored the input. - const spoken = (report) => - (report.warnings ?? []) - .filter((warning) => warning.startsWith(`${experiment.strictProvider}:`)) - .sort() - .join(SEPARATOR); - if ( - provenance.content !== prior.content || - provenance.manifest !== prior.manifest || - [...provenance.capabilities].sort().join(",") !== - [...prior.capabilities].sort().join(",") || - spoken(tolerated) !== spoken(priorDump) - ) { + // Compare the observable publication planes directly. The aggregate + // content digest is not independent evidence here: legacy coverage rows + // use the build-universe digest as their target, so content necessarily + // moves whenever the build input above moves even if every semantic fact + // remains byte-identical. + const changed = publicationChanges( + prior, + provenance, + priorDump, + tolerated, + experiment.strictProvider, + ); + if (changed.length !== 0) { throw new Error( - `${experiment.language}: the catalog records this input as ignored, but the published facts, source manifest, capabilities, or provider warnings changed with it`, + `${experiment.language}: the catalog records this input as a tolerated unchanged publication, but these publication planes moved: ${changed.join(", ")}`, ); } - // The other half of the catalog's claim, as a delta rather than an - // absolute: diagnostics this corpus already had are not evidence about - // this input, and the dump carries every lane's diagnostics, not only - // this provider's slice. const diagnosticCount = tolerated.diagnostics?.length ?? 0; - if (diagnosticCount !== previousDiagnostics) { - throw new Error( - `${experiment.language}: the catalog records this producer as reporting nothing about a malformed build input, but diagnostics moved from ${String(previousDiagnostics)} to ${String(diagnosticCount)}`, - ); - } dump = tolerated; previousIdentity = [ provenance.manifest, @@ -653,7 +641,17 @@ function publicationChanges( ) { const changed = []; if (prior.manifest !== next.manifest) changed.push("manifest"); - if (prior.content !== next.content) changed.push("content"); + for (const plane of [ + "nodes", + "edges", + "coverage", + "unresolved", + "diagnostics", + ]) { + const before = normalizedPublicationPlane(priorDump, plane); + const after = normalizedPublicationPlane(nextDump, plane); + if (before !== after) changed.push(plane); + } if ( [...prior.capabilities].sort().join(",") !== [...next.capabilities].sort().join(",") @@ -666,15 +664,27 @@ function publicationChanges( .sort() .join(SEPARATOR); if (spoken(priorDump) !== spoken(nextDump)) changed.push("warnings"); - if ( - (priorDump.diagnostics?.length ?? 0) !== - (nextDump.diagnostics?.length ?? 0) - ) { - changed.push("diagnostics"); - } return changed; } +function normalizedPublicationPlane(dump, plane) { + const rows = (dump[plane] ?? []).map((row) => { + if ( + row === null || + typeof row !== "object" || + (plane !== "coverage" && plane !== "unresolved") + ) { + return row; + } + // These are generation coordinates, not an independently changed fact. + // The branch already proves the build universe moved. Compare the coverage + // state and unresolved evidence without counting that same movement twice. + const { target: _target, universe: _universe, ...fact } = row; + return fact; + }); + return canonicalGenerationValue(rows); +} + /** A separator no warning can contain, so two lists cannot collide. */ const SEPARATOR = String.fromCharCode(0); diff --git a/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts b/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts index f28b6819..4639b02f 100644 --- a/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts +++ b/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts @@ -161,27 +161,31 @@ export const test_experiment_corpora_are_commit_pinned = () => { runner.includes("semanticLimitation.trim() ==="), ); // scip-python 0.6.6 recovers from a malformed `pyproject.toml`, falls back to - // Pyright defaults and emits no SCIP diagnostics. The pinned Click run proved - // that the fallback changes the analyzed program, so the row must exercise - // the degraded-publication branch rather than claim reject, diagnostic, or - // ignored-input behavior. + // Pyright defaults and emits no SCIP diagnostics. On the pinned Click + // fixture, the source and semantic fact planes stay unchanged. The aggregate + // content digest is not evidence to the contrary because its legacy coverage + // target is the already-moved universe. TestValidator.predicate( - "Python's malformed configuration is a changed degraded publication", - python.includes('failurePolicy: "published"') && + "Python's malformed configuration is a tolerated unchanged publication", + python.includes('failurePolicy: "tolerated"') && declares(python, "failureLimitation") && python.includes("falling back to Pyright defaults") && - lifecycle.includes('fixture.failurePolicy === "published"') && + lifecycle.includes('fixture.failurePolicy === "tolerated"') && lifecycle.includes("provenance.universe === prior.universe") && lifecycle.includes("publicationChanges(") && - lifecycle.includes("changed.length === 0"), + lifecycle.includes("changed.length !== 0") && + lifecycle.includes("normalizedPublicationPlane(") && + !lifecycle.includes("provenance.content !== prior.content"), ); TestValidator.predicate( - "a degraded publication is distinct from an input the producer ignored", - [python, lua].every( + "a degraded publication is distinct from an unchanged tolerated one", + [csharp, lua].every( (row) => row.includes('failurePolicy: "published"') && declares(row, "failureLimitation"), ) && + python.includes('failurePolicy: "tolerated"') && + lifecycle.includes('status: "tolerated"') && lifecycle.includes('fixture.failurePolicy === "published"') && lifecycle.includes('status: "published-with-limitation"') && lifecycle.includes("publicationChanges("), From 71721566a13636c018a35008c90ac85f82011f1d Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 31 Jul 2026 01:24:28 +0900 Subject: [PATCH 06/52] test: name Go fixture indexer receiver --- sidecars/go/main_test.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/sidecars/go/main_test.go b/sidecars/go/main_test.go index 2ddbf365..48545f21 100644 --- a/sidecars/go/main_test.go +++ b/sidecars/go/main_test.go @@ -673,7 +673,10 @@ func (indexer fixtureScipIndexer) Version(context.Context) (string, error) { return "scip-go v0.2.7", nil } -func (fixtureScipIndexer) Index(_ context.Context, moduleRoot string) (scipArtifact, error) { +func (indexer fixtureScipIndexer) Index( + _ context.Context, + moduleRoot string, +) (scipArtifact, error) { var documents []string definitions := map[string]int{} err := filepath.WalkDir(moduleRoot, func(file string, entry os.DirEntry, walkErr error) error { From be05d7c1a06cd450a1b348e96db9e5181e9c7873 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 31 Jul 2026 14:41:09 +0900 Subject: [PATCH 07/52] feat: convert TypeScript snapshots to graph protocol --- .../src/provider/GraphSnapshotProtocol.ts | 41 ++- .../src/provider/ttscgraph/TtscGraphClient.ts | 33 +- .../provider/ttscgraph/adaptTtscGraphDump.ts | 6 +- .../createTtscGraphProtocolTransaction.ts | 293 ++++++++++++++++++ ...otocol_commits_atomic_shard_generations.ts | 42 ++- ...euses_and_atomically_replaces_snapshots.ts | 50 +++ ...ph_dump_adapter_rejects_malformed_facts.ts | 2 + ...aph_native_requests_recover_from_stalls.ts | 7 +- ...tocol_adapter_deletes_dependency_shards.ts | 145 +++++++++ 9 files changed, 594 insertions(+), 25 deletions(-) create mode 100644 packages/graph/src/provider/ttscgraph/createTtscGraphProtocolTransaction.ts create mode 100644 tests/test-graph/src/features/test_ttscgraph_protocol_adapter_deletes_dependency_shards.ts diff --git a/packages/graph/src/provider/GraphSnapshotProtocol.ts b/packages/graph/src/provider/GraphSnapshotProtocol.ts index ee05ccd3..291492e6 100644 --- a/packages/graph/src/provider/GraphSnapshotProtocol.ts +++ b/packages/graph/src/provider/GraphSnapshotProtocol.ts @@ -79,6 +79,8 @@ export namespace GraphSnapshotProtocol { type: "hello"; protocolVersion: 1; schemaVersion: 1; + /** Schema version of the producer payload normalized into this protocol. */ + producerSchemaVersion: number; provider: string; producer: string; producerVersion: string; @@ -149,6 +151,15 @@ export namespace GraphSnapshotProtocol { return digest(shard); } + /** SHA-256 over one ordered source/configuration/dependency manifest. */ + export function manifestDigest(sources: readonly ISource[]): string { + return digest( + [...sources] + .sort((left, right) => compareText(left.file, right.file)) + .map((source) => ({ ...source })), + ); + } + /** * SHA-256 over the complete reconstructed fact payload. * @@ -248,7 +259,11 @@ export namespace GraphSnapshotProtocol { public apply( frames: readonly Frame[], - options: { signal?: AbortSignal } = {}, + options: { + signal?: AbortSignal; + warnings?: readonly string[]; + validate?: (snapshot: IBulkGraphSession.ISnapshot) => void; + } = {}, ): IBulkGraphSession.ISnapshot { throwIfAborted(options.signal); if (frames.length < 3) { @@ -381,7 +396,14 @@ export namespace GraphSnapshotProtocol { if (!equalManifest(commit.shards, expectedManifest)) { throw new Error("graph snapshot protocol: commit shard manifest mismatch"); } - const assembled = assemble(hello, begin, commit, expectedManifest, next); + const assembled = assemble( + hello, + begin, + commit, + expectedManifest, + next, + options.warnings ?? [], + ); assertAssembledFacts(assembled, hello); if (factDigest(assembled) !== commit.factDigest) { throw new Error("graph snapshot protocol: commit fact digest mismatch"); @@ -394,6 +416,8 @@ export namespace GraphSnapshotProtocol { ); throwIfAborted(options.signal); freezeDeep(assembled, "the graph snapshot protocol generation"); + options.validate?.(assembled); + throwIfAborted(options.signal); this.committed = next; this.identity = clone(hello); this.snapshot = assembled; @@ -412,6 +436,7 @@ export namespace GraphSnapshotProtocol { commit: ICommit, manifest: IBulkGraphSession.IShard[], shards: ReadonlyMap, + warnings: readonly string[], ): IBulkGraphSession.ISnapshot { const nodes: ISamchonGraphNode[] = []; const edges: ISamchonGraphEdge[] = []; @@ -454,7 +479,7 @@ export namespace GraphSnapshotProtocol { provider: hello.provider, authority: hello.authority, facts: [...hello.supportedFacts], - schemaVersion: hello.schemaVersion, + schemaVersion: hello.producerSchemaVersion, tool: hello.producer, toolVersion: hello.producerVersion, compilerVersion: hello.compilerVersion, @@ -479,7 +504,7 @@ export namespace GraphSnapshotProtocol { shards: manifest.map((entry) => ({ ...entry })), factDigest: commit.factDigest, }, - warnings: [], + warnings: [...warnings], }; } @@ -494,6 +519,14 @@ export namespace GraphSnapshotProtocol { `graph snapshot protocol: unsupported schema version ${String(hello.schemaVersion)}`, ); } + if ( + !Number.isSafeInteger(hello.producerSchemaVersion) || + hello.producerSchemaVersion < 1 + ) { + throw new Error( + "graph snapshot protocol: invalid producer schema version", + ); + } assertString(hello.provider, "hello.provider"); assertString(hello.producer, "hello.producer"); assertString(hello.producerVersion, "hello.producerVersion"); diff --git a/packages/graph/src/provider/ttscgraph/TtscGraphClient.ts b/packages/graph/src/provider/ttscgraph/TtscGraphClient.ts index 91f772ae..e8865567 100644 --- a/packages/graph/src/provider/ttscgraph/TtscGraphClient.ts +++ b/packages/graph/src/provider/ttscgraph/TtscGraphClient.ts @@ -1,11 +1,12 @@ import { ChildProcessWithoutNullStreams, spawn } from "node:child_process"; +import { compareOrdinal } from "@samchon/graph-sitter"; -import { freezeDeep } from "../../utils/freezeDeep"; -import { sealedMap } from "../../utils/sealedMap"; import { ownedProcess } from "../../utils/ownedProcess"; import { spawnableCommand } from "../../utils/spawnableCommand"; +import { GraphSnapshotProtocol } from "../GraphSnapshotProtocol"; import { IBulkGraphSession } from "../IBulkGraphSession"; import { adaptTtscGraphDump } from "./adaptTtscGraphDump"; +import { createTtscGraphProtocolTransaction } from "./createTtscGraphProtocolTransaction"; import { ITtscGraphSnapshot } from "./ITtscGraphSnapshot"; import { parseTtscGraphSnapshot } from "./parseTtscGraphSnapshot"; @@ -49,6 +50,7 @@ export class TtscGraphClient implements IBulkGraphSession { private readonly validate: ( snapshot: IBulkGraphSession.ISnapshot, ) => void; + private readonly protocol: GraphSnapshotProtocol.Store; private child: NativeChild | undefined; private readonly ownedChildren = new Set(); private readonly pending = new Map(); @@ -102,6 +104,7 @@ export class TtscGraphClient implements IBulkGraphSession { this.requestTimeoutMs = requestTimeoutMs; this.maxResponseBytes = maxResponseBytes; this.validate = options.validate ?? (() => undefined); + this.protocol = new GraphSnapshotProtocol.Store(this.root); } public get generation(): number { @@ -159,18 +162,16 @@ export class TtscGraphClient implements IBulkGraphSession { "ttscgraph: incremental snapshot reports a build universe that moved since the last generation, so its program cannot have been reused", ); } - const next: IBulkGraphSession.ISnapshot = { - languages: ["typescript"], - nodes: adapted.nodes, - edges: adapted.edges, - diagnostics: adapted.diagnostics, - sources: adapted.sources, - provenance, + const frames = createTtscGraphProtocolTransaction(adapted, { + root: this.root, + sequence: this.version + 1, + previous: this.snapshot, + }); + const next = this.protocol.apply(frames, { + signal: options.signal, warnings: adapted.warnings, - }; - next.sources = sealedMap(next.sources, "the ttscgraph snapshot"); - freezeDeep(next, "the ttscgraph snapshot"); - this.validate(next); + validate: this.validate, + }); this.snapshot = next; this.childHasSnapshot = true; this.version += 1; @@ -638,10 +639,8 @@ function assertCapabilitiesMatch( envelope: readonly string[], dump: readonly string[], ): void { - const compare = (left: string, right: string): number => - left < right ? -1 : left > right ? 1 : 0; - const left = JSON.stringify([...envelope].sort(compare)); - const right = JSON.stringify([...dump].sort(compare)); + const left = JSON.stringify([...new Set(envelope)].sort(compareOrdinal)); + const right = JSON.stringify([...new Set(dump)].sort(compareOrdinal)); if (left !== right) { throw new Error( "ttscgraph: response capabilities disagree with the snapshot provenance", diff --git a/packages/graph/src/provider/ttscgraph/adaptTtscGraphDump.ts b/packages/graph/src/provider/ttscgraph/adaptTtscGraphDump.ts index 99080c71..34ad8781 100644 --- a/packages/graph/src/provider/ttscgraph/adaptTtscGraphDump.ts +++ b/packages/graph/src/provider/ttscgraph/adaptTtscGraphDump.ts @@ -27,6 +27,7 @@ import { ITtscGraphSnapshot } from "./ITtscGraphSnapshot"; * version of the frame that carried it. */ interface IAdaptedDump { + target: string; nodes: ISamchonGraphNode[]; edges: ISamchonGraphEdge[]; diagnostics: ISamchonGraphDiagnostic[]; @@ -82,6 +83,8 @@ export function adaptTtscGraphDump( `ttscgraph: response project ${project} does not match ${expectedRoot}`, ); } + const target = stringOf(dump.tsconfig, "dump.tsconfig"); + validateGraphFile(target, "dump.tsconfig"); const rawNodes = arrayOf(dump.nodes, "dump.nodes"); const rawEdges = arrayOf(dump.edges, "dump.edges"); const moduleIds = new Map(); @@ -267,6 +270,7 @@ export function adaptTtscGraphDump( : refuseDiagnostics(dump.diagnostics, warnings); return { + target, nodes, edges, diagnostics, @@ -449,7 +453,7 @@ function provenanceOf( "dump.provenance.producer.typescript", ), universe: universeOf(provenance.universe), - capabilities, + capabilities: [...new Set(capabilities)].sort(compareOrdinal), }; } diff --git a/packages/graph/src/provider/ttscgraph/createTtscGraphProtocolTransaction.ts b/packages/graph/src/provider/ttscgraph/createTtscGraphProtocolTransaction.ts new file mode 100644 index 00000000..2ba42c66 --- /dev/null +++ b/packages/graph/src/provider/ttscgraph/createTtscGraphProtocolTransaction.ts @@ -0,0 +1,293 @@ +import path from "node:path"; + +import { ISamchonGraphCoverage } from "../../structures"; +import { GRAPH_EDGE_KINDS } from "../../typings"; +import { GraphSnapshotProtocol } from "../GraphSnapshotProtocol"; +import { IBulkGraphSession } from "../IBulkGraphSession"; +import { adaptTtscGraphDump } from "./adaptTtscGraphDump"; + +type IAdaptedTtscGraphDump = ReturnType; + +/** + * Normalize one complete compiler dump into a Graph Snapshot Protocol + * transaction. The native process still owns semantic incrementality; this + * adapter adds content-addressed file shards so the graph store can reuse every + * unchanged part of the last committed generation. + */ +export function createTtscGraphProtocolTransaction( + input: IAdaptedTtscGraphDump, + options: { + root: string; + sequence: number; + previous?: IBulkGraphSession.ISnapshot; + }, +): GraphSnapshotProtocol.Frame[] { + const hello: GraphSnapshotProtocol.IHello = { + type: "hello", + protocolVersion: GraphSnapshotProtocol.VERSION, + schemaVersion: GraphSnapshotProtocol.SCHEMA_VERSION, + producerSchemaVersion: input.provenance.schemaVersion, + provider: input.provenance.provider, + producer: input.provenance.tool, + producerVersion: input.provenance.toolVersion, + compilerVersion: input.provenance.compilerVersion, + languages: ["typescript"], + authority: input.provenance.authority, + supportedFacts: [...input.provenance.facts], + capabilities: [...input.provenance.capabilities], + }; + const sources = [...input.sources].map(([file, digest]) => ({ + file, + checkerDigest: digest.checkerDigest, + diskDigest: digest.diskDigest, + })); + const manifest = GraphSnapshotProtocol.manifestDigest(sources); + const shards = shardTtscGraph(input, options.root, sources); + const ordered = [...shards].sort(([left], [right]) => + compareText(left, right), + ); + const shardManifest = ordered.map(([key, shard]) => ({ + key, + digest: GraphSnapshotProtocol.shardDigest(shard), + })); + const previous = options.previous; + const canReuse = + previous?.protocol !== undefined && + previous.provenance.universe === input.provenance.universe && + sameList(previous.protocol.targets, [input.target]) && + sameProducer(previous.provenance, input.provenance); + const begin: GraphSnapshotProtocol.IBegin = { + type: "begin", + sequence: options.sequence, + generation: "", + ...(canReuse + ? { + baseSequence: previous.protocol!.sequence, + baseGeneration: previous.protocol!.generation, + } + : {}), + universe: input.provenance.universe, + manifest, + targets: [input.target], + }; + const snapshot = assembledSnapshot(hello, begin, ordered); + const factDigest = GraphSnapshotProtocol.factDigest(snapshot); + begin.generation = factDigest; + + const previousShards = new Map( + canReuse + ? previous.protocol!.shards.map((entry) => [entry.key, entry.digest]) + : [], + ); + const nextKeys = new Set(shards.keys()); + const deltas: GraphSnapshotProtocol.Frame[] = []; + if (canReuse) { + for (const key of previousShards.keys()) { + if (!nextKeys.has(key)) deltas.push({ type: "deleteShard", key }); + } + } + for (const [key, shard] of ordered) { + const digest = GraphSnapshotProtocol.shardDigest(shard); + if (!canReuse || previousShards.get(key) !== digest) { + deltas.push({ type: "upsertShard", digest, shard }); + } + } + return [ + hello, + begin, + ...deltas, + { + type: "commit", + sequence: begin.sequence, + generation: begin.generation, + shards: shardManifest, + factDigest, + }, + ]; +} + +function shardTtscGraph( + input: IAdaptedTtscGraphDump, + root: string, + sources: readonly GraphSnapshotProtocol.ISource[], +): Map { + const output = new Map(); + const sourceShardByFile = new Map(); + const sourceFileByNode = new Map( + input.nodes.map((node) => [node.id, sourceFile(root, node.file)]), + ); + for (const source of sources) { + const key = sourceShardKey(input, root, source); + const shard: GraphSnapshotProtocol.IShard = { + key, + target: input.target, + languages: ["typescript"], + nodes: [], + edges: [], + diagnostics: [], + coverage: [], + unresolved: [], + sources: [{ ...source }], + }; + output.set(key, shard); + sourceShardByFile.set(source.file, shard); + } + + for (const node of input.nodes) { + sourceShard(sourceShardByFile, sourceFile(root, node.file)).nodes.push(node); + } + for (const edge of input.edges) { + // adaptTtscGraphDump already proved every edge source is a published node. + const file = sourceFileByNode.get(edge.from)!; + sourceShard(sourceShardByFile, file).edges.push(edge); + } + + const coverageShard: GraphSnapshotProtocol.IShard = { + key: metadataShardKey(input), + target: input.target, + languages: ["typescript"], + nodes: [], + edges: [], + diagnostics: [], + coverage: coverageOf(input), + unresolved: [], + sources: [], + }; + for (const diagnostic of input.diagnostics) { + if (diagnostic.file === "") coverageShard.diagnostics.push(diagnostic); + else + sourceShard( + sourceShardByFile, + sourceFile(root, diagnostic.file), + ).diagnostics.push(diagnostic); + } + output.set(coverageShard.key, coverageShard); + return output; +} + +function coverageOf( + input: IAdaptedTtscGraphDump, +): ISamchonGraphCoverage[] { + const supported = new Set(input.provenance.facts); + return GRAPH_EDGE_KINDS.map((family) => ({ + provider: input.provenance.provider, + language: "typescript", + target: input.target, + family, + state: supported.has(family) ? "complete" : "unsupported", + })); +} + +function assembledSnapshot( + hello: GraphSnapshotProtocol.IHello, + begin: GraphSnapshotProtocol.IBegin, + shards: readonly [string, GraphSnapshotProtocol.IShard][], +): Pick< + IBulkGraphSession.ISnapshot, + | "languages" + | "nodes" + | "edges" + | "diagnostics" + | "coverage" + | "unresolved" + | "provenance" +> { + const values = shards.map(([, shard]) => shard); + return { + languages: [...hello.languages], + nodes: values.flatMap((shard) => shard.nodes), + edges: values.flatMap((shard) => shard.edges), + diagnostics: values.flatMap((shard) => shard.diagnostics), + coverage: values.flatMap((shard) => shard.coverage), + unresolved: values.flatMap((shard) => shard.unresolved), + provenance: { + provider: hello.provider, + authority: hello.authority, + facts: [...hello.supportedFacts], + schemaVersion: hello.producerSchemaVersion, + tool: hello.producer, + toolVersion: hello.producerVersion, + compilerVersion: hello.compilerVersion, + protocolVersion: hello.protocolVersion, + universe: begin.universe, + capabilities: [...hello.capabilities], + }, + }; +} + +function sourceShard( + shards: ReadonlyMap, + file: string, +): GraphSnapshotProtocol.IShard { + // adaptTtscGraphDump already bound every fact file to this exact manifest. + return shards.get(file)!; +} + +function sourceFile(root: string, file: string): string { + return file.startsWith("bundled:///") ? file : path.resolve(root, file); +} + +function sourceShardKey( + input: IAdaptedTtscGraphDump, + root: string, + source: GraphSnapshotProtocol.ISource, +): string { + const bundled = source.file.startsWith("bundled:///"); + const identity = bundled + ? source.file + : path.relative(root, source.file).replaceAll("\\", "/"); + return `${bundled ? "2" : "1"}:source:${JSON.stringify([ + GraphSnapshotProtocol.VERSION, + input.provenance.provider, + input.provenance.toolVersion, + input.provenance.compilerVersion, + "typescript", + input.target, + input.provenance.universe, + identity, + source.checkerDigest, + ])}`; +} + +function metadataShardKey(input: IAdaptedTtscGraphDump): string { + return `0:coverage:${JSON.stringify([ + GraphSnapshotProtocol.VERSION, + input.provenance.provider, + input.provenance.toolVersion, + input.provenance.compilerVersion, + "typescript", + input.target, + input.provenance.universe, + ])}`; +} + +function sameProducer( + left: IBulkGraphSession.IProvenance, + right: Omit, +): boolean { + return ( + left.provider === right.provider && + left.authority === right.authority && + left.schemaVersion === right.schemaVersion && + left.tool === right.tool && + left.toolVersion === right.toolVersion && + left.compilerVersion === right.compilerVersion && + sameList(left.facts, right.facts) && + sameList(left.capabilities, right.capabilities) + ); +} + +function sameList( + left: readonly string[], + right: readonly string[], +): boolean { + return ( + left.length === right.length && + left.every((value, index) => value === right[index]) + ); +} + +function compareText(left: string, right: string): number { + /* c8 ignore next -- shard keys are unique. */ + return left < right ? -1 : left > right ? 1 : 0; +} diff --git a/tests/test-graph/src/features/test_graph_snapshot_protocol_commits_atomic_shard_generations.ts b/tests/test-graph/src/features/test_graph_snapshot_protocol_commits_atomic_shard_generations.ts index c80b637e..fba54bbb 100644 --- a/tests/test-graph/src/features/test_graph_snapshot_protocol_commits_atomic_shard_generations.ts +++ b/tests/test-graph/src/features/test_graph_snapshot_protocol_commits_atomic_shard_generations.ts @@ -29,7 +29,13 @@ export const test_graph_snapshot_protocol_commits_atomic_shard_generations = const initialFrames = transaction("generation-1"); const ndjson = initialFrames.map(JSON.stringify).join("\n"); const parsed = GraphSnapshotProtocol.parse(ndjson); - const initial = store.apply(parsed); + let validations = 0; + const initial = store.apply(parsed, { + warnings: ["fixture host warning"], + validate: () => { + validations += 1; + }, + }); const provider = { name: "fixture-compiler", authority: "compiler" as const, @@ -52,6 +58,8 @@ export const test_graph_snapshot_protocol_commits_atomic_shard_generations = initial.nodes.map((node) => node.name), initial.coverage?.length, initial.unresolved?.map((site) => site.reason), + initial.warnings, + validations, ], [ 1, @@ -61,6 +69,8 @@ export const test_graph_snapshot_protocol_commits_atomic_shard_generations = ["run"], GRAPH_EDGE_KINDS.length, ["dynamic", "reflection"], + ["fixture host warning"], + 1, ], ); TestValidator.equals( @@ -186,6 +196,29 @@ export const test_graph_snapshot_protocol_commits_atomic_shard_generations = transaction("generation-3", { sequence: 3 }), "a non-advancing generation sequence", ); + let validationError = ""; + try { + store.apply( + transaction("validator-rejected-4", { + baseGeneration: "generation-3", + baseSequence: 3, + coverageState: "complete", + }), + { + validate: () => { + throw new Error("fixture publication refusal"); + }, + }, + ); + } catch (error) { + validationError = + error instanceof Error ? error.message : String(error); + } + TestValidator.predicate( + "a host publication refusal keeps the prior committed generation", + validationError.includes("fixture publication refusal") && + store.current === deleted, + ); await rejectedWithoutMovement( store, mutate( @@ -630,6 +663,7 @@ function validHello(): GraphSnapshotProtocol.IHello { type: "hello", protocolVersion: 1, schemaVersion: GraphSnapshotProtocol.SCHEMA_VERSION, + producerSchemaVersion: 1, provider: "fixture-compiler", producer: "fixture-exporter", producerVersion: "1.0.0", @@ -742,6 +776,12 @@ function malformedTransactions(): Array< record(frames[0]!).schemaVersion = 2; }), ], + [ + "an invalid producer schema version", + mutate(valid, (frames) => { + record(frames[0]!).producerSchemaVersion = 0; + }), + ], [ "duplicate hello languages", mutate(valid, (frames) => { diff --git a/tests/test-graph/src/features/test_ttscgraph_bulk_provider_reuses_and_atomically_replaces_snapshots.ts b/tests/test-graph/src/features/test_ttscgraph_bulk_provider_reuses_and_atomically_replaces_snapshots.ts index e8b1679a..c1c1b7f6 100644 --- a/tests/test-graph/src/features/test_ttscgraph_bulk_provider_reuses_and_atomically_replaces_snapshots.ts +++ b/tests/test-graph/src/features/test_ttscgraph_bulk_provider_reuses_and_atomically_replaces_snapshots.ts @@ -9,6 +9,7 @@ import { TtscGraphClient } from "../../../../packages/graph/src/provider/ttscgra import { resolveTtscGraphCommand } from "../../../../packages/graph/src/provider/ttscgraph/resolveTtscGraphCommand"; import { ttscGraphProvider } from "../../../../packages/graph/src/provider/ttscgraph/ttscGraphProvider"; import { ISamchonGraphDump } from "../../../../packages/graph/src/structures"; +import { GRAPH_EDGE_KINDS } from "../../../../packages/graph/src/typings/GRAPH_EDGE_KINDS"; import { GraphPaths } from "../internal/GraphPaths"; export const test_ttscgraph_bulk_provider_reuses_and_atomically_replaces_snapshots = @@ -108,6 +109,34 @@ export const test_ttscgraph_bulk_provider_reuses_and_atomically_replaces_snapsho initial.snapshot.provenance.schemaVersion, 6, ); + TestValidator.equals( + "the reference provider publishes one exhaustive protocol coverage matrix", + [ + initial.snapshot.protocol?.sequence, + initial.snapshot.protocol?.targets, + initial.snapshot.coverage?.length, + initial.snapshot.coverage?.filter((row) => row.state === "complete") + .length, + initial.snapshot.coverage?.filter( + (row) => row.state === "unsupported", + ).length, + initial.snapshot.unresolved, + ], + [ + 1, + ["tsconfig.json"], + GRAPH_EDGE_KINDS.length, + ttscGraphProvider.facts.length, + GRAPH_EDGE_KINDS.length - ttscGraphProvider.facts.length, + [], + ], + ); + const initialShards = new Map( + initial.snapshot.protocol?.shards.map((shard) => [ + shard.key, + shard.digest, + ]), + ); TestValidator.equals( "the first snapshot reports the compiler's own mode, not an inferred one", initial.mode, @@ -154,6 +183,27 @@ export const test_ttscgraph_bulk_provider_reuses_and_atomically_replaces_snapsho changed.mode, "incremental", ); + const changedShards = new Map( + changed.snapshot.protocol?.shards.map((shard) => [ + shard.key, + shard.digest, + ]), + ); + TestValidator.equals( + "the incremental generation is based on the prior commit and reuses unaffected file shards", + [ + changed.snapshot.protocol?.baseSequence, + changed.snapshot.protocol?.baseGeneration, + [...changedShards].filter( + ([key, digest]) => initialShards.get(key) === digest, + ).length, + [...changedShards].filter( + ([key, digest]) => + initialShards.has(key) && initialShards.get(key) !== digest, + ).length, + ], + [1, initial.snapshot.protocol?.generation, 4, 2], + ); await rejects(client.refresh(), "serve errors are surfaced"); TestValidator.predicate( "an untrusted child generation preserves the previous trusted snapshot", diff --git a/tests/test-graph/src/features/test_ttscgraph_dump_adapter_rejects_malformed_facts.ts b/tests/test-graph/src/features/test_ttscgraph_dump_adapter_rejects_malformed_facts.ts index 3c952b4c..12558475 100644 --- a/tests/test-graph/src/features/test_ttscgraph_dump_adapter_rejects_malformed_facts.ts +++ b/tests/test-graph/src/features/test_ttscgraph_dump_adapter_rejects_malformed_facts.ts @@ -48,6 +48,7 @@ export const test_ttscgraph_dump_adapter_rejects_malformed_facts = async () => { const good = () => ({ project, + tsconfig: "tsconfig.json", provenance: provenance(), diagnostics: [] as unknown[], nodes: [ @@ -606,6 +607,7 @@ export const test_ttscgraph_dump_adapter_rejects_malformed_facts = async () => { const rich = adaptTtscGraphDump( { project, + tsconfig: "tsconfig.json", provenance: provenance(["src/a.ts", "vendor/dep.ts"]), diagnostics: [], nodes: [ diff --git a/tests/test-graph/src/features/test_ttscgraph_native_requests_recover_from_stalls.ts b/tests/test-graph/src/features/test_ttscgraph_native_requests_recover_from_stalls.ts index 4d7851c0..e407dafa 100644 --- a/tests/test-graph/src/features/test_ttscgraph_native_requests_recover_from_stalls.ts +++ b/tests/test-graph/src/features/test_ttscgraph_native_requests_recover_from_stalls.ts @@ -102,7 +102,7 @@ export const test_ttscgraph_native_requests_recover_from_stalls = async () => { const queuedAbortClient = create( queuedAbortRoot, path.join(queuedAbortRoot, "first-child.txt"), - 5_000, + 15_000, queuedAbortLog, ); const active = queuedAbortClient.refresh(); @@ -395,7 +395,10 @@ const delay = (milliseconds: number): Promise => new Promise((resolve) => setTimeout(resolve, milliseconds)); const waitForFile = async (file: string): Promise => { - const deadline = Date.now() + 5_000; + // c8 instruments the whole package before this Windows Job child starts. + // This is fixture-readiness time, not the native request deadline asserted + // above, so keep enough headroom for an instrumented cold process launch. + const deadline = Date.now() + 15_000; while (!hasContents(file)) { if (Date.now() >= deadline) { throw new Error(`timed out waiting for ${file}`); diff --git a/tests/test-graph/src/features/test_ttscgraph_protocol_adapter_deletes_dependency_shards.ts b/tests/test-graph/src/features/test_ttscgraph_protocol_adapter_deletes_dependency_shards.ts new file mode 100644 index 00000000..e4778318 --- /dev/null +++ b/tests/test-graph/src/features/test_ttscgraph_protocol_adapter_deletes_dependency_shards.ts @@ -0,0 +1,145 @@ +import { TestValidator } from "@nestia/e2e"; +import { createHash } from "node:crypto"; +import path from "node:path"; + +import { GraphSnapshotProtocol } from "../../../../packages/graph/src/provider/GraphSnapshotProtocol"; +import { adaptTtscGraphDump } from "../../../../packages/graph/src/provider/ttscgraph/adaptTtscGraphDump"; +import { createTtscGraphProtocolTransaction } from "../../../../packages/graph/src/provider/ttscgraph/createTtscGraphProtocolTransaction"; +import { GraphPaths } from "../internal/GraphPaths"; + +const sha256 = (text: string): string => + createHash("sha256").update(text).digest("hex"); + +/** + * The TypeScript reference adapter keeps dependency churn inside the protocol: + * a dependency that leaves the compiler manifest becomes an explicit shard + * deletion, while a global compiler finding belongs to the target metadata + * shard rather than to an arbitrary source. + */ +export const test_ttscgraph_protocol_adapter_deletes_dependency_shards = + async () => { + const root = GraphPaths.createTempDirectory( + "samchon-graph-ttscgraph-protocol-", + ); + const store = new GraphSnapshotProtocol.Store(root); + const initialFrames = createTtscGraphProtocolTransaction( + adaptTtscGraphDump(dump(root, true, false), root), + { root, sequence: 1 }, + ); + const initial = store.apply(initialFrames); + const changedFrames = createTtscGraphProtocolTransaction( + adaptTtscGraphDump(dump(root, false, true), root), + { root, sequence: 2, previous: initial }, + ); + TestValidator.equals( + "a removed dependency is carried as one explicit shard deletion", + changedFrames.filter((frame) => frame.type === "deleteShard").length, + 1, + ); + + const changed = store.apply(changedFrames); + TestValidator.equals( + "the delta removes the dependency and retains a global diagnostic", + [ + changed.protocol?.baseSequence, + changed.nodes.some((node) => node.name === "Dependency"), + changed.sources.has(path.join(root, "vendor", "dependency.d.ts")), + changed.diagnostics, + ], + [ + 1, + false, + false, + [ + { + file: "", + line: 0, + column: 0, + code: 9999, + message: "synthetic global finding", + severity: "warning", + }, + ], + ], + ); + }; + +function dump( + root: string, + dependency: boolean, + globalDiagnostic: boolean, +): unknown { + const files = [ + "src/main.ts", + ...(dependency ? ["vendor/dependency.d.ts"] : []), + ]; + return { + project: root, + tsconfig: "tsconfig.json", + provenance: { + schemaVersion: 6, + capabilities: [ + "universe", + "sourceDigests", + "diskDigests", + "diagnostics", + ], + producer: { + tool: "ttscgraph", + version: "0.20.1", + typescript: "5.9.0", + }, + universe: { + configs: [ + { file: "tsconfig.json", digest: sha256("configuration") }, + ], + roots: [{ config: "tsconfig.json", file: "src/main.ts" }], + }, + sources: files.map((file) => ({ + file, + checkerDigest: sha256(`${file}:checker`), + diskDigest: sha256(`${file}:disk`), + })), + }, + diagnostics: globalDiagnostic + ? [ + { + file: "", + line: 0, + column: 0, + code: 9999, + category: "warning", + message: "synthetic global finding", + }, + ] + : [], + nodes: [ + { + id: "src/main.ts#src/main.ts:module", + kind: "module", + name: "src/main.ts", + file: "src/main.ts", + external: false, + }, + { + id: "src/main.ts#run:function", + kind: "function", + name: "run", + file: "src/main.ts", + external: false, + }, + ...(dependency + ? [ + { + id: "vendor/dependency.d.ts#Dependency:interface", + kind: "interface", + name: "Dependency", + file: "vendor/dependency.d.ts", + external: true, + }, + ] + : []), + ], + edges: [], + }; +} From d02b5e773084747d280f8d542ea229562965b4f7 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 31 Jul 2026 14:55:56 +0900 Subject: [PATCH 08/52] fix: preserve graph protocol uncertainty --- .../src/provider/GraphSnapshotProtocol.ts | 21 ++++++++- .../provider/ttscgraph/adaptTtscGraphDump.ts | 11 ++++- .../createTtscGraphProtocolTransaction.ts | 22 ++++++++- ...otocol_commits_atomic_shard_generations.ts | 47 +++++++++++++------ ...euses_and_atomically_replaces_snapshots.ts | 11 ++++- ...ph_dump_adapter_rejects_malformed_facts.ts | 11 +++++ 6 files changed, 101 insertions(+), 22 deletions(-) diff --git a/packages/graph/src/provider/GraphSnapshotProtocol.ts b/packages/graph/src/provider/GraphSnapshotProtocol.ts index 291492e6..faa09100 100644 --- a/packages/graph/src/provider/GraphSnapshotProtocol.ts +++ b/packages/graph/src/provider/GraphSnapshotProtocol.ts @@ -151,7 +151,13 @@ export namespace GraphSnapshotProtocol { return digest(shard); } - /** SHA-256 over one ordered source/configuration/dependency manifest. */ + /** + * SHA-256 over the ordered input-file manifest carried by the shards. + * + * Producers include source, configuration, generated and dependency inputs + * here. The store recomputes this digest from the reconstructed generation, + * so `begin.manifest` is evidence rather than an unchecked producer label. + */ export function manifestDigest(sources: readonly ISource[]): string { return digest( [...sources] @@ -405,6 +411,19 @@ export namespace GraphSnapshotProtocol { options.warnings ?? [], ); assertAssembledFacts(assembled, hello); + if ( + manifestDigest( + [...assembled.sources].map(([file, source]) => ({ + file, + checkerDigest: source.checkerDigest, + diskDigest: source.diskDigest, + })), + ) !== begin.manifest + ) { + throw new Error( + "graph snapshot protocol: input manifest digest mismatch", + ); + } if (factDigest(assembled) !== commit.factDigest) { throw new Error("graph snapshot protocol: commit fact digest mismatch"); } diff --git a/packages/graph/src/provider/ttscgraph/adaptTtscGraphDump.ts b/packages/graph/src/provider/ttscgraph/adaptTtscGraphDump.ts index 34ad8781..e314068f 100644 --- a/packages/graph/src/provider/ttscgraph/adaptTtscGraphDump.ts +++ b/packages/graph/src/provider/ttscgraph/adaptTtscGraphDump.ts @@ -279,6 +279,7 @@ export function adaptTtscGraphDump( dump.provenance, schemaVersion as number, capabilities, + target, ), warnings, }; @@ -356,7 +357,7 @@ function manifestOf( * would fingerprint identically, and a universe change that reshuffled exactly * that way would look like no change at all. */ -function universeOf(value: unknown): string { +function universeOf(value: unknown, target: string): string { const universe = objectOf(value, "dump.provenance.universe"); const hash = createHash("sha256"); const push = (text: string): void => { @@ -392,6 +393,11 @@ function universeOf(value: unknown): string { ), ); } + if (!configFiles.has(target)) { + throw new Error( + `ttscgraph: dump.tsconfig names an unknown build-universe config: ${target}`, + ); + } const roots = arrayOf(universe.roots, "dump.provenance.universe.roots"); push("roots"); const rootsByConfig = new Map>(); @@ -425,6 +431,7 @@ function provenanceOf( value: unknown, schemaVersion: number, capabilities: string[], + target: string, ): Omit { const provenance = objectOf(value, "dump.provenance"); // Read the universe even though only the fingerprint is kept: skipping the @@ -452,7 +459,7 @@ function provenanceOf( producer.typescript, "dump.provenance.producer.typescript", ), - universe: universeOf(provenance.universe), + universe: universeOf(provenance.universe, target), capabilities: [...new Set(capabilities)].sort(compareOrdinal), }; } diff --git a/packages/graph/src/provider/ttscgraph/createTtscGraphProtocolTransaction.ts b/packages/graph/src/provider/ttscgraph/createTtscGraphProtocolTransaction.ts index 2ba42c66..e02aa229 100644 --- a/packages/graph/src/provider/ttscgraph/createTtscGraphProtocolTransaction.ts +++ b/packages/graph/src/provider/ttscgraph/createTtscGraphProtocolTransaction.ts @@ -150,7 +150,7 @@ function shardTtscGraph( edges: [], diagnostics: [], coverage: coverageOf(input), - unresolved: [], + unresolved: unresolvedOf(input), sources: [], }; for (const diagnostic of input.diagnostics) { @@ -174,7 +174,25 @@ function coverageOf( language: "typescript", target: input.target, family, - state: supported.has(family) ? "complete" : "unsupported", + state: supported.has(family) ? "partial" : "unsupported", + })); +} + +function unresolvedOf( + input: IAdaptedTtscGraphDump, +): GraphSnapshotProtocol.IShard["unresolved"] { + return input.provenance.facts.map((family) => ({ + provider: input.provenance.provider, + language: "typescript", + target: input.target, + universe: input.provenance.universe, + family, + evidence: { + file: input.target, + startLine: 1, + startCol: 1, + }, + reason: "provider-gap", })); } diff --git a/tests/test-graph/src/features/test_graph_snapshot_protocol_commits_atomic_shard_generations.ts b/tests/test-graph/src/features/test_graph_snapshot_protocol_commits_atomic_shard_generations.ts index fba54bbb..4cc5181e 100644 --- a/tests/test-graph/src/features/test_graph_snapshot_protocol_commits_atomic_shard_generations.ts +++ b/tests/test-graph/src/features/test_graph_snapshot_protocol_commits_atomic_shard_generations.ts @@ -64,7 +64,13 @@ export const test_graph_snapshot_protocol_commits_atomic_shard_generations = [ 1, "generation-1", - digest("b"), + GraphSnapshotProtocol.manifestDigest([ + { + file: path.resolve("src/main.ts"), + checkerDigest: digest("c"), + diskDigest: digest("c"), + }, + ]), ["coverage", "source"], ["run"], GRAPH_EDGE_KINDS.length, @@ -356,16 +362,12 @@ export const test_graph_snapshot_protocol_commits_atomic_shard_generations = const manifestStore = new GraphSnapshotProtocol.Store(process.cwd()); manifestStore.apply(transaction("manifest-generation-1")); - const manifestEdit = mutate( - transaction("manifest-generation-2", { - baseGeneration: "manifest-generation-1", - baseSequence: 1, - nodeName: "manifest-edited", - }), - (frames) => { - (frames[1] as GraphSnapshotProtocol.IBegin).manifest = digest("d"); - }, - ); + const manifestEdit = transaction("manifest-generation-2", { + baseGeneration: "manifest-generation-1", + baseSequence: 1, + nodeName: "manifest-edited", + sourceDigest: "d", + }); TestValidator.equals( "manifest movement commits when a shard delta carries the affected facts", manifestStore.apply(manifestEdit).nodes.map((node) => node.name), @@ -376,6 +378,7 @@ export const test_graph_snapshot_protocol_commits_atomic_shard_generations = baseGeneration: "manifest-generation-2", baseSequence: 2, nodeName: "manifest-edited", + sourceDigest: "d", }), (frames) => { (frames[1] as GraphSnapshotProtocol.IBegin).manifest = digest("e"); @@ -391,6 +394,7 @@ export const test_graph_snapshot_protocol_commits_atomic_shard_generations = baseGeneration: "manifest-generation-2", baseSequence: 2, nodeName: "manifest-edited", + sourceDigest: "d", }), (frames) => { (frames[1] as GraphSnapshotProtocol.IBegin).universe = digest("d"); @@ -491,6 +495,7 @@ interface ITransactionOptions { baseGeneration?: string; coverageState?: "complete" | "partial"; nodeName?: string; + sourceDigest?: string; deleteSource?: boolean; } @@ -511,7 +516,7 @@ function transaction( } : {}), universe: digest("a"), - manifest: digest("b"), + manifest: digest("pending manifest"), targets: ["app"], }; const coverage: GraphSnapshotProtocol.IShard = { @@ -581,8 +586,8 @@ function transaction( sources: [ { file: path.resolve("src/main.ts"), - checkerDigest: digest("c"), - diskDigest: digest("c"), + checkerDigest: digest(options.sourceDigest ?? "c"), + diskDigest: digest(options.sourceDigest ?? "c"), }, ], }; @@ -607,6 +612,9 @@ function transaction( key, digest: GraphSnapshotProtocol.shardDigest(shard), })); + begin.manifest = GraphSnapshotProtocol.manifestDigest( + [...retained.values()].flatMap((shard) => shard.sources), + ); const snapshot = snapshotOf(hello, begin, [...retained.values()]); return [ hello, @@ -647,7 +655,7 @@ function snapshotOf( provider: hello.provider, authority: hello.authority, facts: [...hello.supportedFacts], - schemaVersion: hello.schemaVersion, + schemaVersion: hello.producerSchemaVersion, tool: hello.producer, toolVersion: hello.producerVersion, compilerVersion: hello.compilerVersion, @@ -713,6 +721,9 @@ function refreshDigests(frames: GraphSnapshotProtocol.Frame[]): void { return frame.shard; }); const last = commit(frames); + begin.manifest = GraphSnapshotProtocol.manifestDigest( + shards.flatMap((shard) => shard.sources), + ); last.shards = shards .map((shard) => ({ key: shard.key, @@ -782,6 +793,12 @@ function malformedTransactions(): Array< record(frames[0]!).producerSchemaVersion = 0; }), ], + [ + "an input manifest digest mismatch", + mutate(valid, (frames) => { + record(frames[1]!).manifest = digest("f"); + }), + ], [ "duplicate hello languages", mutate(valid, (frames) => { diff --git a/tests/test-graph/src/features/test_ttscgraph_bulk_provider_reuses_and_atomically_replaces_snapshots.ts b/tests/test-graph/src/features/test_ttscgraph_bulk_provider_reuses_and_atomically_replaces_snapshots.ts index c1c1b7f6..08e92bce 100644 --- a/tests/test-graph/src/features/test_ttscgraph_bulk_provider_reuses_and_atomically_replaces_snapshots.ts +++ b/tests/test-graph/src/features/test_ttscgraph_bulk_provider_reuses_and_atomically_replaces_snapshots.ts @@ -117,18 +117,25 @@ export const test_ttscgraph_bulk_provider_reuses_and_atomically_replaces_snapsho initial.snapshot.coverage?.length, initial.snapshot.coverage?.filter((row) => row.state === "complete") .length, + initial.snapshot.coverage?.filter((row) => row.state === "partial") + .length, initial.snapshot.coverage?.filter( (row) => row.state === "unsupported", ).length, - initial.snapshot.unresolved, + initial.snapshot.unresolved?.length, + initial.snapshot.unresolved?.every( + (site) => site.reason === "provider-gap", + ), ], [ 1, ["tsconfig.json"], GRAPH_EDGE_KINDS.length, + 0, ttscGraphProvider.facts.length, GRAPH_EDGE_KINDS.length - ttscGraphProvider.facts.length, - [], + ttscGraphProvider.facts.length, + true, ], ); const initialShards = new Map( diff --git a/tests/test-graph/src/features/test_ttscgraph_dump_adapter_rejects_malformed_facts.ts b/tests/test-graph/src/features/test_ttscgraph_dump_adapter_rejects_malformed_facts.ts index 12558475..0be0c6b4 100644 --- a/tests/test-graph/src/features/test_ttscgraph_dump_adapter_rejects_malformed_facts.ts +++ b/tests/test-graph/src/features/test_ttscgraph_dump_adapter_rejects_malformed_facts.ts @@ -105,6 +105,17 @@ export const test_ttscgraph_dump_adapter_rejects_malformed_facts = async () => { "a well-formed dump adapts cleanly", adaptTtscGraphDump(good(), project).nodes.length === 2, ); + rejectsWithMessage( + () => + adaptTtscGraphDump( + mutate((d) => { + d.tsconfig = "tsconfig.missing.json"; + }), + project, + ), + "a dump target absent from its build universe", + "dump.tsconfig names an unknown build-universe config", + ); rejectsWithMessage( () => adaptTtscGraphDump( From 13da35ddbd471f285388720365e2fbe7e61bcf49 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 31 Jul 2026 15:04:30 +0900 Subject: [PATCH 09/52] fix: canonicalize shared protocol inputs --- .../src/provider/GraphSnapshotProtocol.ts | 16 ++++++- ...otocol_commits_atomic_shard_generations.ts | 44 ++++++++++++++++--- 2 files changed, 53 insertions(+), 7 deletions(-) diff --git a/packages/graph/src/provider/GraphSnapshotProtocol.ts b/packages/graph/src/provider/GraphSnapshotProtocol.ts index faa09100..b932320d 100644 --- a/packages/graph/src/provider/GraphSnapshotProtocol.ts +++ b/packages/graph/src/provider/GraphSnapshotProtocol.ts @@ -159,8 +159,22 @@ export namespace GraphSnapshotProtocol { * so `begin.manifest` is evidence rather than an unchecked producer label. */ export function manifestDigest(sources: readonly ISource[]): string { + const unique = new Map(); + for (const source of sources) { + const prior = unique.get(source.file); + if ( + prior !== undefined && + (prior.checkerDigest !== source.checkerDigest || + prior.diskDigest !== source.diskDigest) + ) { + throw new Error( + `graph snapshot protocol: input manifest disagrees about source ${source.file}`, + ); + } + unique.set(source.file, source); + } return digest( - [...sources] + [...unique.values()] .sort((left, right) => compareText(left.file, right.file)) .map((source) => ({ ...source })), ); diff --git a/tests/test-graph/src/features/test_graph_snapshot_protocol_commits_atomic_shard_generations.ts b/tests/test-graph/src/features/test_graph_snapshot_protocol_commits_atomic_shard_generations.ts index 4cc5181e..c49f75b2 100644 --- a/tests/test-graph/src/features/test_graph_snapshot_protocol_commits_atomic_shard_generations.ts +++ b/tests/test-graph/src/features/test_graph_snapshot_protocol_commits_atomic_shard_generations.ts @@ -95,6 +95,33 @@ export const test_graph_snapshot_protocol_commits_atomic_shard_generations = ], [GRAPH_EDGE_KINDS.length, 2, 64, 64], ); + const sharedInput = { + file: path.resolve("src/main.ts"), + checkerDigest: digest("c"), + diskDigest: digest("c"), + }; + TestValidator.equals( + "a shared configuration or dependency input has one manifest identity", + GraphSnapshotProtocol.manifestDigest([sharedInput, sharedInput]), + GraphSnapshotProtocol.manifestDigest([sharedInput]), + ); + TestValidator.error( + "a shared input with conflicting digests is refused", + () => + GraphSnapshotProtocol.manifestDigest([ + sharedInput, + { ...sharedInput, diskDigest: digest("d") }, + ]), + ); + const sharedFrames = transaction("shared-input"); + coverageShard(sharedFrames).shard.sources.push({ ...sharedInput }); + refreshDigests(sharedFrames); + TestValidator.equals( + "two shards can share one byte-identical input", + new GraphSnapshotProtocol.Store(process.cwd()).apply(sharedFrames).sources + .size, + 1, + ); for (const [label, expected, mutateSnapshot] of invalidProtocolSnapshots()) { let message = ""; try { @@ -708,7 +735,10 @@ function mutate( return cloned; } -function refreshDigests(frames: GraphSnapshotProtocol.Frame[]): void { +function refreshDigests( + frames: GraphSnapshotProtocol.Frame[], + options: { manifest?: boolean } = {}, +): void { const hello = frames[0] as GraphSnapshotProtocol.IHello; const begin = frames[1] as GraphSnapshotProtocol.IBegin; const shards = frames @@ -721,9 +751,11 @@ function refreshDigests(frames: GraphSnapshotProtocol.Frame[]): void { return frame.shard; }); const last = commit(frames); - begin.manifest = GraphSnapshotProtocol.manifestDigest( - shards.flatMap((shard) => shard.sources), - ); + if (options.manifest !== false) { + begin.manifest = GraphSnapshotProtocol.manifestDigest( + shards.flatMap((shard) => shard.sources), + ); + } last.shards = shards .map((shard) => ({ key: shard.key, @@ -1100,7 +1132,7 @@ function malformedTransactions(): Array< checkerDigest: digest("d"), diskDigest: digest("d"), }); - refreshDigests(frames); + refreshDigests(frames, { manifest: false }); }), ], [ @@ -1111,7 +1143,7 @@ function malformedTransactions(): Array< checkerDigest: digest("c"), diskDigest: digest("d"), }); - refreshDigests(frames); + refreshDigests(frames, { manifest: false }); }), ], [ From b6e8feb6628bc1dfbd1110becc2dbbdd1993a444 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 31 Jul 2026 17:08:29 +0900 Subject: [PATCH 10/52] feat: add repository context topology Add versioned repository-context facts, authoritative pnpm/Cargo/Gradle/CMake providers, resident atomic refresh, generation-fenced MCP topology joins, and complete contract coverage.\n\nCloses #159 --- README.md | 35 +- packages/graph/build/copy-sidecars.mjs | 9 +- packages/graph/src/SamchonGraphApplication.ts | 62 +- packages/graph/src/SamchonGraphMemory.ts | 3 + packages/graph/src/index.ts | 1 + .../graph/src/indexer/buildGraphResult.ts | 10 +- packages/graph/src/indexer/buildLspGraph.ts | 4 + .../src/indexer/createResidentGraphSource.ts | 1 + .../src/mcp/createCompositeResidentClose.ts | 24 + packages/graph/src/mcp/createServer.ts | 6 +- packages/graph/src/mcp/startServer.ts | 29 +- packages/graph/src/operations/graphTrust.ts | 4 +- .../repository/IRepositoryContextProvider.ts | 43 + .../repository/IRepositoryContextSession.ts | 26 + .../IResidentRepositoryContextSource.ts | 9 + .../REPOSITORY_CONTEXT_PROVIDERS.ts | 14 + .../repository/RepositoryContextProtocol.ts | 705 +++++++++ .../SamchonRepositoryContextMemory.ts | 133 ++ .../cargoRepositoryContextProvider.ts | 409 +++++ .../cmakeRepositoryContextProvider.ts | 540 +++++++ .../createRepositoryContextSession.ts | 275 ++++ ...teResidentRepositoryContextMemorySource.ts | 20 + .../createResidentRepositoryContextSource.ts | 328 ++++ .../gradleRepositoryContextProvider.ts | 364 +++++ packages/graph/src/repository/index.ts | 15 + .../parseGradleRepositoryContextModel.ts | 78 + .../pnpmRepositoryContextProvider.ts | 438 ++++++ .../src/repository/repositoryContextFacts.ts | 124 ++ .../validateRepositoryContextProviders.ts | 22 + .../structures/ISamchonGraphApplication.ts | 21 +- .../graph/src/structures/ISamchonGraphDump.ts | 9 + .../src/structures/ISamchonGraphTopology.ts | 65 + .../ISamchonRepositoryContextDump.ts | 115 ++ packages/graph/src/structures/index.ts | 2 + .../src/typings/RepositoryContextAuthority.ts | 5 + .../typings/RepositoryContextCoverageState.ts | 5 + .../src/typings/RepositoryContextNodeKind.ts | 11 + .../typings/RepositoryContextRelationKind.ts | 10 + packages/graph/src/typings/index.ts | 4 + sidecars/gradle/RepositoryContext.java | 95 ++ ...lication_exercises_every_request_branch.ts | 1 + ...mcp_resident_close_handler_settles_once.ts | 41 + ...gy_fences_file_joins_by_code_generation.ts | 267 ++++ ..._adapters_preserve_authoritative_models.ts | 1318 +++++++++++++++++ ..._context_protocol_commits_atomic_shards.ts | 603 ++++++++ ...ository_context_is_atomic_and_retryable.ts | 536 +++++++ .../test-graph/src/internal/ContractGraph.ts | 33 +- .../test-graph/src/internal/ContractParity.ts | 93 +- .../test-graph/src/internal/GraphFixtures.ts | 1 + 49 files changed, 6931 insertions(+), 35 deletions(-) create mode 100644 packages/graph/src/mcp/createCompositeResidentClose.ts create mode 100644 packages/graph/src/repository/IRepositoryContextProvider.ts create mode 100644 packages/graph/src/repository/IRepositoryContextSession.ts create mode 100644 packages/graph/src/repository/IResidentRepositoryContextSource.ts create mode 100644 packages/graph/src/repository/REPOSITORY_CONTEXT_PROVIDERS.ts create mode 100644 packages/graph/src/repository/RepositoryContextProtocol.ts create mode 100644 packages/graph/src/repository/SamchonRepositoryContextMemory.ts create mode 100644 packages/graph/src/repository/cargoRepositoryContextProvider.ts create mode 100644 packages/graph/src/repository/cmakeRepositoryContextProvider.ts create mode 100644 packages/graph/src/repository/createRepositoryContextSession.ts create mode 100644 packages/graph/src/repository/createResidentRepositoryContextMemorySource.ts create mode 100644 packages/graph/src/repository/createResidentRepositoryContextSource.ts create mode 100644 packages/graph/src/repository/gradleRepositoryContextProvider.ts create mode 100644 packages/graph/src/repository/index.ts create mode 100644 packages/graph/src/repository/parseGradleRepositoryContextModel.ts create mode 100644 packages/graph/src/repository/pnpmRepositoryContextProvider.ts create mode 100644 packages/graph/src/repository/repositoryContextFacts.ts create mode 100644 packages/graph/src/repository/validateRepositoryContextProviders.ts create mode 100644 packages/graph/src/structures/ISamchonGraphTopology.ts create mode 100644 packages/graph/src/structures/ISamchonRepositoryContextDump.ts create mode 100644 packages/graph/src/typings/RepositoryContextAuthority.ts create mode 100644 packages/graph/src/typings/RepositoryContextCoverageState.ts create mode 100644 packages/graph/src/typings/RepositoryContextNodeKind.ts create mode 100644 packages/graph/src/typings/RepositoryContextRelationKind.ts create mode 100644 sidecars/gradle/RepositoryContext.java create mode 100644 tests/test-graph/src/features/test_mcp_topology_fences_file_joins_by_code_generation.ts create mode 100644 tests/test-graph/src/features/test_repository_context_adapters_preserve_authoritative_models.ts create mode 100644 tests/test-graph/src/features/test_repository_context_protocol_commits_atomic_shards.ts create mode 100644 tests/test-graph/src/features/test_resident_repository_context_is_atomic_and_retryable.ts diff --git a/README.md b/README.md index da6e2f7b..5d313370 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,21 @@ A language server improves the graph with semantically resolved edges. Install t Each server must be on `PATH`. If none is present for a file's language, that language falls back to the static indexer automatically. +### Repository topology + +The same `inspect_code_graph` tool has a `topology` request for workspaces, packages, source roots, targets, tasks, entrypoints, project dependencies, and file joins. These facts use a sibling provider plane: repository nodes never masquerade as code symbols, and a file join is returned only when the topology model can be fenced against one stable code generation. + +The first adapter slice is deliberately read-only: + +| Ecosystem | Model and policy | +|---|---| +| pnpm | Runs `pnpm list -r --json --depth 0` and reads versioned package/workspace/lock manifests. Package scripts are listed as tasks, never executed. | +| Cargo | Runs `cargo metadata --format-version 1 --locked --offline`; it neither updates the lockfile nor accesses the network. | +| Gradle | Uses the Tooling API and may evaluate project configuration, but runs no task. It is disabled until `SAMCHON_GRAPH_ALLOW_GRADLE_MODEL=1`; provide `SAMCHON_GRAPH_GRADLE_TOOLING_CLASSPATH` or `GRADLE_HOME`. | +| CMake | Reads existing File API codemodel-v2 and cmakeFiles-v1 replies. It never writes a query or configures the project; set `SAMCHON_GRAPH_CMAKE_REPLY` when the reply is outside a conventional build directory. | + +Every topology result carries provider/tool provenance, per-relation `complete`/`partial`/`unsupported` coverage, its resident generation, and explicit join compatibility. Gradle configuration failures and missing or stale CMake replies become unavailable coverage; they do not fall back to guessed build facts. + Before the generic lane runs, indexing asks a registry of strict providers which languages they own. A provider states what its facts are grounded in — a compiler, a whole-project analyzer, or a precomputed semantic index — and which edge families it can prove; a snapshot that publishes outside those is rejected rather than merged. Whatever no provider claims falls through to the language server, and then to the static indexer. Every decline is one sentence naming the provider and the authority the build gave up, so a fallback is never mistaken for the strict result it replaced. The dump carries one `provenance` row per contributing provider: its authority, the fact families it proves, the producing tool and versions, a fingerprint of the inputs that decided the file set, and digests over the manifest and the published facts. Absent when no strict provider served the build. What a provider *did* to compute a generation is deliberately not recorded there — that belongs to one refresh rather than to the facts, and writing it down would make two dumps of the same unedited checkout differ. @@ -199,6 +214,8 @@ pnpm --filter @samchon/graph-benchmark render:png # reference SVG + exact 2x PN * the classes that implement an interface, which is the one call that answers * "what actually implements this". * - `overview`: project layers and folder structure. + * - `topology`: workspace, package, target, task, source-root, entrypoint, and + * project-dependency orientation from declared or owning-tool models. * - `escape`: the answer is outside the graph (source body text, files outside * the indexed languages, exact search). * @@ -247,24 +264,24 @@ pnpm --filter @samchon/graph-benchmark render:png # reference SVG + exact 2x PN */ export interface ISamchonGraphApplication { /** - * Answer a __LANG__ question from this repository's own program index. + * Answer a __LANG__ question from the repository's program index. * - * The graph returns proved indexed facts plus structured coverage and - * uncertainty. Submit exactly one request: + * The graph returns proved facts with coverage and uncertainty. Submit one + * request: * - * - `tour`: architecture, the runtime flow from the public API to the code that - * does the work, nearby paths, and the tests to read — a whole orientation - * in one call + * - `tour`: architecture, runtime flow, nearby paths, and tests * - `trace`: what a symbol calls, what calls it, or the path from A to B * - `details`: signatures, members, and what implements an interface * - `lookup`: where a named symbol is declared * - `entrypoints`: where execution starts, when the entry is unknown * - `overview`: the project's layers and folder structure + * - `topology`: repository workspaces, packages, roots, targets, tasks, and + * dependencies * * Every fact in a result is checked against the index before return, so no * fact needs verifying; for the ranked operations (`lookup`, `entrypoints`, - * `tour`), judge whether the shortlist covers your question. Read a file for - * what the graph does not carry: a body or the text inside a span. + * `tour`), judge whether the shortlist covers your question. Read source only + * for a body or span text. * * @param props Reasoning plus one graph request * @returns Matching `result` union member @@ -303,6 +320,7 @@ export namespace ISamchonGraphApplication { | ISamchonGraphDetails.IRequest | ISamchonGraphOverview.IRequest | ISamchonGraphTour.IRequest + | ISamchonGraphTopology.IRequest | ISamchonGraphEscape.IRequest; } @@ -364,6 +382,7 @@ export namespace ISamchonGraphApplication { | ISamchonGraphDetails | ISamchonGraphOverview | ISamchonGraphTour + | ISamchonGraphTopology | ISamchonGraphEscape; } } diff --git a/packages/graph/build/copy-sidecars.mjs b/packages/graph/build/copy-sidecars.mjs index 23deaaff..e3db1e6b 100644 --- a/packages/graph/build/copy-sidecars.mjs +++ b/packages/graph/build/copy-sidecars.mjs @@ -5,10 +5,10 @@ import { fileURLToPath } from "node:url"; /** * Copy the sidecar sources this package ships into the package itself. * - * Two quite different things travel this way. The Go sidecar is source a user - * compiles into `samchon-graph-go`; the Lua exporter is a script the provider - * hands to lua-language-server at run time, so it has to be present in an - * installed package rather than only in this repository. + * The Go sidecar is source a user compiles into `samchon-graph-go`; the Gradle + * Java source reads the opted-in Tooling API model; and the Lua exporter is a + * script the provider hands to lua-language-server at run time. All three must + * exist in an installed package rather than only in this repository. * * Named per file rather than copied wholesale. A directory copy would ship * whatever happened to be sitting there — a probe, a scratch file, a build @@ -21,6 +21,7 @@ const packageRoot = path.resolve( const repositoryRoot = path.resolve(packageRoot, "..", ".."); const SIDECARS = { + gradle: ["RepositoryContext.java"], go: [ "analyze.go", "go.mod", diff --git a/packages/graph/src/SamchonGraphApplication.ts b/packages/graph/src/SamchonGraphApplication.ts index b537aa0f..db4bbfcd 100644 --- a/packages/graph/src/SamchonGraphApplication.ts +++ b/packages/graph/src/SamchonGraphApplication.ts @@ -12,6 +12,7 @@ import { runOverview } from "./operations/runOverview"; import { runTour } from "./operations/runTour"; import { runTrace } from "./operations/runTrace"; import { SamchonGraphMemory } from "./SamchonGraphMemory"; +import { SamchonRepositoryContextMemory } from "./repository"; import { ISamchonGraphApplication, ISamchonGraphEscape } from "./structures"; /** @@ -34,9 +35,20 @@ export class SamchonGraphApplication implements ISamchonGraphApplication { private readonly graph: () => | SamchonGraphMemory | Promise; + private readonly topology: + | (() => + | SamchonRepositoryContextMemory + | Promise) + | undefined; - public constructor(source: AsyncSamchonGraphSource) { + public constructor( + source: AsyncSamchonGraphSource, + topology?: () => + | SamchonRepositoryContextMemory + | Promise, + ) { this.graph = typeof source === "function" ? source : () => source; + this.topology = topology; } public async inspect_code_graph( @@ -118,6 +130,54 @@ export class SamchonGraphApplication implements ISamchonGraphApplication { result: r.result, }; } + case "topology": { + if (this.topology === undefined) { + throw new Error( + "@samchon/graph: repository-context source is unavailable", + ); + } + const topology = await this.topology(); + const confirmed = await this.load(); + const compatible = + graph.project === topology.dump.project && + topology.dump.provenance.length !== 0 && + graph.inputGeneration !== undefined && + graph.inputGeneration === confirmed.inputGeneration; + const join = compatible + ? { + state: "compatible" as const, + topologyInputGeneration: topology.dump.inputGeneration, + codeInputGeneration: graph.inputGeneration!, + } + : { + state: "unavailable" as const, + topologyInputGeneration: topology.dump.inputGeneration, + ...(graph.inputGeneration !== undefined + ? { codeInputGeneration: graph.inputGeneration } + : {}), + reason: + topology.dump.provenance.length === 0 + ? "No repository-context provider produced a compatible current generation." + : "The code generation moved while topology was loading, or the code dump predates cross-plane generation fencing.", + }; + return { + audit: + "Repository topology is returned from declared or owning-tool models; file joins are included only when the code generation stayed stable across the topology load.", + next: resultNext( + "answer", + "The requested repository orientation is present in this topology result.", + ), + result: topology.inspect( + props.request, + join, + new Set( + graph.nodes + .filter((node) => node.kind === "file") + .map((node) => node.file), + ), + ), + }; + } default: props.request satisfies never; throw new Error("Unknown graph request type"); diff --git a/packages/graph/src/SamchonGraphMemory.ts b/packages/graph/src/SamchonGraphMemory.ts index af20341e..8f5d1aff 100644 --- a/packages/graph/src/SamchonGraphMemory.ts +++ b/packages/graph/src/SamchonGraphMemory.ts @@ -42,6 +42,8 @@ export class SamchonGraphMemory { public readonly languages: readonly string[]; /** Which indexing strategy produced the graph. */ public readonly indexer: ISamchonGraphDump["indexer"]; + /** Complete coordinator input generation for cross-plane compatibility. */ + public readonly inputGeneration: string | undefined; /** Every node, raw plus synthesized (file containers). */ public readonly nodes: readonly ISamchonGraphNode[]; /** Every edge, raw plus synthesized containment. */ @@ -68,6 +70,7 @@ export class SamchonGraphMemory { this.project = dump.project; this.languages = dump.languages; this.indexer = dump.indexer; + this.inputGeneration = dump.generation?.input; this.nodes = nodes; this.edges = edges; this.diagnostics = dump.diagnostics ?? []; diff --git a/packages/graph/src/index.ts b/packages/graph/src/index.ts index eb3e3c12..23ce045a 100644 --- a/packages/graph/src/index.ts +++ b/packages/graph/src/index.ts @@ -7,6 +7,7 @@ export * from "./operations/RESULT_AUDIT_DETAILS"; export * from "./operations/RESULT_AUDIT_SELECTION"; export * from "./operations/RESULT_AUDIT_ESCAPE"; export * from "./provider"; +export * from "./repository"; export * from "./SamchonGraphMemory"; export * from "./SamchonGraphSourceReader"; export * from "./runGraph"; diff --git a/packages/graph/src/indexer/buildGraphResult.ts b/packages/graph/src/indexer/buildGraphResult.ts index d8729b66..bf2425a9 100644 --- a/packages/graph/src/indexer/buildGraphResult.ts +++ b/packages/graph/src/indexer/buildGraphResult.ts @@ -22,5 +22,13 @@ export async function buildGraphResult( buildStaticGraphResult(normalized), ) : await buildLspGraph(normalized); - return { ...result, dump: parseGraphDump(result.dump) }; + return { + ...result, + dump: parseGraphDump({ + ...result.dump, + generation: { + input: result.inputGeneration!, + }, + }), + }; } diff --git a/packages/graph/src/indexer/buildLspGraph.ts b/packages/graph/src/indexer/buildLspGraph.ts index aa87ff7b..194f3f66 100644 --- a/packages/graph/src/indexer/buildLspGraph.ts +++ b/packages/graph/src/indexer/buildLspGraph.ts @@ -89,6 +89,10 @@ export async function buildLspGraph( ? [] : closeKeptSessions(result.sessions), ); + committed.dump = { + ...committed.dump, + generation: { input: committed.inputGeneration! }, + }; if (options.keepAlive) { const { providerSourceDigests: _providerSourceDigests, ...result } = committed; diff --git a/packages/graph/src/indexer/createResidentGraphSource.ts b/packages/graph/src/indexer/createResidentGraphSource.ts index dd6596f2..a2448eb6 100644 --- a/packages/graph/src/indexer/createResidentGraphSource.ts +++ b/packages/graph/src/indexer/createResidentGraphSource.ts @@ -444,6 +444,7 @@ export function createResidentGraphSource( project: current.dump.project, languages: current.dump.languages, indexer: current.dump.indexer, + generation: { input: inputGeneration }, nodes: wireNodes(finalized.nodes), edges: wireEdges(finalized.edges, finalized.nodes), diagnostics, diff --git a/packages/graph/src/mcp/createCompositeResidentClose.ts b/packages/graph/src/mcp/createCompositeResidentClose.ts new file mode 100644 index 00000000..2763f039 --- /dev/null +++ b/packages/graph/src/mcp/createCompositeResidentClose.ts @@ -0,0 +1,24 @@ +/** Close every opened resident plane while retaining the first failure. */ +export function createCompositeResidentClose( + residents: readonly ( + | { close(): Promise } + | undefined + )[], +): { close(): Promise } { + return { + async close(): Promise { + let failure: unknown; + for (const resident of residents) { + if (resident === undefined) continue; + try { + await resident.close(); + } catch (error) { + failure ??= error; + } + } + if (failure !== undefined) { + throw failure instanceof Error ? failure : new Error(String(failure)); + } + }, + }; +} diff --git a/packages/graph/src/mcp/createServer.ts b/packages/graph/src/mcp/createServer.ts index 74357536..08b6017e 100644 --- a/packages/graph/src/mcp/createServer.ts +++ b/packages/graph/src/mcp/createServer.ts @@ -7,6 +7,7 @@ import { SamchonGraphApplication, } from "../application"; import { ISamchonGraphApplication } from "../structures"; +import { SamchonRepositoryContextMemory } from "../repository"; import { GraphLanguage } from "../typings"; import { languageDisplayNameOf } from "./languageDisplayNameOf"; @@ -32,6 +33,9 @@ export function createServer( graph: AsyncSamchonGraphSource, version: string, languages: readonly GraphLanguage[] = [], + topology?: () => + | SamchonRepositoryContextMemory + | Promise, ): McpServer { const controller: ILlmController = { protocol: "class", @@ -40,7 +44,7 @@ export function createServer( typia.llm.application(), languageDisplayNameOf(languages), ), - execute: new SamchonGraphApplication(graph), + execute: new SamchonGraphApplication(graph, topology), }; return createMcpServer(controller, { version }); } diff --git a/packages/graph/src/mcp/startServer.ts b/packages/graph/src/mcp/startServer.ts index a7e983a5..46b65ac2 100644 --- a/packages/graph/src/mcp/startServer.ts +++ b/packages/graph/src/mcp/startServer.ts @@ -11,6 +11,11 @@ import { parseGraphDump } from "../indexer/parseGraphDump"; import { SamchonGraphMemory } from "../SamchonGraphMemory"; import { SamchonGraphSourceReader } from "../SamchonGraphSourceReader"; import { GraphLanguage } from "../typings"; +import { + createResidentRepositoryContextMemorySource, + createResidentRepositoryContextSource, +} from "../repository"; +import { createCompositeResidentClose } from "./createCompositeResidentClose"; import { createResidentCloseHandler } from "./createResidentCloseHandler"; import { createResidentGraphMemorySource } from "./createResidentGraphMemorySource"; import { createServer } from "./createServer"; @@ -54,7 +59,15 @@ export async function startServer( ); languages = dump.languages; source = once(() => - SamchonGraphMemory.from(dump, SamchonGraphSourceReader.none(dump.project)), + // A graph file proves the generation it was built from, but this static + // server never revalidates that token against the current checkout. + // Preserve the graph facts while withholding cross-plane compatibility: + // otherwise a current topology model could join to arbitrarily stale + // code merely because two reads returned the same memoized object. + SamchonGraphMemory.from( + { ...dump, generation: undefined }, + SamchonGraphSourceReader.none(dump.project), + ), ); } else { const root = path.resolve(options.cwd ?? process.cwd()); @@ -65,7 +78,15 @@ export async function startServer( resident = opened; source = createResidentGraphMemorySource(opened); } - const server = createServer(source, options.version, languages); + const topologyResident = createResidentRepositoryContextSource( + options.graphFile === undefined + ? path.resolve(options.cwd ?? process.cwd()) + : (await source()).project, + ); + const topology = createResidentRepositoryContextMemorySource( + topologyResident, + ); + const server = createServer(source, options.version, languages, topology); const transport = new StdioServerTransport(); // The resident source holds a live language-server process per language, and // nothing else is going to end them: a client that disconnects closes the @@ -73,7 +94,9 @@ export async function startServer( // goes with it — an orphaned language server outliving the MCP server that // spawned it would hold the process's event loop open and keep a whole Gradle // or solution load resident behind a session nobody is talking to. - const close = createResidentCloseHandler(resident); + const close = createResidentCloseHandler( + createCompositeResidentClose([resident, topologyResident]), + ); // These two bodies run only when the MCP transport is torn down gracefully -- // a client that closes the transport, or a client exit that ends our stdin. // The deterministic harness disconnects by killing the spawned server diff --git a/packages/graph/src/operations/graphTrust.ts b/packages/graph/src/operations/graphTrust.ts index 52db00cd..fb67f694 100644 --- a/packages/graph/src/operations/graphTrust.ts +++ b/packages/graph/src/operations/graphTrust.ts @@ -16,7 +16,7 @@ export function graphTrust( graph: SamchonGraphMemory, type: Exclude< ISamchonGraphApplication.IProps["request"]["type"], - "escape" + "escape" | "topology" >, ): { provenance?: ISamchonGraphApplication.IOutput["provenance"]; @@ -62,7 +62,7 @@ export function graphTrust( function familiesOf( type: Exclude< ISamchonGraphApplication.IProps["request"]["type"], - "escape" + "escape" | "topology" >, ): GraphEdgeKind[] { switch (type) { diff --git a/packages/graph/src/repository/IRepositoryContextProvider.ts b/packages/graph/src/repository/IRepositoryContextProvider.ts new file mode 100644 index 00000000..00277e45 --- /dev/null +++ b/packages/graph/src/repository/IRepositoryContextProvider.ts @@ -0,0 +1,43 @@ +import { + RepositoryContextAuthority, + RepositoryContextRelationKind, +} from "../typings"; +import { IRepositoryContextSession } from "./IRepositoryContextSession"; +import { RepositoryContextProtocol } from "./RepositoryContextProtocol"; + +/** One sibling repository-topology provider. */ +export interface IRepositoryContextProvider { + readonly name: string; + readonly ecosystem: string; + readonly authority: Exclude; + readonly families: readonly RepositoryContextRelationKind[]; + readonly buildInputs: readonly string[]; + + /** Whether this repository declares the ecosystem. */ + detect(root: string): boolean; + + /** Open a resident topology session without changing the project. */ + open(props: IRepositoryContextProvider.IOpenProps): IRepositoryContextSession; +} + +export namespace IRepositoryContextProvider { + export interface IOpenProps { + root: string; + env: NodeJS.ProcessEnv; + } + + export interface ICollection { + producerSchemaVersion: number; + tool: string; + toolVersion: string; + capabilities: string[]; + universe: string; + target: string; + shards: RepositoryContextProtocol.IShard[]; + warnings: string[]; + } + + export type Collector = ( + props: IOpenProps & { signal?: AbortSignal }, + ) => Promise | ICollection; +} diff --git a/packages/graph/src/repository/IRepositoryContextSession.ts b/packages/graph/src/repository/IRepositoryContextSession.ts new file mode 100644 index 00000000..d8337d16 --- /dev/null +++ b/packages/graph/src/repository/IRepositoryContextSession.ts @@ -0,0 +1,26 @@ +import { RepositoryContextProtocol } from "./RepositoryContextProtocol"; + +/** One resident repository-context provider session. */ +export interface IRepositoryContextSession { + readonly kind: "repository-context"; + readonly provider: string; + readonly ecosystem: string; + readonly root: string; + readonly generation: number; + readonly current: RepositoryContextProtocol.ISnapshot | undefined; + + refresh(options?: { + signal?: AbortSignal; + }): Promise; + close(): Promise; +} + +export namespace IRepositoryContextSession { + export interface IRefresh { + changed: boolean; + generation: number; + mode: "initial" | "unchanged" | "incremental" | "reload"; + snapshot: RepositoryContextProtocol.ISnapshot; + warnings: string[]; + } +} diff --git a/packages/graph/src/repository/IResidentRepositoryContextSource.ts b/packages/graph/src/repository/IResidentRepositoryContextSource.ts new file mode 100644 index 00000000..e11ca6fb --- /dev/null +++ b/packages/graph/src/repository/IResidentRepositoryContextSource.ts @@ -0,0 +1,9 @@ +import { ISamchonRepositoryContextDump } from "../structures"; + +/** Resident sibling source for repository topology. */ +export interface IResidentRepositoryContextSource { + load(options?: { + signal?: AbortSignal; + }): Promise; + close(): Promise; +} diff --git a/packages/graph/src/repository/REPOSITORY_CONTEXT_PROVIDERS.ts b/packages/graph/src/repository/REPOSITORY_CONTEXT_PROVIDERS.ts new file mode 100644 index 00000000..0cbb8719 --- /dev/null +++ b/packages/graph/src/repository/REPOSITORY_CONTEXT_PROVIDERS.ts @@ -0,0 +1,14 @@ +import { cargoRepositoryContextProvider } from "./cargoRepositoryContextProvider"; +import { cmakeRepositoryContextProvider } from "./cmakeRepositoryContextProvider"; +import { gradleRepositoryContextProvider } from "./gradleRepositoryContextProvider"; +import { pnpmRepositoryContextProvider } from "./pnpmRepositoryContextProvider"; +import { validateRepositoryContextProviders } from "./validateRepositoryContextProviders"; + +/** Built-in sibling repository-context provider registry. */ +export const REPOSITORY_CONTEXT_PROVIDERS = + validateRepositoryContextProviders([ + pnpmRepositoryContextProvider, + cargoRepositoryContextProvider, + gradleRepositoryContextProvider, + cmakeRepositoryContextProvider, + ]); diff --git a/packages/graph/src/repository/RepositoryContextProtocol.ts b/packages/graph/src/repository/RepositoryContextProtocol.ts new file mode 100644 index 00000000..2be6afe5 --- /dev/null +++ b/packages/graph/src/repository/RepositoryContextProtocol.ts @@ -0,0 +1,705 @@ +import { createHash } from "node:crypto"; + +import { ISamchonRepositoryContextDump } from "../structures"; +import { + RepositoryContextAuthority, + RepositoryContextCoverageState, + RepositoryContextNodeKind, + RepositoryContextRelationKind, +} from "../typings"; + +/** Atomic, content-addressed repository-context shard protocol. */ +export namespace RepositoryContextProtocol { + export const VERSION = 1 as const; + export const SCHEMA_VERSION = 1 as const; + + export const RELATION_KINDS = [ + "contains", + "depends-on", + "source-of", + "test-of", + "produces", + "invokes", + "entrypoint-of", + "joins-file", + ] as const satisfies readonly RepositoryContextRelationKind[]; + + const AUTHORITIES = [ + "tool-resolved", + "declared", + "inferred", + ] as const satisfies readonly RepositoryContextAuthority[]; + + const COVERAGE_STATES = [ + "complete", + "partial", + "unsupported", + ] as const satisfies readonly RepositoryContextCoverageState[]; + + const NODE_KINDS = [ + "workspace", + "project", + "package", + "source-set", + "source-root", + "generated-root", + "build-target", + "task", + "entrypoint", + ] as const satisfies readonly RepositoryContextNodeKind[]; + + export interface IHello { + type: "hello"; + protocolVersion: 1; + schemaVersion: 1; + producerSchemaVersion: number; + provider: string; + ecosystem: string; + authority: RepositoryContextAuthority; + tool: string; + toolVersion: string; + supportedFamilies: RepositoryContextRelationKind[]; + capabilities: string[]; + } + + export interface IBegin { + type: "begin"; + sequence: number; + generation: string; + baseSequence?: number; + baseGeneration?: string; + inputGeneration: string; + universe: string; + target: string; + manifest: string; + } + + export interface IShard { + key: string; + target: string; + nodes: ISamchonRepositoryContextDump.INode[]; + edges: ISamchonRepositoryContextDump.IEdge[]; + coverage: ISamchonRepositoryContextDump.ICoverage[]; + files: string[]; + sources: ISamchonRepositoryContextDump.ISource[]; + } + + export interface IUpsertShard { + type: "upsertShard"; + digest: string; + shard: IShard; + } + + export interface IDeleteShard { + type: "deleteShard"; + key: string; + } + + export interface ICommit { + type: "commit"; + sequence: number; + generation: string; + shards: ISamchonRepositoryContextDump.IShard[]; + contentDigest: string; + } + + export type Frame = + | IHello + | IBegin + | IUpsertShard + | IDeleteShard + | ICommit; + + export interface ISnapshot { + hello: IHello; + begin: IBegin; + generation: ISamchonRepositoryContextDump.IGeneration; + nodes: ISamchonRepositoryContextDump.INode[]; + edges: ISamchonRepositoryContextDump.IEdge[]; + coverage: ISamchonRepositoryContextDump.ICoverage[]; + files: string[]; + sources: ISamchonRepositoryContextDump.ISource[]; + } + + /** SHA-256 over a canonical JSON value. */ + export function digest(value: unknown): string { + return createHash("sha256").update(canonical(value)).digest("hex"); + } + + export function shardDigest(shard: IShard): string { + return digest(normalizeShard(shard)); + } + + export function manifestDigest( + sources: readonly ISamchonRepositoryContextDump.ISource[], + ): string { + const unique = new Map(); + for (const source of sources) { + const prior = unique.get(source.file); + if (prior !== undefined && prior !== source.digest) { + throw new Error( + `repository context protocol: sources disagree about ${source.file}`, + ); + } + unique.set(source.file, source.digest); + } + return digest( + [...unique] + .sort(([left], [right]) => compare(left, right)) + .map(([file, sourceDigest]) => ({ file, digest: sourceDigest })), + ); + } + + export function contentDigest( + snapshot: Pick, + ): string { + return digest({ + nodes: [...snapshot.nodes].sort((left, right) => + compare(left.id, right.id), + ), + edges: [...snapshot.edges].sort(compareEdges), + coverage: [...snapshot.coverage].sort(compareCoverage), + }); + } + + /** One-provider atomic shard store. */ + export class Store { + private committed = new Map(); + private identity: IHello | undefined; + private snapshot: ISnapshot | undefined; + + public get current(): ISnapshot | undefined { + return this.snapshot; + } + + public apply( + frames: readonly Frame[], + options: { signal?: AbortSignal } = {}, + ): ISnapshot { + throwIfAborted(options.signal); + if (frames.length < 3) { + throw new Error("repository context protocol: incomplete transaction"); + } + const hello = frames[0]; + const begin = frames[1]; + const commit = frames.at(-1); + if (hello?.type !== "hello" || begin?.type !== "begin") { + throw new Error( + "repository context protocol: transaction must start with hello and begin", + ); + } + if (commit?.type !== "commit") { + throw new Error( + "repository context protocol: transaction must end with commit", + ); + } + assertHello(hello); + assertBegin(begin); + if ( + commit.sequence !== begin.sequence || + commit.generation !== begin.generation + ) { + throw new Error( + "repository context protocol: commit generation does not match begin", + ); + } + if (this.identity !== undefined && !sameIdentity(this.identity, hello)) { + throw new Error( + "repository context protocol: provider identity changed inside one store", + ); + } + const prior = this.snapshot?.begin; + if (prior === undefined) { + if ( + begin.sequence !== 1 || + begin.baseSequence !== undefined || + begin.baseGeneration !== undefined + ) { + throw new Error( + "repository context protocol: initial generation must start at sequence 1 without a base", + ); + } + } else if ( + begin.sequence !== prior.sequence + 1 || + begin.baseSequence !== prior.sequence || + begin.baseGeneration !== prior.generation + ) { + throw new Error( + "repository context protocol: delta does not extend the current generation", + ); + } + + const next = + prior === undefined + ? new Map() + : new Map(this.committed); + const touched = new Set(); + for (const frame of frames.slice(2, -1)) { + throwIfAborted(options.signal); + if (frame.type === "upsertShard") { + if (touched.has(frame.shard.key)) { + throw new Error( + `repository context protocol: duplicate shard delta ${frame.shard.key}`, + ); + } + assertShard(frame.shard, hello, begin); + const actual = shardDigest(frame.shard); + if (actual !== frame.digest) { + throw new Error( + `repository context protocol: shard digest mismatch ${frame.shard.key}`, + ); + } + touched.add(frame.shard.key); + next.set(frame.shard.key, { + digest: actual, + shard: clone(frame.shard), + }); + } else if (frame.type === "deleteShard") { + if (touched.has(frame.key) || !next.has(frame.key)) { + throw new Error( + `repository context protocol: invalid shard deletion ${frame.key}`, + ); + } + touched.add(frame.key); + next.delete(frame.key); + } else { + throw new Error( + `repository context protocol: unexpected transaction frame ${frame.type}`, + ); + } + } + + const manifest = [...next] + .sort(([left], [right]) => compare(left, right)) + .map(([key, value]) => ({ key, digest: value.digest })); + if (canonical(manifest) !== canonical(commit.shards)) { + throw new Error( + "repository context protocol: commit shard manifest mismatch", + ); + } + const assembled = assemble(hello, begin, manifest, next); + assertSnapshot(assembled); + if (manifestDigest(assembled.sources) !== begin.manifest) { + throw new Error( + "repository context protocol: input manifest digest mismatch", + ); + } + const facts = contentDigest(assembled); + if (facts !== commit.contentDigest) { + throw new Error( + "repository context protocol: content digest mismatch", + ); + } + throwIfAborted(options.signal); + const published: ISnapshot = { + ...assembled, + generation: { + sequence: begin.sequence, + token: begin.generation, + shards: manifest, + contentDigest: facts, + }, + }; + freeze(published); + this.committed = next; + this.identity = clone(hello); + this.snapshot = published; + return published; + } + } + + function assemble( + hello: IHello, + begin: IBegin, + manifest: ISamchonRepositoryContextDump.IShard[], + shards: ReadonlyMap, + ): ISnapshot { + const nodes: ISamchonRepositoryContextDump.INode[] = []; + const edges: ISamchonRepositoryContextDump.IEdge[] = []; + const coverage: ISamchonRepositoryContextDump.ICoverage[] = []; + const files = new Set(); + const sources = new Map(); + for (const entry of manifest) { + const shard = shards.get(entry.key)!.shard; + nodes.push(...clone(shard.nodes)); + edges.push(...clone(shard.edges)); + coverage.push(...clone(shard.coverage)); + for (const file of shard.files) files.add(file); + for (const source of shard.sources) { + const prior = sources.get(source.file); + if (prior !== undefined && prior !== source.digest) { + throw new Error( + `repository context protocol: shards disagree about ${source.file}`, + ); + } + sources.set(source.file, source.digest); + } + } + return { + hello: clone(hello), + begin: clone(begin), + generation: { + sequence: begin.sequence, + token: begin.generation, + shards: manifest, + contentDigest: "", + }, + nodes, + edges, + coverage, + files: [...files].sort(compare), + sources: [...sources] + .sort(([left], [right]) => compare(left, right)) + .map(([file, sourceDigest]) => ({ file, digest: sourceDigest })), + }; + } + + function assertHello(hello: IHello): void { + if ( + hello.protocolVersion !== VERSION || + hello.schemaVersion !== SCHEMA_VERSION || + !Number.isSafeInteger(hello.producerSchemaVersion) || + hello.producerSchemaVersion < 1 + ) { + throw new Error("repository context protocol: unsupported schema"); + } + for (const value of [ + hello.provider, + hello.ecosystem, + hello.tool, + hello.toolVersion, + ]) { + assertText(value, "hello identity"); + } + if (!AUTHORITIES.includes(hello.authority)) { + throw new Error("repository context protocol: unknown authority"); + } + if (hello.authority === "inferred") { + throw new Error( + "repository context protocol: version 1 refuses inferred facts", + ); + } + assertUniqueClosed( + hello.supportedFamilies, + RELATION_KINDS, + "supported family", + ); + assertUniqueText(hello.capabilities, "capability"); + } + + function assertBegin(begin: IBegin): void { + if (!Number.isSafeInteger(begin.sequence) || begin.sequence < 1) { + throw new Error("repository context protocol: invalid sequence"); + } + for (const value of [ + begin.generation, + begin.inputGeneration, + begin.universe, + begin.target, + ]) { + assertText(value, "generation identity"); + } + assertDigest(begin.manifest, "manifest"); + if ( + (begin.baseSequence === undefined) !== + (begin.baseGeneration === undefined) + ) { + throw new Error( + "repository context protocol: base sequence and generation must move together", + ); + } + if ( + begin.baseSequence !== undefined && + (!Number.isSafeInteger(begin.baseSequence) || begin.baseSequence < 1) + ) { + throw new Error("repository context protocol: invalid base sequence"); + } + if (begin.baseGeneration !== undefined) { + assertText(begin.baseGeneration, "base generation"); + } + } + + function assertShard( + shard: IShard, + hello: IHello, + begin: IBegin, + ): void { + assertText(shard.key, "shard key"); + if (shard.target !== begin.target) { + throw new Error( + `repository context protocol: shard target mismatch ${shard.key}`, + ); + } + const nodeIds = new Set(); + for (const node of shard.nodes) { + for (const value of [ + node.id, + node.name, + node.ecosystem, + node.coordinate, + node.configuration, + ]) { + assertText(value, "node identity"); + } + if (node.ecosystem !== hello.ecosystem || nodeIds.has(node.id)) { + throw new Error( + `repository context protocol: invalid node ownership ${node.id}`, + ); + } + if (!NODE_KINDS.includes(node.kind)) { + throw new Error( + `repository context protocol: unknown node kind ${node.kind}`, + ); + } + if (node.root !== undefined) { + assertText(node.root, "node root"); + if ( + node.kind !== "source-root" && + node.kind !== "generated-root" + ) { + throw new Error( + `repository context protocol: non-root node carries root ${node.id}`, + ); + } + } + if (node.file !== undefined) { + assertText(node.file, "node file"); + } + nodeIds.add(node.id); + assertEvidence(node.evidence); + } + const edgeKeys = new Set(); + for (const edge of shard.edges) { + if (!hello.supportedFamilies.includes(edge.kind)) { + throw new Error( + `repository context protocol: unadvertised edge family ${edge.kind}`, + ); + } + assertText(edge.from, "edge source"); + assertText(edge.to, "edge target"); + const key = `${edge.kind}\0${edge.from}\0${edge.to}`; + if (edgeKeys.has(key)) { + throw new Error( + `repository context protocol: duplicate edge ${edge.kind}`, + ); + } + edgeKeys.add(key); + assertEvidence(edge.evidence); + } + const coverageKeys = new Set(); + for (const row of shard.coverage) { + if ( + row.provider !== hello.provider || + row.ecosystem !== hello.ecosystem || + row.target !== begin.target || + !RELATION_KINDS.includes(row.family) || + !COVERAGE_STATES.includes(row.state) + ) { + throw new Error( + "repository context protocol: invalid coverage ownership", + ); + } + if (coverageKeys.has(row.family)) { + throw new Error( + `repository context protocol: duplicate coverage ${row.family}`, + ); + } + coverageKeys.add(row.family); + } + for (const family of RELATION_KINDS) { + if (!coverageKeys.has(family)) { + throw new Error( + `repository context protocol: missing coverage ${family}`, + ); + } + } + const sourceFiles = new Set(); + const joinedFiles = new Set(); + for (const file of shard.files) { + assertText(file, "joined file"); + if (joinedFiles.has(file)) { + throw new Error( + `repository context protocol: duplicate joined file ${file}`, + ); + } + joinedFiles.add(file); + } + for (const source of shard.sources) { + assertText(source.file, "source file"); + assertDigest(source.digest, "source"); + if (sourceFiles.has(source.file)) { + throw new Error( + `repository context protocol: duplicate source ${source.file}`, + ); + } + sourceFiles.add(source.file); + } + } + + function assertSnapshot(snapshot: ISnapshot): void { + const nodes = new Set(); + for (const node of snapshot.nodes) { + if (nodes.has(node.id)) { + throw new Error( + `repository context protocol: duplicate assembled node ${node.id}`, + ); + } + nodes.add(node.id); + } + const files = new Set(snapshot.files); + const edges = new Set(); + for (const edge of snapshot.edges) { + const key = `${edge.kind}\0${edge.from}\0${edge.to}`; + if (edges.has(key)) { + throw new Error( + `repository context protocol: duplicate assembled edge ${edge.kind}`, + ); + } + edges.add(key); + if ( + !nodes.has(edge.from) || + (edge.kind === "joins-file" + ? !files.has(edge.to) + : !nodes.has(edge.to)) + ) { + throw new Error( + `repository context protocol: absent edge endpoint ${edge.from} -> ${edge.to}`, + ); + } + } + } + + function normalizeShard(shard: IShard): IShard { + return { + ...clone(shard), + nodes: [...shard.nodes].sort((left, right) => compare(left.id, right.id)), + edges: [...shard.edges].sort(compareEdges), + coverage: [...shard.coverage].sort(compareCoverage), + files: [...shard.files].sort(compare), + sources: [...shard.sources].sort((left, right) => + compare(left.file, right.file), + ), + }; + } + + function compareEdges( + left: ISamchonRepositoryContextDump.IEdge, + right: ISamchonRepositoryContextDump.IEdge, + ): number { + return ( + compare(left.kind, right.kind) || + compare(left.from, right.from) || + compare(left.to, right.to) + ); + } + + function compareCoverage( + left: ISamchonRepositoryContextDump.ICoverage, + right: ISamchonRepositoryContextDump.ICoverage, + ): number { + return ( + compare(left.provider, right.provider) || + compare(left.ecosystem, right.ecosystem) || + compare(left.target, right.target) || + compare(left.family, right.family) + ); + } + + function sameIdentity(left: IHello, right: IHello): boolean { + return canonical(left) === canonical(right); + } + + function assertEvidence( + evidence: ISamchonRepositoryContextDump.IEvidence | undefined, + ): void { + if (evidence === undefined) return; + assertText(evidence.file, "evidence file"); + for (const value of [ + evidence.startLine, + evidence.startColumn, + evidence.endLine, + evidence.endColumn, + ]) { + if (value !== undefined && (!Number.isSafeInteger(value) || value < 1)) { + throw new Error("repository context protocol: invalid evidence span"); + } + } + /* c8 ignore start -- V8 attributes an implicit iterator-completion arm to + * this closing line; valid, absent and invalid evidence fields are tested. */ + } + /* c8 ignore stop */ + + function assertText(value: string, label: string): void { + if (value.trim() === "" || value.includes("\0")) { + throw new Error(`repository context protocol: invalid ${label}`); + } + } + + function assertDigest(value: string, label: string): void { + if (!/^[a-f0-9]{64}$/.test(value)) { + throw new Error(`repository context protocol: invalid ${label} digest`); + } + } + + function assertUniqueText(values: readonly string[], label: string): void { + const seen = new Set(); + for (const value of values) { + assertText(value, label); + if (seen.has(value)) { + throw new Error(`repository context protocol: duplicate ${label}`); + } + seen.add(value); + } + } + + function assertUniqueClosed( + values: readonly T[], + allowed: readonly T[], + label: string, + ): void { + assertUniqueText(values, label); + for (const value of values) { + if (!allowed.includes(value)) { + throw new Error(`repository context protocol: unknown ${label}`); + } + } + } + + function throwIfAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) { + throw new Error("repository context protocol: transaction cancelled"); + } + } + + function canonical(value: unknown): string { + return JSON.stringify(sortValue(value)); + } + + function sortValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortValue); + if (value !== null && typeof value === "object") { + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => compare(left, right)) + .map(([key, child]) => [key, sortValue(child)]), + ); + } + return value; + } + + function compare(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; + } + + function clone(value: T): T { + return structuredClone(value); + } + + function freeze(value: unknown): void { + if (value === null || typeof value !== "object" || Object.isFrozen(value)) { + return; + } + Object.freeze(value); + for (const child of Object.values(value)) freeze(child); + } +} diff --git a/packages/graph/src/repository/SamchonRepositoryContextMemory.ts b/packages/graph/src/repository/SamchonRepositoryContextMemory.ts new file mode 100644 index 00000000..2baed167 --- /dev/null +++ b/packages/graph/src/repository/SamchonRepositoryContextMemory.ts @@ -0,0 +1,133 @@ +import { + ISamchonGraphTopology, + ISamchonRepositoryContextDump, +} from "../structures"; +import { RepositoryContextRelationKind } from "../typings"; + +/** Indexed in-memory view of one repository-context snapshot. */ +export class SamchonRepositoryContextMemory { + public readonly dump: ISamchonRepositoryContextDump; + private readonly nodesById: ReadonlyMap< + string, + ISamchonRepositoryContextDump.INode + >; + + public constructor(dump: ISamchonRepositoryContextDump) { + this.dump = dump; + this.nodesById = new Map(dump.nodes.map((node) => [node.id, node])); + } + + public inspect( + request: ISamchonGraphTopology.IRequest, + join: ISamchonGraphTopology.IJoin, + codeFiles: ReadonlySet = new Set(), + ): ISamchonGraphTopology { + const limit = Math.max(1, Math.min(request.limit ?? 100, 500)); + const joinLimit = Math.max( + 1, + Math.min(request.joinLimit ?? 50, 500), + ); + const families = + request.relations === undefined || request.relations.length === 0 + ? undefined + : new Set(request.relations); + const query = request.query?.trim().toLowerCase(); + const availableEdges = + join.state === "compatible" + ? withCodeJoins(this.dump.edges, this.dump.nodes, codeFiles) + : this.dump.edges; + const seeds = + query === undefined || query === "" + ? this.dump.nodes + : this.dump.nodes.filter( + (node) => + node.id.toLowerCase() === query || + node.name.toLowerCase().includes(query) || + node.coordinate.toLowerCase().includes(query), + ); + const selected = new Set(seeds.slice(0, limit).map((node) => node.id)); + const matchingEdges = availableEdges.filter( + (edge) => + (families === undefined || families.has(edge.kind)) && + (edge.kind !== "joins-file" || + (join.state === "compatible" && codeFiles.has(edge.to))) && + (selected.has(edge.from) || + (edge.kind !== "joins-file" && selected.has(edge.to))), + ); + const matchingJoins = matchingEdges.filter( + (edge) => edge.kind === "joins-file", + ); + const edges = [ + ...matchingEdges.filter((edge) => edge.kind !== "joins-file"), + ...matchingJoins.slice(0, joinLimit), + ]; + for (const edge of edges) { + if (this.nodesById.has(edge.from)) selected.add(edge.from); + if (this.nodesById.has(edge.to)) selected.add(edge.to); + } + const nodes = this.dump.nodes + .filter((node) => selected.has(node.id)) + .slice(0, limit); + const retained = new Set(nodes.map((node) => node.id)); + return { + type: "topology", + schemaVersion: 1, + nodes, + edges: edges.filter( + (edge) => + retained.has(edge.from) && + (edge.kind === "joins-file" || retained.has(edge.to)), + ), + provenance: this.dump.provenance.map((row) => ({ ...row })), + coverage: this.dump.coverage + .filter((row) => families === undefined || families.has(row.family)) + .map((row) => ({ ...row })), + generation: { + ...this.dump.generation, + shards: this.dump.generation.shards.map((row) => ({ ...row })), + }, + join, + truncated: + seeds.length > limit || matchingJoins.length > joinLimit, + }; + } +} + +function withCodeJoins( + declared: readonly ISamchonRepositoryContextDump.IEdge[], + nodes: readonly ISamchonRepositoryContextDump.INode[], + codeFiles: ReadonlySet, +): ISamchonRepositoryContextDump.IEdge[] { + const rows = new Map( + declared.map( + (edge) => + [`${edge.kind}\0${edge.from}\0${edge.to}`, edge] as const, + ), + ); + for (const node of nodes) { + if (node.file !== undefined && codeFiles.has(node.file)) { + add(node.id, node.file); + } + if (node.root !== undefined) { + const prefix = node.root === "." ? "" : `${node.root.replace(/\/$/, "")}/`; + for (const file of codeFiles) { + if (prefix === "" || file.startsWith(prefix)) add(node.id, file); + } + } + } + return [...rows.values()].sort( + (left, right) => + compare(left.kind, right.kind) || + compare(left.from, right.from) || + compare(left.to, right.to), + ); + + function add(from: string, to: string): void { + const edge = { kind: "joins-file" as const, from, to }; + rows.set(`${edge.kind}\0${edge.from}\0${edge.to}`, edge); + } +} + +function compare(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} diff --git a/packages/graph/src/repository/cargoRepositoryContextProvider.ts b/packages/graph/src/repository/cargoRepositoryContextProvider.ts new file mode 100644 index 00000000..9dac19f6 --- /dev/null +++ b/packages/graph/src/repository/cargoRepositoryContextProvider.ts @@ -0,0 +1,409 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +import { ISamchonRepositoryContextDump } from "../structures"; +import { spawnableCommand } from "../utils/spawnableCommand"; +import { IRepositoryContextProvider } from "./IRepositoryContextProvider"; +import { createRepositoryContextSession } from "./createRepositoryContextSession"; +import { repositoryContextFacts } from "./repositoryContextFacts"; + +const { + compareRepositoryText, + repositoryContextCoverage, + repositoryContextEvidence, + repositoryContextFile, + repositoryContextId, + repositoryContextSource, + uniqueRepositorySources, +} = repositoryContextFacts; + +const PROVIDER = "cargo-metadata"; +const ECOSYSTEM = "cargo"; +const TARGET = "workspace"; + +interface ICargoMetadata { + packages: ICargoPackage[]; + workspace_members: string[]; + workspace_root: string; + resolve: { + nodes: Array<{ id: string; dependencies: string[]; features?: string[] }>; + } | null; +} + +interface ICargoPackage { + id: string; + name: string; + version: string; + manifest_path: string; + targets: ICargoTarget[]; +} + +interface ICargoTarget { + name: string; + kind: string[]; + crate_types: string[]; + src_path: string; +} + +export const cargoRepositoryContextProvider: IRepositoryContextProvider & { + collect: typeof collectCargoRepositoryContext; +} = { + name: PROVIDER, + ecosystem: ECOSYSTEM, + authority: "tool-resolved", + families: [ + "contains", + "depends-on", + "source-of", + "test-of", + "entrypoint-of", + "joins-file", + ], + buildInputs: [ + "Cargo.toml", + "Cargo.lock", + "rust-toolchain", + "rust-toolchain.toml", + ], + detect: (root) => fs.existsSync(path.join(root, "Cargo.toml")), + open: (props) => + createRepositoryContextSession( + cargoRepositoryContextProvider, + props, + collectCargoRepositoryContext, + ), + collect: collectCargoRepositoryContext, +}; + +function collectCargoRepositoryContext( + props: IRepositoryContextProvider.IOpenProps & { signal?: AbortSignal }, + execute: typeof executeCargoMetadata = executeCargoMetadata, +): IRepositoryContextProvider.ICollection { + throwIfAborted(props.signal); + const metadata = execute(props.root, props.env); + throwIfAborted(props.signal); + const members = new Set(metadata.workspace_members); + const workspaceCoordinate = repositoryContextFile( + props.root, + metadata.workspace_root, + ); + const workspaceId = repositoryContextId( + ECOSYSTEM, + "workspace", + workspaceCoordinate, + ); + const workspaceManifest = path.join(metadata.workspace_root, "Cargo.toml"); + const nodes: ISamchonRepositoryContextDump.INode[] = [ + { + id: workspaceId, + kind: "workspace", + name: path.basename(metadata.workspace_root), + ecosystem: ECOSYSTEM, + coordinate: workspaceCoordinate, + configuration: "default", + external: false, + evidence: repositoryContextEvidence(props.root, workspaceManifest), + }, + ]; + const edges: ISamchonRepositoryContextDump.IEdge[] = []; + const sources: ISamchonRepositoryContextDump.ISource[] = [ + repositoryContextSource(props.root, workspaceManifest), + ]; + const files = new Set(); + const packageIds = new Map(); + const configurations = new Map( + (metadata.resolve?.nodes ?? []).map((node) => [ + node.id, + cargoConfiguration(node.features), + ]), + ); + + for (const pkg of [...metadata.packages].sort((left, right) => + compareRepositoryText(left.id, right.id), + )) { + const member = members.has(pkg.id); + const coordinate = `${pkg.name}@${pkg.version}:${repositoryContextFile( + props.root, + path.dirname(pkg.manifest_path), + )}`; + const packageId = repositoryContextId( + ECOSYSTEM, + "package", + coordinate, + configurations.get(pkg.id) ?? "default", + ); + packageIds.set(pkg.id, packageId); + sources.push(repositoryContextSource(props.root, pkg.manifest_path)); + nodes.push({ + id: packageId, + kind: "package", + name: pkg.name, + ecosystem: ECOSYSTEM, + coordinate, + configuration: configurations.get(pkg.id) ?? "default", + external: !member, + evidence: repositoryContextEvidence(props.root, pkg.manifest_path), + }); + if (member) edges.push({ kind: "contains", from: workspaceId, to: packageId }); + appendCargoTargets( + props.root, + pkg, + packageId, + configurations.get(pkg.id) ?? "default", + nodes, + edges, + files, + ); + } + + for (const resolved of metadata.resolve?.nodes ?? []) { + const from = packageIds.get(resolved.id); + if (from === undefined) continue; + for (const dependency of [...resolved.dependencies].sort( + compareRepositoryText, + )) { + const to = packageIds.get(dependency); + if (to !== undefined) edges.push({ kind: "depends-on", from, to }); + } + } + + const shard = { + key: `${PROVIDER}:workspace`, + target: TARGET, + nodes: nodes.sort((left, right) => + compareRepositoryText(left.id, right.id), + ), + edges: dedupeEdges(edges), + coverage: repositoryContextCoverage( + PROVIDER, + ECOSYSTEM, + TARGET, + [ + "contains", + "depends-on", + "source-of", + "test-of", + "entrypoint-of", + "joins-file", + ], + ), + files: [...files].sort(compareRepositoryText), + sources: uniqueRepositorySources([ + ...sources, + ...metadata.packages + .filter((pkg) => members.has(pkg.id)) + .map((pkg) => path.dirname(path.dirname(pkg.manifest_path))) + .map((directory) => repositoryContextSource(props.root, directory)), + ...["Cargo.lock", "rust-toolchain", "rust-toolchain.toml"] + .map((file) => path.join(props.root, file)) + .filter((file) => fs.existsSync(file)) + .map((file) => repositoryContextSource(props.root, file)), + ]), + }; + return { + producerSchemaVersion: 1, + tool: "cargo metadata", + toolVersion: cargoVersion(props.root, props.env), + capabilities: [ + "workspace-members", + "resolved-dependencies", + "targets", + "features", + "source-files", + ], + universe: `${ECOSYSTEM}:${shard.sources + .map((source) => `${source.file}:${source.digest}`) + .join("|")}`, + target: TARGET, + shards: [shard], + warnings: [], + }; +} + +function appendCargoTargets( + root: string, + pkg: ICargoPackage, + packageId: string, + configuration: string, + nodes: ISamchonRepositoryContextDump.INode[], + edges: ISamchonRepositoryContextDump.IEdge[], + files: Set, +): void { + for (const target of [...pkg.targets].sort((left, right) => + compareRepositoryText( + `${left.name}:${left.kind.join(",")}`, + `${right.name}:${right.kind.join(",")}`, + ), + )) { + const targetCoordinate = `${pkg.id}#${target.name}:${target.kind.join("+")}`; + const targetId = repositoryContextId( + ECOSYSTEM, + "build-target", + targetCoordinate, + configuration, + ); + const sourceSetId = repositoryContextId( + ECOSYSTEM, + "source-set", + targetCoordinate, + configuration, + ); + const evidence = repositoryContextEvidence(root, pkg.manifest_path); + const file = repositoryContextFile(root, target.src_path); + nodes.push( + { + id: targetId, + kind: "build-target", + name: target.name, + ecosystem: ECOSYSTEM, + coordinate: targetCoordinate, + configuration, + external: !isInside(root, target.src_path), + file, + evidence, + }, + { + id: sourceSetId, + kind: "source-set", + name: target.kind.join("+"), + ecosystem: ECOSYSTEM, + coordinate: targetCoordinate, + configuration, + external: !isInside(root, target.src_path), + file, + evidence, + }, + ); + edges.push( + { kind: "contains", from: packageId, to: targetId }, + { kind: "contains", from: targetId, to: sourceSetId }, + { kind: "source-of", from: sourceSetId, to: packageId }, + { + kind: "joins-file", + from: sourceSetId, + to: file, + }, + ); + files.add(file); + if (target.kind.includes("test") || target.kind.includes("bench")) { + edges.push({ kind: "test-of", from: sourceSetId, to: packageId }); + } + if ( + target.kind.some((kind) => + ["bin", "example", "test", "bench"].includes(kind), + ) + ) { + const entrypointId = repositoryContextId( + ECOSYSTEM, + "entrypoint", + targetCoordinate, + configuration, + ); + nodes.push({ + id: entrypointId, + kind: "entrypoint", + name: target.name, + ecosystem: ECOSYSTEM, + coordinate: targetCoordinate, + configuration, + external: !isInside(root, target.src_path), + file, + evidence, + }); + edges.push( + { kind: "contains", from: targetId, to: entrypointId }, + { kind: "entrypoint-of", from: entrypointId, to: targetId }, + { + kind: "joins-file", + from: entrypointId, + to: file, + }, + ); + } + } +} + +function cargoConfiguration(features: readonly string[] | undefined): string { + if (features === undefined || features.length === 0) return "default"; + return `features=${[...features].sort(compareRepositoryText).join(",")}`; +} + +function executeCargoMetadata( + root: string, + env: NodeJS.ProcessEnv, +): ICargoMetadata { + /* c8 ignore next -- each coverage host has exactly one native shim suffix. */ + const command = process.platform === "win32" ? "cargo.cmd" : "cargo"; + const invocation = spawnableCommand( + command, + ["metadata", "--format-version", "1", "--locked", "--offline"], + env, + ); + const result = spawnSync(invocation.command, invocation.args, { + cwd: root, + env, + encoding: "utf8", + windowsHide: true, + windowsVerbatimArguments: invocation.windowsVerbatimArguments, + }); + if (result.status !== 0) { + /* c8 ignore start -- direct-spawn errors and silent nonzero exits are + * operating-system fallbacks; stderr failures are exercised here. */ + const failure = + result.stderr || result.error?.message || "unknown error"; + /* c8 ignore stop */ + throw new Error( + `cargo metadata failed without changing the project: ${failure.trim()}`, + ); + } + const parsed = JSON.parse(result.stdout) as ICargoMetadata; + if ( + !Array.isArray(parsed.packages) || + !Array.isArray(parsed.workspace_members) || + typeof parsed.workspace_root !== "string" + ) { + throw new Error("cargo metadata returned a malformed model"); + } + return parsed; +} + +function cargoVersion(root: string, env: NodeJS.ProcessEnv): string { + /* c8 ignore next -- each coverage host has exactly one native shim suffix. */ + const command = process.platform === "win32" ? "cargo.cmd" : "cargo"; + const invocation = spawnableCommand(command, ["--version"], env); + const result = spawnSync(invocation.command, invocation.args, { + cwd: root, + env, + encoding: "utf8", + windowsHide: true, + windowsVerbatimArguments: invocation.windowsVerbatimArguments, + }); + return result.status === 0 ? result.stdout.trim() : ""; +} + +function dedupeEdges( + input: readonly ISamchonRepositoryContextDump.IEdge[], +): ISamchonRepositoryContextDump.IEdge[] { + const rows = new Map(); + for (const edge of input) { + rows.set(`${edge.kind}\0${edge.from}\0${edge.to}`, edge); + } + return [...rows.values()].sort( + (left, right) => + compareRepositoryText(left.kind, right.kind) || + compareRepositoryText(left.from, right.from) || + compareRepositoryText(left.to, right.to), + ); +} + +function isInside(root: string, file: string): boolean { + const relative = path.relative(root, file); + return relative !== ".." && !relative.startsWith(`..${path.sep}`); +} + +function throwIfAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) { + throw new Error("cargo repository context cancelled"); + } +} diff --git a/packages/graph/src/repository/cmakeRepositoryContextProvider.ts b/packages/graph/src/repository/cmakeRepositoryContextProvider.ts new file mode 100644 index 00000000..5f639669 --- /dev/null +++ b/packages/graph/src/repository/cmakeRepositoryContextProvider.ts @@ -0,0 +1,540 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { ISamchonRepositoryContextDump } from "../structures"; +import { IRepositoryContextProvider } from "./IRepositoryContextProvider"; +import { createRepositoryContextSession } from "./createRepositoryContextSession"; +import { repositoryContextFacts } from "./repositoryContextFacts"; + +const { + compareRepositoryText, + repositoryContextCoverage, + repositoryContextEvidence, + repositoryContextFile, + repositoryContextId, + repositoryContextSource, + uniqueRepositorySources, +} = repositoryContextFacts; + +const PROVIDER = "cmake-file-api"; +const ECOSYSTEM = "cmake"; + +interface ICmakeIndex { + cmake?: { version?: { string?: string } }; + reply?: Record; + objects?: Array<{ kind?: string; jsonFile?: string }>; +} + +interface ICmakeCodemodel { + configurations: ICmakeConfiguration[]; + paths: { source: string; build: string }; +} + +interface ICmakeFiles { + paths: { source: string; build: string }; + inputs: Array<{ path: string }>; +} + +interface ICmakeConfiguration { + name: string; + projects: Array<{ + name: string; + directoryIndexes: number[]; + targetIndexes: number[]; + }>; + directories: Array<{ + source: string; + build: string; + projectIndex?: number; + targetIndexes: number[]; + }>; + targets: Array<{ + name: string; + id: string; + directoryIndex: number; + projectIndex: number; + jsonFile: string; + }>; +} + +interface ICmakeTarget { + name: string; + id: string; + type: string; + paths: { source: string; build: string }; + sources?: Array<{ path: string; isGenerated?: boolean }>; + dependencies?: Array<{ id: string }>; + artifacts?: Array<{ path: string }>; +} + +export const cmakeRepositoryContextProvider: IRepositoryContextProvider & { + collect: typeof collectCmakeRepositoryContext; +} = { + name: PROVIDER, + ecosystem: ECOSYSTEM, + authority: "tool-resolved", + families: [ + "contains", + "depends-on", + "source-of", + "produces", + "entrypoint-of", + "joins-file", + ], + buildInputs: [ + "CMakeLists.txt", + "CMakePresets.json", + "CMakeUserPresets.json", + ], + detect: (root) => fs.existsSync(path.join(root, "CMakeLists.txt")), + open: (props) => + createRepositoryContextSession( + cmakeRepositoryContextProvider, + props, + collectCmakeRepositoryContext, + ), + collect: collectCmakeRepositoryContext, +}; + +function collectCmakeRepositoryContext( + props: IRepositoryContextProvider.IOpenProps & { signal?: AbortSignal }, +): IRepositoryContextProvider.ICollection { + throwIfAborted(props.signal); + const reply = locateReply(props.root, props.env); + if (reply === undefined) { + throw new Error( + "CMake File API reply is unavailable. Configure the project with codemodel-v2 and cmakeFiles-v1 queries first; repository-context indexing will not write a query or run configuration implicitly.", + ); + } + const indexFile = latestIndex(reply); + const index = readJson(indexFile); + const codemodelRef = objectReference(index, "codemodel", "codemodel-v2"); + const cmakeFilesRef = objectReference( + index, + "cmakeFiles", + "cmakeFiles-v1", + ); + if (codemodelRef === undefined || cmakeFilesRef === undefined) { + throw new Error( + "CMake File API index must contain codemodel-v2 and cmakeFiles-v1 replies", + ); + } + const codemodelFile = path.join(reply, codemodelRef); + const codemodel = readJson(codemodelFile); + const cmakeFilesFile = path.join(reply, cmakeFilesRef); + const cmakeFiles = readJson(cmakeFilesFile); + const modelInputs = [ + repositoryContextSource(props.root, cmakeFilesFile), + ...cmakeFiles.inputs.map((input) => + repositoryContextSource( + props.root, + path.resolve(cmakeFiles.paths.source, input.path), + ), + ), + ]; + const configurations = selectConfigurations( + codemodel.configurations, + props.env.SAMCHON_GRAPH_CMAKE_CONFIGURATION, + ); + const shards = configurations.map((configuration) => + cmakeConfigurationShard( + props.root, + reply, + indexFile, + codemodelFile, + codemodel, + configuration, + modelInputs, + ), + ); + throwIfAborted(props.signal); + const sources = uniqueRepositorySources( + shards.flatMap((shard) => shard.sources), + ); + return { + producerSchemaVersion: 1, + tool: "CMake File API", + toolVersion: index.cmake?.version?.string ?? "", + capabilities: [ + "codemodel-v2", + "cmakeFiles-v1", + "projects", + "targets", + "target-dependencies", + "sources", + "artifacts", + ], + universe: `${ECOSYSTEM}:${sources + .map((source) => `${source.file}:${source.digest}`) + .join("|")}`, + /* c8 ignore next -- selectConfigurations rejects an empty shard set. */ + target: shards[0]?.target ?? "default", + shards, + warnings: [ + "CMake context uses an existing File API reply and does not configure or mutate the project.", + ], + }; +} + +function objectReference( + index: ICmakeIndex, + kind: string, + replyPrefix: string, +): string | undefined { + return ( + index.objects?.find((entry) => entry.kind === kind)?.jsonFile ?? + Object.entries(index.reply ?? {}).find(([key]) => + key.startsWith(replyPrefix), + )?.[1].jsonFile + ); +} + +function cmakeConfigurationShard( + root: string, + reply: string, + indexFile: string, + codemodelFile: string, + codemodel: ICmakeCodemodel, + configuration: ICmakeConfiguration, + modelInputs: readonly ISamchonRepositoryContextDump.ISource[], +) { + const target = configuration.name || "default"; + const workspaceId = repositoryContextId( + ECOSYSTEM, + "workspace", + repositoryContextFile(root, codemodel.paths.source), + target, + ); + const evidenceFile = path.join(codemodel.paths.source, "CMakeLists.txt"); + const evidence = repositoryContextEvidence(root, evidenceFile); + const nodes: ISamchonRepositoryContextDump.INode[] = [ + { + id: workspaceId, + kind: "workspace", + name: path.basename(codemodel.paths.source), + ecosystem: ECOSYSTEM, + coordinate: repositoryContextFile(root, codemodel.paths.source), + configuration: target, + external: false, + evidence, + }, + ]; + const edges: ISamchonRepositoryContextDump.IEdge[] = []; + const files = new Set(); + const sources = [ + repositoryContextSource(root, indexFile), + repositoryContextSource(root, codemodelFile), + repositoryContextSource(root, evidenceFile), + ...modelInputs, + ...configuration.directories.map((directory) => + repositoryContextSource( + root, + path.join(codemodel.paths.source, directory.source, "CMakeLists.txt"), + ), + ), + ]; + assertCmakeReplyFresh(indexFile, sources, root); + const projectIds = new Map(); + const targetIds = new Map(); + + configuration.projects.forEach((project, index) => { + const projectId = repositoryContextId( + ECOSYSTEM, + "project", + project.name, + target, + ); + projectIds.set(index, projectId); + nodes.push({ + id: projectId, + kind: "project", + name: project.name, + ecosystem: ECOSYSTEM, + coordinate: project.name, + configuration: target, + external: false, + evidence, + }); + edges.push({ kind: "contains", from: workspaceId, to: projectId }); + }); + + const details = new Map(); + for (const summary of configuration.targets) { + const detailFile = path.join(reply, summary.jsonFile); + const detail = readJson(detailFile); + details.set(summary.id, detail); + sources.push(repositoryContextSource(root, detailFile)); + const projectId = projectIds.get(summary.projectIndex)!; + const targetId = repositoryContextId( + ECOSYSTEM, + "build-target", + summary.id, + target, + ); + targetIds.set(summary.id, targetId); + nodes.push({ + id: targetId, + kind: "build-target", + name: summary.name, + ecosystem: ECOSYSTEM, + coordinate: summary.id, + configuration: target, + external: false, + evidence, + }); + edges.push({ kind: "contains", from: projectId, to: targetId }); + appendCmakeSources( + root, + target, + projectId, + targetId, + detail, + nodes, + edges, + files, + ); + if (detail.type === "EXECUTABLE") { + const entrypointId = repositoryContextId( + ECOSYSTEM, + "entrypoint", + summary.id, + target, + ); + nodes.push({ + id: entrypointId, + kind: "entrypoint", + name: detail.name, + ecosystem: ECOSYSTEM, + coordinate: summary.id, + configuration: target, + external: false, + evidence, + }); + edges.push( + { kind: "contains", from: targetId, to: entrypointId }, + { kind: "entrypoint-of", from: entrypointId, to: targetId }, + ); + } + for (const artifact of detail.artifacts ?? []) { + const artifactPath = path.resolve(detail.paths.build, artifact.path); + const coordinate = repositoryContextFile( + root, + path.dirname(artifactPath), + ); + const generatedId = repositoryContextId( + ECOSYSTEM, + "generated-root", + `${summary.id}:${coordinate}`, + target, + ); + nodes.push({ + id: generatedId, + kind: "generated-root", + name: path.basename(path.dirname(artifactPath)), + ecosystem: ECOSYSTEM, + coordinate, + configuration: target, + external: !isInside(root, artifactPath), + evidence, + }); + edges.push( + { kind: "contains", from: targetId, to: generatedId }, + { kind: "produces", from: targetId, to: generatedId }, + ); + } + } + for (const [id, detail] of details) { + const from = targetIds.get(id)!; + for (const dependency of detail.dependencies ?? []) { + const to = targetIds.get(dependency.id); + if (to !== undefined) edges.push({ kind: "depends-on", from, to }); + } + } + return { + key: `${PROVIDER}:${target}`, + target, + nodes: dedupeNodes(nodes), + edges: dedupeEdges(edges), + coverage: repositoryContextCoverage( + PROVIDER, + ECOSYSTEM, + target, + [ + "contains", + "depends-on", + "source-of", + "produces", + "entrypoint-of", + "joins-file", + ], + ), + files: [...files].sort(compareRepositoryText), + sources: uniqueRepositorySources(sources), + }; +} + +function appendCmakeSources( + root: string, + configuration: string, + projectId: string, + targetId: string, + detail: ICmakeTarget, + nodes: ISamchonRepositoryContextDump.INode[], + edges: ISamchonRepositoryContextDump.IEdge[], + files: Set, +): void { + const roots = new Map< + string, + { generated: boolean; files: string[] } + >(); + for (const source of detail.sources ?? []) { + // Codemodel-v2 makes a source path relative only when it lies inside the + // top-level source tree; generated files outside that tree are absolute. + const absolute = path.resolve(detail.paths.source, source.path); + const directory = path.dirname(absolute); + const row = roots.get(directory) ?? { + generated: source.isGenerated === true, + files: [], + }; + row.generated ||= source.isGenerated === true; + row.files.push(absolute); + roots.set(directory, row); + } + for (const [directory, row] of [...roots].sort(([left], [right]) => + compareRepositoryText(left, right), + )) { + const coordinate = `${detail.id}:${repositoryContextFile(root, directory)}`; + const sourceId = repositoryContextId( + ECOSYSTEM, + row.generated ? "generated-root" : "source-root", + coordinate, + configuration, + ); + nodes.push({ + id: sourceId, + kind: row.generated ? "generated-root" : "source-root", + name: path.basename(directory), + ecosystem: ECOSYSTEM, + coordinate, + configuration, + external: !isInside(root, directory), + evidence: repositoryContextEvidence( + root, + path.join(detail.paths.source, "CMakeLists.txt"), + ), + }); + edges.push( + { kind: "contains", from: targetId, to: sourceId }, + { kind: "source-of", from: sourceId, to: projectId }, + ); + for (const file of row.files) { + const joined = repositoryContextFile(root, file); + files.add(joined); + edges.push({ kind: "joins-file", from: sourceId, to: joined }); + } + } +} + +function assertCmakeReplyFresh( + indexFile: string, + sources: readonly ISamchonRepositoryContextDump.ISource[], + root: string, +): void { + const replyTime = fs.statSync(indexFile).mtimeMs; + for (const source of sources) { + if (!source.file.endsWith("CMakeLists.txt")) continue; + const file = path.resolve(root, source.file); + if (fs.existsSync(file) && fs.statSync(file).mtimeMs > replyTime) { + throw new Error( + `CMake File API reply predates ${source.file}; reconfigure the project before repository-context indexing.`, + ); + } + } +} + +function selectConfigurations( + configurations: readonly ICmakeConfiguration[], + requested: string | undefined, +): readonly ICmakeConfiguration[] { + if (configurations.length === 0) { + throw new Error("CMake File API codemodel has no configuration"); + } + if (configurations.length <= 1) return configurations; + if (requested !== undefined) { + const selected = configurations.find( + (configuration) => configuration.name === requested, + ); + if (selected !== undefined) return [selected]; + } + throw new Error( + "CMake File API returned multiple configurations; select one with SAMCHON_GRAPH_CMAKE_CONFIGURATION before joining it to one repository-context generation.", + ); +} + +function locateReply( + root: string, + env: NodeJS.ProcessEnv, +): string | undefined { + const candidates = [ + env.SAMCHON_GRAPH_CMAKE_REPLY, + path.join(root, ".cmake", "api", "v1", "reply"), + path.join(root, "build", ".cmake", "api", "v1", "reply"), + path.join(root, "cmake-build-debug", ".cmake", "api", "v1", "reply"), + path.join(root, "cmake-build-release", ".cmake", "api", "v1", "reply"), + ].filter((value): value is string => value !== undefined); + return candidates.map((value) => path.resolve(value)).find((value) => + fs.existsSync(value), + ); +} + +function latestIndex(reply: string): string { + const files = fs + .readdirSync(reply) + .filter((file) => /^index-.*\.json$/.test(file)) + .sort(compareRepositoryText); + const latest = files.at(-1); + if (latest === undefined) { + throw new Error("CMake File API reply directory has no index"); + } + return path.join(reply, latest); +} + +function readJson(file: string): T { + return JSON.parse(fs.readFileSync(file, "utf8")) as T; +} + +function dedupeNodes( + input: readonly ISamchonRepositoryContextDump.INode[], +): ISamchonRepositoryContextDump.INode[] { + const rows = new Map(); + for (const node of input) rows.set(node.id, node); + return [...rows.values()].sort((left, right) => + compareRepositoryText(left.id, right.id), + ); +} + +function dedupeEdges( + input: readonly ISamchonRepositoryContextDump.IEdge[], +): ISamchonRepositoryContextDump.IEdge[] { + const rows = new Map(); + for (const edge of input) { + rows.set(`${edge.kind}\0${edge.from}\0${edge.to}`, edge); + } + return [...rows.values()].sort( + (left, right) => + compareRepositoryText(left.kind, right.kind) || + compareRepositoryText(left.from, right.from) || + compareRepositoryText(left.to, right.to), + ); +} + +function isInside(root: string, file: string): boolean { + const relative = path.relative(root, file); + return relative !== ".." && !relative.startsWith(`..${path.sep}`); +} + +function throwIfAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) { + throw new Error("CMake repository context cancelled"); + } +} diff --git a/packages/graph/src/repository/createRepositoryContextSession.ts b/packages/graph/src/repository/createRepositoryContextSession.ts new file mode 100644 index 00000000..31641a9b --- /dev/null +++ b/packages/graph/src/repository/createRepositoryContextSession.ts @@ -0,0 +1,275 @@ +import path from "node:path"; + +import { IRepositoryContextProvider } from "./IRepositoryContextProvider"; +import { IRepositoryContextSession } from "./IRepositoryContextSession"; +import { RepositoryContextProtocol } from "./RepositoryContextProtocol"; +import { repositoryContextFacts } from "./repositoryContextFacts"; + +const { repositoryContextPathDigest } = repositoryContextFacts; + +/** Build the common atomic resident shell around an owning-tool collector. */ +export function createRepositoryContextSession( + provider: Pick< + IRepositoryContextProvider, + "name" | "ecosystem" | "authority" | "families" | "buildInputs" + >, + props: IRepositoryContextProvider.IOpenProps, + collect: IRepositoryContextProvider.Collector, +): IRepositoryContextSession { + const store = new RepositoryContextProtocol.Store(); + let generation = 0; + let closed = false; + let inputState: string | undefined; + let queue = Promise.resolve(); + let currentWarnings: string[] = []; + + return { + kind: "repository-context", + provider: provider.name, + ecosystem: provider.ecosystem, + root: props.root, + get generation() { + return generation; + }, + get current() { + return store.current; + }, + refresh(options = {}) { + return enqueue(async () => { + assertOpen(); + throwIfAborted(options.signal); + const observed = createRepositoryContextSession.observeInputGeneration( + props.root, + provider.buildInputs, + store.current?.sources.map((source) => source.file), + props.env, + ); + if (store.current !== undefined && observed === inputState) { + return { + changed: false, + generation, + mode: "unchanged" as const, + snapshot: store.current, + warnings: [...currentWarnings], + }; + } + + const collected = await collect({ ...props, signal: options.signal }); + assertOpen(); + throwIfAborted(options.signal); + const sources = collected.shards.flatMap((shard) => shard.sources); + const afterCollection = + createRepositoryContextSession.observeInputGeneration( + props.root, + provider.buildInputs, + sources.map((source) => source.file), + props.env, + ); + const consumed = consumedInputState( + props.root, + provider.buildInputs, + sources, + props.env, + ); + if (afterCollection !== consumed) { + throw new Error( + `repository context provider ${provider.name} inputs changed while its model was being collected`, + ); + } + const manifest = RepositoryContextProtocol.manifestDigest(sources); + const sequence = generation + 1; + const token = RepositoryContextProtocol.digest({ + provider: provider.name, + sequence, + universe: collected.universe, + manifest, + }); + const previous = store.current; + const priorShards = new Map( + previous?.generation.shards.map((entry) => [entry.key, entry.digest]) ?? + [], + ); + const nextShards = new Map( + collected.shards.map((shard) => [ + shard.key, + RepositoryContextProtocol.shardDigest(shard), + ]), + ); + const frames: RepositoryContextProtocol.Frame[] = [ + { + type: "hello", + protocolVersion: 1, + schemaVersion: 1, + producerSchemaVersion: collected.producerSchemaVersion, + provider: provider.name, + ecosystem: provider.ecosystem, + authority: provider.authority, + tool: collected.tool, + toolVersion: collected.toolVersion, + supportedFamilies: [...provider.families], + capabilities: [...collected.capabilities], + }, + { + type: "begin", + sequence, + generation: token, + ...(previous !== undefined + ? { + baseSequence: previous.generation.sequence, + baseGeneration: previous.generation.token, + } + : {}), + inputGeneration: RepositoryContextProtocol.digest({ + universe: collected.universe, + manifest, + }), + universe: collected.universe, + target: collected.target, + manifest, + }, + ]; + for (const key of [...priorShards.keys()].sort(compare)) { + if (!nextShards.has(key)) frames.push({ type: "deleteShard", key }); + } + for (const shard of [...collected.shards].sort((left, right) => + compare(left.key, right.key), + )) { + const digest = nextShards.get(shard.key)!; + if (priorShards.get(shard.key) !== digest) { + frames.push({ + type: "upsertShard", + digest, + shard, + }); + } + } + const facts = { + nodes: collected.shards.flatMap((shard) => shard.nodes), + edges: collected.shards.flatMap((shard) => shard.edges), + coverage: collected.shards.flatMap((shard) => shard.coverage), + }; + frames.push({ + type: "commit", + sequence, + generation: token, + shards: [...nextShards] + .sort(([left], [right]) => compare(left, right)) + .map(([key, digest]) => ({ key, digest })), + contentDigest: RepositoryContextProtocol.contentDigest(facts), + }); + const snapshot = store.apply(frames, options); + generation = sequence; + currentWarnings = [...collected.warnings]; + inputState = createRepositoryContextSession.observeInputGeneration( + props.root, + provider.buildInputs, + snapshot.sources.map((source) => source.file), + props.env, + ); + return { + changed: true, + generation, + mode: + previous === undefined + ? ("initial" as const) + : previous.begin.universe === snapshot.begin.universe + ? ("incremental" as const) + : ("reload" as const), + snapshot, + warnings: [...currentWarnings], + }; + }); + }, + close() { + closed = true; + return queue; + }, + }; + + function enqueue(task: () => Promise): Promise { + const result = queue.catch(() => undefined).then(task); + queue = result.then(() => undefined).catch(() => undefined); + return result; + } + + function assertOpen(): void { + if (closed) { + throw new Error( + `repository context provider ${provider.name} is closed`, + ); + } + } +} + +export namespace createRepositoryContextSession { +/** Fingerprint the current declared and previously published provider inputs. */ + export function observeInputGeneration( + root: string, + declared: readonly string[], + published: readonly string[] | undefined, + env: NodeJS.ProcessEnv, +): string { + const files = new Set([ + ...declared.map((file) => normalize(root, file)), + ...(published ?? []).map((file) => normalize(root, file)), + ]); + const rows = [...files].sort(compare).map((file) => ({ + file: relative(root, file), + digest: repositoryContextPathDigest(file), + })); + return RepositoryContextProtocol.digest({ + rows, + path: env.PATH ?? "", + }); +} +/* c8 ignore start -- declaration merging emits a namespace creation arm after + * the function object already exists, so that arm is unreachable. */ +} +/* c8 ignore stop */ + +function consumedInputState( + root: string, + declared: readonly string[], + published: readonly { file: string; digest: string }[], + env: NodeJS.ProcessEnv, +): string { + const consumed = new Map( + published.map((source) => [ + normalize(root, source.file), + source.digest, + ]), + ); + for (const file of declared.map((entry) => normalize(root, entry))) { + if (!consumed.has(file)) { + consumed.set(file, repositoryContextPathDigest(file)); + } + } + return RepositoryContextProtocol.digest({ + rows: [...consumed] + .sort(([left], [right]) => compare(left, right)) + .map(([file, digest]) => ({ + file: relative(root, file), + digest, + })), + path: env.PATH ?? "", + }); +} + +function normalize(root: string, file: string): string { + return path.resolve(root, file); +} + +function relative(root: string, file: string): string { + return path.relative(root, file).replaceAll("\\", "/") || "."; +} + +function throwIfAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) { + throw new Error("repository context provider refresh cancelled"); + } +} + +function compare(left: string, right: string): number { + /* c8 ignore next -- canonical input and shard sets contain distinct keys. */ + return left < right ? -1 : left > right ? 1 : 0; +} diff --git a/packages/graph/src/repository/createResidentRepositoryContextMemorySource.ts b/packages/graph/src/repository/createResidentRepositoryContextMemorySource.ts new file mode 100644 index 00000000..3d8787e3 --- /dev/null +++ b/packages/graph/src/repository/createResidentRepositoryContextMemorySource.ts @@ -0,0 +1,20 @@ +import { SamchonRepositoryContextMemory } from "./SamchonRepositoryContextMemory"; +import { IResidentRepositoryContextSource } from "./IResidentRepositoryContextSource"; + +/** Reuse the exact topology memory while its resident dump identity is stable. */ +export function createResidentRepositoryContextMemorySource( + resident: IResidentRepositoryContextSource, +): () => Promise { + let currentDump: + | Awaited> + | undefined; + let currentMemory: SamchonRepositoryContextMemory | undefined; + return async () => { + const dump = await resident.load(); + if (currentMemory === undefined || dump !== currentDump) { + currentDump = dump; + currentMemory = new SamchonRepositoryContextMemory(dump); + } + return currentMemory; + }; +} diff --git a/packages/graph/src/repository/createResidentRepositoryContextSource.ts b/packages/graph/src/repository/createResidentRepositoryContextSource.ts new file mode 100644 index 00000000..a044b28d --- /dev/null +++ b/packages/graph/src/repository/createResidentRepositoryContextSource.ts @@ -0,0 +1,328 @@ +import path from "node:path"; + +import { ISamchonRepositoryContextDump } from "../structures"; +import { IRepositoryContextProvider } from "./IRepositoryContextProvider"; +import { IRepositoryContextSession } from "./IRepositoryContextSession"; +import { IResidentRepositoryContextSource } from "./IResidentRepositoryContextSource"; +import { REPOSITORY_CONTEXT_PROVIDERS } from "./REPOSITORY_CONTEXT_PROVIDERS"; +import { RepositoryContextProtocol } from "./RepositoryContextProtocol"; +import { createRepositoryContextSession } from "./createRepositoryContextSession"; +import { repositoryContextFacts } from "./repositoryContextFacts"; + +const { compareRepositoryText } = repositoryContextFacts; + +/** Open and atomically merge every detected repository-context provider. */ +export function createResidentRepositoryContextSource( + root: string, + env: NodeJS.ProcessEnv = process.env, + providers: readonly IRepositoryContextProvider[] = REPOSITORY_CONTEXT_PROVIDERS, +): IResidentRepositoryContextSource { + const project = path.resolve(root); + const sessions = providers + .filter((provider) => provider.detect(project)) + .map((provider) => ({ + provider, + session: provider.open({ root: project, env }), + })); + let current: ISamchonRepositoryContextDump | undefined; + let sequence = 0; + let queue = Promise.resolve(); + let closed = false; + let priorFailures: string[] = []; + + return { + load(options = {}) { + return enqueue(async () => { + assertOpen(); + const snapshots: Array<{ + provider: IRepositoryContextProvider; + snapshot: NonNullable; + warnings: string[]; + }> = []; + const failures: IProviderFailure[] = []; + let changed = current === undefined; + for (const row of sessions) { + try { + const refresh = await row.session.refresh(options); + changed ||= refresh.changed; + snapshots.push({ + provider: row.provider, + snapshot: refresh.snapshot, + warnings: refresh.warnings, + }); + } catch (error) { + if (options.signal?.aborted) throw error; + failures.push({ + provider: row.provider, + inputGeneration: + createRepositoryContextSession.observeInputGeneration( + project, + row.provider.buildInputs, + row.session.current?.sources.map((source) => source.file), + env, + ), + message: error instanceof Error ? error.message : String(error), + }); + } + } + if ( + !changed && + sameStrings( + failures.map(failureIdentity), + priorFailures, + ) && + current !== undefined + ) { + return current; + } + const next = assemble(project, sequence + 1, snapshots, failures); + sequence = next.generation.sequence; + priorFailures = failures.map(failureIdentity); + current = next; + return next; + }); + }, + close() { + closed = true; + return enqueue(async () => { + let failure: Error | undefined; + for (const row of sessions) { + try { + await row.session.close(); + } catch (error) { + failure ??= + error instanceof Error ? error : new Error(String(error)); + } + } + if (failure !== undefined) throw failure; + }, true); + }, + }; + + function enqueue( + task: () => Promise, + allowClosed = false, + ): Promise { + const result = queue + .catch(() => undefined) + .then(() => { + if (!allowClosed) assertOpen(); + return task(); + }); + queue = result.then(() => undefined).catch(() => undefined); + return result; + } + + function assertOpen(): void { + if (closed) { + throw new Error("repository context source is closed"); + } + } +} + +function sameStrings( + left: readonly string[], + right: readonly string[], +): boolean { + return ( + left.length === right.length && + left.every((value, index) => value === right[index]) + ); +} + +interface IProviderFailure { + provider: IRepositoryContextProvider; + inputGeneration: string; + message: string; +} + +function failureIdentity(failure: IProviderFailure): string { + return [ + failure.provider.name, + failure.inputGeneration, + failure.message, + ].join("\0"); +} + +function assemble( + project: string, + sequence: number, + rows: readonly { + provider: IRepositoryContextProvider; + snapshot: RepositoryContextProtocol.ISnapshot; + warnings: readonly string[]; + }[], + failures: readonly IProviderFailure[], +): ISamchonRepositoryContextDump { + const nodes = rows.flatMap((row) => row.snapshot.nodes); + const edges = rows.flatMap((row) => row.snapshot.edges); + const coverage = rows.flatMap((row) => row.snapshot.coverage); + for (const failure of failures) { + coverage.push( + ...RepositoryContextProtocol.RELATION_KINDS.map((family) => ({ + provider: failure.provider.name, + ecosystem: failure.provider.ecosystem, + target: "unavailable", + family, + state: "unsupported" as const, + })), + ); + } + const sources = mergeSources(rows.flatMap((row) => row.snapshot.sources)); + const shards = rows + .flatMap((row) => + row.snapshot.generation.shards.map((shard) => ({ + key: `${row.provider.name}/${shard.key}`, + digest: shard.digest, + })), + ) + .sort((left, right) => compareRepositoryText(left.key, right.key)); + const inputGeneration = RepositoryContextProtocol.digest( + [ + ...rows.map((row) => ({ + provider: row.provider.name, + generation: row.snapshot.begin.inputGeneration, + })), + ...failures.map((failure) => ({ + provider: failure.provider.name, + generation: failure.inputGeneration, + })), + ] + .sort((left, right) => + compareRepositoryText(left.provider, right.provider), + ), + ); + const contentDigest = RepositoryContextProtocol.digest({ + nodes: [...nodes].sort((left, right) => + compareRepositoryText(left.id, right.id), + ), + edges: [...edges].sort( + (left, right) => + compareRepositoryText(left.kind, right.kind) || + compareRepositoryText(left.from, right.from) || + compareRepositoryText(left.to, right.to), + ), + coverage, + }); + const dump: ISamchonRepositoryContextDump = { + project, + schemaVersion: 1, + inputGeneration, + generation: { + sequence, + token: RepositoryContextProtocol.digest({ + sequence, + inputGeneration, + contentDigest, + }), + shards, + contentDigest, + }, + provenance: rows + .map(({ provider, snapshot }) => ({ + provider: provider.name, + ecosystem: provider.ecosystem, + authority: provider.authority, + tool: snapshot.hello.tool, + toolVersion: snapshot.hello.toolVersion, + schemaVersion: snapshot.hello.producerSchemaVersion, + protocolVersion: snapshot.hello.protocolVersion, + universe: snapshot.begin.universe, + manifest: snapshot.begin.manifest, + content: snapshot.generation.contentDigest, + capabilities: [...snapshot.hello.capabilities], + })) + .sort((left, right) => + compareRepositoryText(left.provider, right.provider), + ), + coverage: coverage.sort( + (left, right) => + compareRepositoryText(left.provider, right.provider) || + compareRepositoryText(left.family, right.family), + ), + nodes: dedupeNodes(nodes), + edges: dedupeEdges(edges), + files: [ + ...new Set(rows.flatMap((row) => row.snapshot.files)), + ].sort(compareRepositoryText), + sources, + warnings: [ + ...rows.flatMap((row) => row.warnings), + ...failures.map( + (failure) => + `repository context unavailable: ${failure.provider.name}: ${failure.message}`, + ), + ].sort(compareRepositoryText), + }; + freeze(dump); + return dump; +} + +function mergeSources( + input: readonly ISamchonRepositoryContextDump.ISource[], +): ISamchonRepositoryContextDump.ISource[] { + const rows = new Map(); + for (const source of input) { + const prior = rows.get(source.file); + if (prior !== undefined && prior !== source.digest) { + throw new Error( + `repository context providers disagree about input ${source.file}`, + ); + } + rows.set(source.file, source.digest); + } + return [...rows] + .sort(([left], [right]) => compareRepositoryText(left, right)) + .map(([file, digest]) => ({ file, digest })); +} + +function dedupeNodes( + input: readonly ISamchonRepositoryContextDump.INode[], +): ISamchonRepositoryContextDump.INode[] { + const rows = new Map(); + for (const node of input) { + if (rows.has(node.id)) { + throw new Error( + `repository context providers published duplicate node ${node.id}`, + ); + } + rows.set(node.id, node); + } + return [...rows.values()].sort((left, right) => + compareRepositoryText(left.id, right.id), + ); +} + +function dedupeEdges( + input: readonly ISamchonRepositoryContextDump.IEdge[], +): ISamchonRepositoryContextDump.IEdge[] { + const rows = new Map(); + for (const edge of input) { + const key = `${edge.kind}\0${edge.from}\0${edge.to}`; + /* c8 ignore start -- an equal edge requires equal endpoint identities, + * which dedupeNodes rejects before edge deduplication is reached. */ + if (rows.has(key)) { + throw new Error( + `repository context providers published duplicate edge ${edge.kind}`, + ); + } + /* c8 ignore stop */ + rows.set(key, edge); + } + return [...rows.values()].sort( + /* c8 ignore start -- edge tuple keys are distinct after the guard above. */ + (left, right) => + compareRepositoryText(left.kind, right.kind) || + compareRepositoryText(left.from, right.from) || + compareRepositoryText(left.to, right.to), + /* c8 ignore stop */ + ); +} + +function freeze(value: unknown): void { + if (value === null || typeof value !== "object" || Object.isFrozen(value)) { + return; + } + Object.freeze(value); + for (const child of Object.values(value)) freeze(child); +} diff --git a/packages/graph/src/repository/gradleRepositoryContextProvider.ts b/packages/graph/src/repository/gradleRepositoryContextProvider.ts new file mode 100644 index 00000000..50ed60aa --- /dev/null +++ b/packages/graph/src/repository/gradleRepositoryContextProvider.ts @@ -0,0 +1,364 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +import { ISamchonRepositoryContextDump } from "../structures"; +import { IRepositoryContextProvider } from "./IRepositoryContextProvider"; +import { createRepositoryContextSession } from "./createRepositoryContextSession"; +import { parseGradleRepositoryContextModel } from "./parseGradleRepositoryContextModel"; +import { repositoryContextFacts } from "./repositoryContextFacts"; + +const { + compareRepositoryText, + repositoryContextCoverage, + repositoryContextEvidence, + repositoryContextFile, + repositoryContextId, + repositoryContextSource, + uniqueRepositorySources, +} = repositoryContextFacts; + +const PROVIDER = "gradle-tooling-api"; +const ECOSYSTEM = "gradle"; +const TARGET = "workspace"; + +export const gradleRepositoryContextProvider: IRepositoryContextProvider & { + collect: typeof collectGradleRepositoryContext; +} = { + name: PROVIDER, + ecosystem: ECOSYSTEM, + authority: "tool-resolved", + families: [ + "contains", + "depends-on", + "source-of", + "test-of", + "joins-file", + ], + buildInputs: [ + "settings.gradle", + "settings.gradle.kts", + "build.gradle", + "build.gradle.kts", + "gradle.properties", + "gradle/libs.versions.toml", + "gradle/wrapper/gradle-wrapper.properties", + ], + detect: (root) => + ["settings.gradle", "settings.gradle.kts"].some((file) => + fs.existsSync(path.join(root, file)), + ), + open: (props) => + createRepositoryContextSession( + gradleRepositoryContextProvider, + props, + collectGradleRepositoryContext, + ), + collect: collectGradleRepositoryContext, +}; + +function collectGradleRepositoryContext( + props: IRepositoryContextProvider.IOpenProps & { signal?: AbortSignal }, + execute: typeof executeGradleModel = executeGradleModel, +): IRepositoryContextProvider.ICollection { + throwIfAborted(props.signal); + if (props.env.SAMCHON_GRAPH_ALLOW_GRADLE_MODEL !== "1") { + throw new Error( + "Gradle repository context is disabled until SAMCHON_GRAPH_ALLOW_GRADLE_MODEL=1 acknowledges that the Tooling API evaluates project build configuration; no task is run.", + ); + } + const model = execute(props.root, props.env); + throwIfAborted(props.signal); + const workspaceId = repositoryContextId(ECOSYSTEM, "workspace", "."); + const settings = firstExisting(props.root, [ + "settings.gradle", + "settings.gradle.kts", + ]); + const nodes: ISamchonRepositoryContextDump.INode[] = [ + { + id: workspaceId, + kind: "workspace", + name: path.basename(props.root), + ecosystem: ECOSYSTEM, + coordinate: ".", + configuration: "default", + external: false, + evidence: repositoryContextEvidence(props.root, settings), + }, + ]; + const edges: ISamchonRepositoryContextDump.IEdge[] = []; + const files = new Set(); + const sources = gradleInputs(props.root).map((file) => + repositoryContextSource(props.root, file), + ); + const projectIds = new Map(); + const names = new Map(); + + for (const module of [...model.modules].sort((left, right) => + compareRepositoryText(left.path, right.path), + )) { + const projectId = repositoryContextId( + ECOSYSTEM, + "project", + module.path, + ); + const buildTargetId = repositoryContextId( + ECOSYSTEM, + "build-target", + module.path, + ); + projectIds.set(module.path, projectId); + names.set(module.name, [...(names.get(module.name) ?? []), projectId]); + const buildFile = firstExisting(module.directory, [ + "build.gradle", + "build.gradle.kts", + ]); + sources.push(repositoryContextSource(props.root, buildFile)); + const evidence = repositoryContextEvidence(props.root, buildFile); + nodes.push( + { + id: projectId, + kind: "project", + name: module.name, + ecosystem: ECOSYSTEM, + coordinate: module.path, + configuration: "default", + external: false, + evidence, + }, + { + id: buildTargetId, + kind: "build-target", + name: module.path, + ecosystem: ECOSYSTEM, + coordinate: module.path, + configuration: "default", + external: false, + evidence, + }, + ); + edges.push( + { kind: "contains", from: workspaceId, to: projectId }, + { kind: "contains", from: projectId, to: buildTargetId }, + ); + for (const source of module.sources) { + const coordinate = `${module.path}:${repositoryContextFile( + props.root, + source.directory, + )}`; + const sourceId = repositoryContextId( + ECOSYSTEM, + source.generated ? "generated-root" : "source-root", + coordinate, + ); + nodes.push({ + id: sourceId, + kind: source.generated ? "generated-root" : "source-root", + name: path.basename(source.directory), + ecosystem: ECOSYSTEM, + coordinate, + configuration: source.kind, + external: !isInside(props.root, source.directory), + root: repositoryContextFile(props.root, source.directory), + evidence, + }); + edges.push( + { kind: "contains", from: buildTargetId, to: sourceId }, + { kind: "source-of", from: sourceId, to: projectId }, + ); + if (source.kind.startsWith("test")) { + edges.push({ kind: "test-of", from: sourceId, to: projectId }); + } + } + for (const task of module.tasks) { + const taskId = repositoryContextId( + ECOSYSTEM, + "task", + task.path, + ); + nodes.push({ + id: taskId, + kind: "task", + name: task.name, + ecosystem: ECOSYSTEM, + coordinate: task.path, + configuration: "default", + external: false, + evidence, + }); + edges.push({ kind: "contains", from: projectId, to: taskId }); + } + } + + let unresolvedDependencies = 0; + for (const module of model.modules) { + const from = projectIds.get(module.path)!; + for (const dependency of module.dependencies) { + const candidates = names.get(dependency) ?? []; + const to = + projectIds.get(dependency) ?? + (candidates.length === 1 ? candidates[0] : undefined); + if (to !== undefined) edges.push({ kind: "depends-on", from, to }); + else unresolvedDependencies += 1; + } + } + const shard = { + key: `${PROVIDER}:workspace`, + target: TARGET, + nodes: nodes.sort((left, right) => + compareRepositoryText(left.id, right.id), + ), + edges: dedupeEdges(edges), + coverage: repositoryContextCoverage( + PROVIDER, + ECOSYSTEM, + TARGET, + [ + "contains", + ...(unresolvedDependencies === 0 ? ["depends-on" as const] : []), + "source-of", + "test-of", + "joins-file", + ], + unresolvedDependencies === 0 ? [] : ["depends-on"], + ), + files: [...files].sort(compareRepositoryText), + sources: uniqueRepositorySources(sources), + }; + return { + producerSchemaVersion: 1, + tool: "Gradle Tooling API", + toolVersion: model.version, + capabilities: [ + "projects", + "project-dependencies", + "source-directories", + "tasks", + "daemon-reuse", + ], + universe: `${ECOSYSTEM}:${shard.sources + .map((source) => `${source.file}:${source.digest}`) + .join("|")}`, + target: TARGET, + shards: [shard], + warnings: [ + "Gradle Tooling API model evaluation was explicitly enabled; no build task was invoked.", + ...(unresolvedDependencies === 0 + ? [] + : [ + `${unresolvedDependencies} Gradle project dependencies had ambiguous or absent Tooling API module identities.`, + ]), + ], + }; +} + +function executeGradleModel( + root: string, + env: NodeJS.ProcessEnv, +): parseGradleRepositoryContextModel.IModel { + const classpath = gradleToolingClasspath(env); + if (classpath === undefined) { + throw new Error( + "Gradle Tooling API classpath is unavailable; set SAMCHON_GRAPH_GRADLE_TOOLING_CLASSPATH or GRADLE_HOME without downloading or mutating the project.", + ); + } + /* c8 ignore next 4 -- a coverage host exercises exactly one native Java + * executable suffix; JAVA_HOME and PATH selection are both tested. */ + const java = + env.JAVA_HOME !== undefined + ? path.join(env.JAVA_HOME, "bin", process.platform === "win32" ? "java.exe" : "java") + : "java"; + const source = path.resolve( + __dirname, + "..", + "..", + "sidecars", + "gradle", + "RepositoryContext.java", + ); + const result = spawnSync( + java, + ["--class-path", classpath, source, path.resolve(root)], + { + cwd: root, + env, + encoding: "utf8", + windowsHide: true, + }, + ); + if (result.status !== 0) { + /* c8 ignore start -- direct-spawn error details differ by operating + * system; explicit classpath, GRADLE_HOME and failure paths are tested. */ + const failure = + result.stderr || result.error?.message || "unknown error"; + /* c8 ignore stop */ + throw new Error( + `Gradle Tooling API model failed: ${failure.trim()}`, + ); + } + /* c8 ignore start -- a successful external JVM boundary needs an installed + * Tooling API; its complete output parser is tested independently. */ + return parseGradleRepositoryContextModel(result.stdout); +} +/* c8 ignore stop */ + +function gradleToolingClasspath( + env: NodeJS.ProcessEnv, +): string | undefined { + if (env.SAMCHON_GRAPH_GRADLE_TOOLING_CLASSPATH?.trim()) { + return env.SAMCHON_GRAPH_GRADLE_TOOLING_CLASSPATH; + } + if (!env.GRADLE_HOME?.trim()) return undefined; + return [ + path.join(env.GRADLE_HOME, "lib", "*"), + path.join(env.GRADLE_HOME, "lib", "plugins", "*"), + ].join(path.delimiter); +} + +function gradleInputs(root: string): string[] { + return [ + "settings.gradle", + "settings.gradle.kts", + "build.gradle", + "build.gradle.kts", + "gradle.properties", + "gradle/libs.versions.toml", + "gradle/wrapper/gradle-wrapper.properties", + ] + .map((file) => path.join(root, file)) + .filter((file) => fs.existsSync(file)); +} + +function firstExisting(root: string, candidates: readonly string[]): string { + return ( + candidates + .map((file) => path.join(root, file)) + .find((file) => fs.existsSync(file)) ?? path.join(root, candidates[0]!) + ); +} + +function dedupeEdges( + input: readonly ISamchonRepositoryContextDump.IEdge[], +): ISamchonRepositoryContextDump.IEdge[] { + const rows = new Map(); + for (const edge of input) { + rows.set(`${edge.kind}\0${edge.from}\0${edge.to}`, edge); + } + return [...rows.values()].sort( + (left, right) => + compareRepositoryText(left.kind, right.kind) || + compareRepositoryText(left.from, right.from) || + compareRepositoryText(left.to, right.to), + ); +} + +function isInside(root: string, file: string): boolean { + const relative = path.relative(root, file); + return relative !== ".." && !relative.startsWith(`..${path.sep}`); +} + +function throwIfAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) { + throw new Error("Gradle repository context cancelled"); + } +} diff --git a/packages/graph/src/repository/index.ts b/packages/graph/src/repository/index.ts new file mode 100644 index 00000000..2215b202 --- /dev/null +++ b/packages/graph/src/repository/index.ts @@ -0,0 +1,15 @@ +export * from "./cargoRepositoryContextProvider"; +export * from "./cmakeRepositoryContextProvider"; +export * from "./createRepositoryContextSession"; +export * from "./gradleRepositoryContextProvider"; +export * from "./IRepositoryContextProvider"; +export * from "./IRepositoryContextSession"; +export * from "./IResidentRepositoryContextSource"; +export * from "./pnpmRepositoryContextProvider"; +export * from "./REPOSITORY_CONTEXT_PROVIDERS"; +export * from "./repositoryContextFacts"; +export * from "./RepositoryContextProtocol"; +export * from "./createResidentRepositoryContextSource"; +export * from "./createResidentRepositoryContextMemorySource"; +export * from "./SamchonRepositoryContextMemory"; +export * from "./validateRepositoryContextProviders"; diff --git a/packages/graph/src/repository/parseGradleRepositoryContextModel.ts b/packages/graph/src/repository/parseGradleRepositoryContextModel.ts new file mode 100644 index 00000000..ee8c8ac1 --- /dev/null +++ b/packages/graph/src/repository/parseGradleRepositoryContextModel.ts @@ -0,0 +1,78 @@ +/** Parse the line-framed output produced by the packaged Gradle Tooling helper. */ +export function parseGradleRepositoryContextModel( + output: string, +): parseGradleRepositoryContextModel.IModel { + let version = ""; + const modules = new Map(); + for (const raw of output.split(/\r?\n/)) { + if (raw.trim() === "") continue; + const [kind, ...encoded] = raw.split("\t"); + const fields = encoded.map((value) => + Buffer.from(value, "base64url").toString("utf8"), + ); + if (kind === "V" && fields.length === 1) { + version = fields[0]!; + } else if (kind === "M" && fields.length === 3) { + modules.set(fields[0]!, { + path: fields[0]!, + name: fields[1]!, + directory: fields[2]!, + dependencies: [], + sources: [], + tasks: [], + }); + } else if (kind === "D" && fields.length === 2) { + requiredModule(modules, fields[0]!).dependencies.push(fields[1]!); + } else if (kind === "S" && fields.length === 4) { + requiredModule(modules, fields[0]!).sources.push({ + kind: fields[1]!, + directory: fields[2]!, + generated: fields[3] === "true", + }); + } else if (kind === "T" && fields.length === 3) { + requiredModule(modules, fields[0]!).tasks.push({ + path: fields[1]!, + name: fields[2]!, + }); + } else { + throw new Error("Gradle Tooling API helper returned a malformed model"); + } + } + if (version === "" || modules.size === 0) { + throw new Error("Gradle Tooling API helper returned an empty model"); + } + return { version, modules: [...modules.values()] }; +} + +export namespace parseGradleRepositoryContextModel { + export interface IModel { + version: string; + modules: IModule[]; + } + + export interface IModule { + path: string; + name: string; + directory: string; + dependencies: string[]; + sources: Array<{ + kind: string; + directory: string; + generated: boolean; + }>; + tasks: Array<{ path: string; name: string }>; + } +} + +function requiredModule( + modules: ReadonlyMap, + project: string, +): parseGradleRepositoryContextModel.IModule { + const found = modules.get(project); + if (found === undefined) { + throw new Error( + `Gradle Tooling API helper referenced unknown project ${project}`, + ); + } + return found; +} diff --git a/packages/graph/src/repository/pnpmRepositoryContextProvider.ts b/packages/graph/src/repository/pnpmRepositoryContextProvider.ts new file mode 100644 index 00000000..70b6fe65 --- /dev/null +++ b/packages/graph/src/repository/pnpmRepositoryContextProvider.ts @@ -0,0 +1,438 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +import { ISamchonRepositoryContextDump } from "../structures"; +import { spawnableCommand } from "../utils/spawnableCommand"; +import { IRepositoryContextProvider } from "./IRepositoryContextProvider"; +import { createRepositoryContextSession } from "./createRepositoryContextSession"; +import { repositoryContextFacts } from "./repositoryContextFacts"; + +const { + compareRepositoryText, + repositoryContextCoverage, + repositoryContextEvidence, + repositoryContextFile, + repositoryContextId, + repositoryContextSource, + uniqueRepositorySources, +} = repositoryContextFacts; + +const PROVIDER = "pnpm-workspace"; +const ECOSYSTEM = "pnpm"; +const TARGET = "workspace"; + +interface IPnpmPackage { + name?: string; + version?: string; + path: string; + private?: boolean; + dependencies?: Record; + devDependencies?: Record; + optionalDependencies?: Record; +} + +interface IPnpmDependency { + path?: string; +} + +interface IPackageManifest { + name?: string; + files?: string[]; + scripts?: Record; + main?: string; + module?: string; + types?: string; + typings?: string; + bin?: string | Record; + exports?: unknown; +} + +export const pnpmRepositoryContextProvider: IRepositoryContextProvider & { + collect: typeof collectPnpmRepositoryContext; +} = { + name: PROVIDER, + ecosystem: ECOSYSTEM, + authority: "tool-resolved", + families: [ + "contains", + "depends-on", + "source-of", + "entrypoint-of", + "joins-file", + ], + buildInputs: [ + "package.json", + "pnpm-workspace.yaml", + "pnpm-lock.yaml", + "pnpm-workspace.yml", + ], + detect: (root) => + fs.existsSync(path.join(root, "pnpm-workspace.yaml")) || + fs.existsSync(path.join(root, "pnpm-workspace.yml")), + open: (props) => + createRepositoryContextSession( + pnpmRepositoryContextProvider, + props, + collectPnpmRepositoryContext, + ), + collect: collectPnpmRepositoryContext, +}; + +function collectPnpmRepositoryContext( + props: IRepositoryContextProvider.IOpenProps & { signal?: AbortSignal }, + execute: typeof executePnpm = executePnpm, +): IRepositoryContextProvider.ICollection { + throwIfAborted(props.signal); + const packages = execute(props.root, props.env); + throwIfAborted(props.signal); + const byPath = new Map( + packages.map((entry) => [path.resolve(entry.path), entry]), + ); + const workspace = repositoryContextId( + ECOSYSTEM, + "workspace", + repositoryContextFile(props.root, props.root), + ); + const nodes: ISamchonRepositoryContextDump.INode[] = [ + { + id: workspace, + kind: "workspace", + name: path.basename(props.root), + ecosystem: ECOSYSTEM, + coordinate: ".", + configuration: "default", + external: false, + evidence: workspaceEvidence(props.root), + }, + ]; + const edges: ISamchonRepositoryContextDump.IEdge[] = []; + const sources: ISamchonRepositoryContextDump.ISource[] = [ + ...workspaceInputs(props.root).map((file) => + repositoryContextSource(props.root, file), + ), + ]; + const files = new Set(); + + const packageIds = new Map(); + for (const entry of packages.sort((left, right) => + compareRepositoryText(left.path, right.path), + )) { + const absolute = path.resolve(entry.path); + const coordinate = repositoryContextFile(props.root, absolute); + const manifestFile = path.join(absolute, "package.json"); + const manifest = readManifest(manifestFile); + const packageId = repositoryContextId( + ECOSYSTEM, + "package", + manifest.name ?? entry.name ?? coordinate, + ); + packageIds.set(absolute, packageId); + sources.push(repositoryContextSource(props.root, manifestFile)); + nodes.push({ + id: packageId, + kind: "package", + name: manifest.name ?? entry.name ?? path.basename(absolute), + ecosystem: ECOSYSTEM, + coordinate, + configuration: "default", + external: false, + evidence: repositoryContextEvidence(props.root, manifestFile), + }); + edges.push({ kind: "contains", from: workspace, to: packageId }); + appendManifestFacts( + props.root, + absolute, + packageId, + manifest, + nodes, + edges, + files, + ); + } + for (const parent of new Set( + packages + .map((entry) => path.resolve(entry.path)) + .filter((directory) => directory !== path.resolve(props.root)) + .map((directory) => path.dirname(directory)), + )) { + sources.push(repositoryContextSource(props.root, parent)); + } + + for (const entry of packages) { + const from = packageIds.get(path.resolve(entry.path))!; + for (const dependency of dependencyRows(entry)) { + if (dependency.path === undefined) continue; + const target = packageIds.get(path.resolve(dependency.path)); + if (target !== undefined) { + edges.push({ kind: "depends-on", from, to: target }); + } + } + } + + const shard = { + key: `${PROVIDER}:workspace`, + target: TARGET, + nodes: nodes.sort((left, right) => + compareRepositoryText(left.id, right.id), + ), + edges: dedupeEdges(edges), + coverage: repositoryContextCoverage( + PROVIDER, + ECOSYSTEM, + TARGET, + ["contains", "depends-on", "entrypoint-of", "joins-file"], + ["source-of"], + ), + files: [...files].sort(compareRepositoryText), + sources: uniqueRepositorySources(sources), + }; + return { + producerSchemaVersion: 1, + tool: "pnpm", + toolVersion: detectPnpmVersion(props.root, props.env), + capabilities: [ + "workspace-members", + "resolved-local-dependencies", + "declared-entrypoints", + "declared-publication-roots", + ], + universe: `${ECOSYSTEM}:${shard.sources + .map((source) => `${source.file}:${source.digest}`) + .join("|")}`, + target: TARGET, + shards: [shard], + warnings: [ + "pnpm source-of coverage is partial: only package-manifest publication roots are declared facts.", + ], + }; +} + +function appendManifestFacts( + root: string, + packageRoot: string, + packageId: string, + manifest: IPackageManifest, + nodes: ISamchonRepositoryContextDump.INode[], + edges: ISamchonRepositoryContextDump.IEdge[], + files: Set, +): void { + const evidence = repositoryContextEvidence( + root, + path.join(packageRoot, "package.json"), + ); + for (const rootName of manifest.files ?? []) { + if (!isSimplePath(rootName)) continue; + const coordinate = `${repositoryContextFile(root, packageRoot)}/${rootName}`; + const generated = isGeneratedRoot(rootName); + const id = repositoryContextId( + ECOSYSTEM, + generated ? "generated-root" : "source-root", + coordinate, + ); + nodes.push({ + id, + kind: generated ? "generated-root" : "source-root", + name: rootName, + ecosystem: ECOSYSTEM, + coordinate, + configuration: "default", + external: false, + root: repositoryContextFile(root, path.resolve(packageRoot, rootName)), + evidence, + }); + edges.push({ kind: "contains", from: packageId, to: id }); + edges.push({ kind: "source-of", from: id, to: packageId }); + } + for (const [name, target] of entrypoints(manifest)) { + const coordinate = `${repositoryContextFile(root, packageRoot)}:${name}`; + const id = repositoryContextId( + ECOSYSTEM, + "entrypoint", + coordinate, + ); + const file = repositoryContextFile(root, path.resolve(packageRoot, target)); + files.add(file); + nodes.push({ + id, + kind: "entrypoint", + name, + ecosystem: ECOSYSTEM, + coordinate, + configuration: "default", + external: false, + file, + evidence, + }); + edges.push({ kind: "contains", from: packageId, to: id }); + edges.push({ kind: "entrypoint-of", from: id, to: packageId }); + edges.push({ kind: "joins-file", from: id, to: file }); + } + for (const name of Object.keys(manifest.scripts ?? {}).sort( + compareRepositoryText, + )) { + const coordinate = `${repositoryContextFile(root, packageRoot)}:${name}`; + const id = repositoryContextId(ECOSYSTEM, "task", coordinate); + nodes.push({ + id, + kind: "task", + name, + ecosystem: ECOSYSTEM, + coordinate, + configuration: "default", + external: false, + evidence, + }); + edges.push({ kind: "contains", from: packageId, to: id }); + } +} + +function entrypoints(manifest: IPackageManifest): Array<[string, string]> { + const rows: Array<[string, string]> = []; + for (const [name, value] of [ + ["main", manifest.main], + ["module", manifest.module], + ["types", manifest.types ?? manifest.typings], + ] as const) { + if (typeof value === "string") rows.push([name, value]); + } + if (typeof manifest.bin === "string") rows.push(["bin", manifest.bin]); + else { + for (const [name, value] of Object.entries(manifest.bin ?? {})) { + rows.push([`bin:${name}`, value]); + } + } + collectExports(manifest.exports, "exports", rows); + return [...new Map(rows.map(([name, value]) => [`${name}\0${value}`, [name, value] as [string, string]])).values()].sort( + ([left], [right]) => compareRepositoryText(left, right), + ); +} + +function collectExports( + value: unknown, + name: string, + rows: Array<[string, string]>, +): void { + if (typeof value === "string") { + rows.push([name, value]); + } else if (value !== null && typeof value === "object") { + for (const [key, child] of Object.entries(value).sort(([left], [right]) => + compareRepositoryText(left, right), + )) { + collectExports(child, `${name}:${key}`, rows); + } + } +} + +function dependencyRows(entry: IPnpmPackage): IPnpmDependency[] { + return Object.values({ + ...(entry.dependencies ?? {}), + ...(entry.devDependencies ?? {}), + ...(entry.optionalDependencies ?? {}), + }); +} + +function readManifest(file: string): IPackageManifest { + return JSON.parse(fs.readFileSync(file, "utf8")) as IPackageManifest; +} + +function workspaceInputs(root: string): string[] { + return [ + "package.json", + "pnpm-workspace.yaml", + "pnpm-workspace.yml", + "pnpm-lock.yaml", + ].filter((file) => fs.existsSync(path.join(root, file))); +} + +function workspaceEvidence( + root: string, +): ISamchonRepositoryContextDump.IEvidence { + const file = workspaceInputs(root).find((entry) => + entry.startsWith("pnpm-workspace."), + ); + return repositoryContextEvidence(root, path.join(root, file ?? "package.json")); +} + +function executePnpm( + root: string, + env: NodeJS.ProcessEnv, +): IPnpmPackage[] { + /* c8 ignore next -- each coverage host has exactly one native shim suffix. */ + const command = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; + const invocation = spawnableCommand( + command, + ["list", "-r", "--json", "--depth", "0"], + env, + ); + const result = spawnSync(invocation.command, invocation.args, { + cwd: root, + env, + encoding: "utf8", + windowsHide: true, + windowsVerbatimArguments: invocation.windowsVerbatimArguments, + }); + if (result.status !== 0) { + /* c8 ignore start -- direct-spawn errors and silent nonzero exits are + * operating-system fallbacks; stderr failures are exercised here. */ + const failure = + result.stderr || result.error?.message || "unknown error"; + /* c8 ignore stop */ + throw new Error( + `pnpm repository context failed: ${failure.trim()}`, + ); + } + const parsed = JSON.parse(result.stdout) as IPnpmPackage[]; + if (!Array.isArray(parsed) || parsed.some((entry) => !entry.path)) { + throw new Error("pnpm repository context returned a malformed package list"); + } + return parsed; +} + +function detectPnpmVersion(root: string, env: NodeJS.ProcessEnv): string { + /* c8 ignore next -- each coverage host has exactly one native shim suffix. */ + const command = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; + const invocation = spawnableCommand(command, ["--version"], env); + const result = spawnSync(invocation.command, invocation.args, { + cwd: root, + env, + encoding: "utf8", + windowsHide: true, + windowsVerbatimArguments: invocation.windowsVerbatimArguments, + }); + return result.status === 0 ? result.stdout.trim() : ""; +} + +function dedupeEdges( + input: readonly ISamchonRepositoryContextDump.IEdge[], +): ISamchonRepositoryContextDump.IEdge[] { + const rows = new Map(); + for (const edge of input) { + rows.set(`${edge.kind}\0${edge.from}\0${edge.to}`, edge); + } + return [...rows.values()].sort( + (left, right) => + compareRepositoryText(left.kind, right.kind) || + compareRepositoryText(left.from, right.from) || + compareRepositoryText(left.to, right.to), + ); +} + +function isSimplePath(value: string): boolean { + return ( + value.trim() !== "" && + !value.includes("*") && + !value.startsWith("!") && + !path.isAbsolute(value) + ); +} + +function isGeneratedRoot(value: string): boolean { + return /^(?:lib|dist|build|out)(?:\/|$)/.test(value.replaceAll("\\", "/")); +} + +function throwIfAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) { + throw new Error("pnpm repository context cancelled"); + } +} diff --git a/packages/graph/src/repository/repositoryContextFacts.ts b/packages/graph/src/repository/repositoryContextFacts.ts new file mode 100644 index 00000000..06174f24 --- /dev/null +++ b/packages/graph/src/repository/repositoryContextFacts.ts @@ -0,0 +1,124 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { ISamchonRepositoryContextDump } from "../structures"; +import { RepositoryContextRelationKind } from "../typings"; +import { RepositoryContextProtocol } from "./RepositoryContextProtocol"; + +/** Canonical repository-context identities, evidence and input digests. */ +export namespace repositoryContextFacts { + export function repositoryContextId( + ecosystem: string, + kind: ISamchonRepositoryContextDump.INode["kind"], + coordinate: string, + configuration = "default", +): string { + return `repository://${encodeURIComponent(ecosystem)}/${encodeURIComponent( + configuration, + )}/${encodeURIComponent(kind)}/${encodeURIComponent(coordinate)}`; +} + + export function repositoryContextCoverage( + provider: string, + ecosystem: string, + target: string, + complete: readonly RepositoryContextRelationKind[], + partial: readonly RepositoryContextRelationKind[] = [], +): ISamchonRepositoryContextDump.ICoverage[] { + return RepositoryContextProtocol.RELATION_KINDS.map((family) => ({ + provider, + ecosystem, + target, + family, + state: complete.includes(family) + ? "complete" + : partial.includes(family) + ? "partial" + : "unsupported", + })); +} + + export function repositoryContextSource( + root: string, + file: string, +): ISamchonRepositoryContextDump.ISource { + const absolute = path.resolve(root, file); + return { + file: repositoryContextFile(root, absolute), + digest: repositoryContextPathDigest(absolute), + }; +} + +/** Digest file bytes or one directory's immediate entry identities. */ + export function repositoryContextPathDigest(file: string): string { + try { + const stat = fs.statSync(file); + if (stat.isFile()) { + return RepositoryContextProtocol.digest(fs.readFileSync(file)); + } + if (stat.isDirectory()) { + return RepositoryContextProtocol.digest( + fs + .readdirSync(file, { withFileTypes: true }) + .map((entry) => ({ + name: entry.name, + kind: entry.isDirectory() + ? "directory" + : entry.isFile() + ? "file" + /* c8 ignore next -- special Dirents are platform-specific. */ + : "other", + })) + .sort((left, right) => compare(left.name, right.name)), + ); + } + } catch { + // The absent identity below is also used when a path moves mid-read. + } + return RepositoryContextProtocol.digest({ absent: true }); +} + + export function repositoryContextFile(root: string, file: string): string { + return path.relative(root, path.resolve(file)).replaceAll("\\", "/") || "."; +} + + export function repositoryContextEvidence( + root: string, + file: string, +): ISamchonRepositoryContextDump.IEvidence { + return { + file: repositoryContextFile(root, file), + startLine: 1, + startColumn: 1, + }; +} + + export function uniqueRepositorySources( + sources: readonly ISamchonRepositoryContextDump.ISource[], +): ISamchonRepositoryContextDump.ISource[] { + const unique = new Map(); + for (const source of sources) { + const prior = unique.get(source.file); + if (prior !== undefined && prior !== source.digest) { + throw new Error( + `repository context adapter: sources disagree about ${source.file}`, + ); + } + unique.set(source.file, source.digest); + } + return [...unique] + .sort(([left], [right]) => compare(left, right)) + .map(([file, digest]) => ({ file, digest })); +} + + export function compareRepositoryText( + left: string, + right: string, +): number { + return compare(left, right); +} + +function compare(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} +} diff --git a/packages/graph/src/repository/validateRepositoryContextProviders.ts b/packages/graph/src/repository/validateRepositoryContextProviders.ts new file mode 100644 index 00000000..3b415d03 --- /dev/null +++ b/packages/graph/src/repository/validateRepositoryContextProviders.ts @@ -0,0 +1,22 @@ +import { IRepositoryContextProvider } from "./IRepositoryContextProvider"; + +/** Validate unique, non-empty repository-context provider contracts. */ +export function validateRepositoryContextProviders( + providers: readonly IRepositoryContextProvider[], +): readonly IRepositoryContextProvider[] { + const names = new Set(); + for (const provider of providers) { + if (provider.name.trim() === "" || names.has(provider.name)) { + throw new Error( + `repository context registry has an invalid provider name: ${provider.name}`, + ); + } + names.add(provider.name); + if (provider.ecosystem.trim() === "" || provider.families.length === 0) { + throw new Error( + `repository context registry provider ${provider.name} has an empty contract`, + ); + } + } + return Object.freeze([...providers]); +} diff --git a/packages/graph/src/structures/ISamchonGraphApplication.ts b/packages/graph/src/structures/ISamchonGraphApplication.ts index fede08e7..9f10a827 100644 --- a/packages/graph/src/structures/ISamchonGraphApplication.ts +++ b/packages/graph/src/structures/ISamchonGraphApplication.ts @@ -8,6 +8,7 @@ import { ISamchonGraphCoverageSummary } from "./ISamchonGraphCoverageSummary"; import { ISamchonGraphDump } from "./ISamchonGraphDump"; import { ISamchonGraphTour } from "./ISamchonGraphTour"; import { ISamchonGraphTrace } from "./ISamchonGraphTrace"; +import { ISamchonGraphTopology } from "./ISamchonGraphTopology"; import { ISamchonGraphUnresolvedSummary } from "./ISamchonGraphUnresolvedSummary"; /** @@ -45,6 +46,8 @@ import { ISamchonGraphUnresolvedSummary } from "./ISamchonGraphUnresolvedSummary * the classes that implement an interface, which is the one call that answers * "what actually implements this". * - `overview`: project layers and folder structure. + * - `topology`: workspace, package, target, task, source-root, entrypoint, and + * project-dependency orientation from declared or owning-tool models. * - `escape`: the answer is outside the graph (source body text, files outside * the indexed languages, exact search). * @@ -93,24 +96,24 @@ import { ISamchonGraphUnresolvedSummary } from "./ISamchonGraphUnresolvedSummary */ export interface ISamchonGraphApplication { /** - * Answer a __LANG__ question from this repository's own program index. + * Answer a __LANG__ question from the repository's program index. * - * The graph returns proved indexed facts plus structured coverage and - * uncertainty. Submit exactly one request: + * The graph returns proved facts with coverage and uncertainty. Submit one + * request: * - * - `tour`: architecture, the runtime flow from the public API to the code that - * does the work, nearby paths, and the tests to read — a whole orientation - * in one call + * - `tour`: architecture, runtime flow, nearby paths, and tests * - `trace`: what a symbol calls, what calls it, or the path from A to B * - `details`: signatures, members, and what implements an interface * - `lookup`: where a named symbol is declared * - `entrypoints`: where execution starts, when the entry is unknown * - `overview`: the project's layers and folder structure + * - `topology`: repository workspaces, packages, roots, targets, tasks, and + * dependencies * * Every fact in a result is checked against the index before return, so no * fact needs verifying; for the ranked operations (`lookup`, `entrypoints`, - * `tour`), judge whether the shortlist covers your question. Read a file for - * what the graph does not carry: a body or the text inside a span. + * `tour`), judge whether the shortlist covers your question. Read source only + * for a body or span text. * * @param props Reasoning plus one graph request * @returns Matching `result` union member @@ -149,6 +152,7 @@ export namespace ISamchonGraphApplication { | ISamchonGraphDetails.IRequest | ISamchonGraphOverview.IRequest | ISamchonGraphTour.IRequest + | ISamchonGraphTopology.IRequest | ISamchonGraphEscape.IRequest; } @@ -210,6 +214,7 @@ export namespace ISamchonGraphApplication { | ISamchonGraphDetails | ISamchonGraphOverview | ISamchonGraphTour + | ISamchonGraphTopology | ISamchonGraphEscape; } } diff --git a/packages/graph/src/structures/ISamchonGraphDump.ts b/packages/graph/src/structures/ISamchonGraphDump.ts index b83caf49..670a6db6 100644 --- a/packages/graph/src/structures/ISamchonGraphDump.ts +++ b/packages/graph/src/structures/ISamchonGraphDump.ts @@ -41,6 +41,15 @@ export interface ISamchonGraphDump { /** Which indexing strategy produced the graph. */ indexer: "lsp" | "static" | "hybrid"; + /** + * Complete coordinator input generation used to fence code/topology joins. + * + * Absent only on dumps written before cross-plane generation fencing. + */ + generation?: { + input: string; + }; + /** What each strict provider proved about the slice it contributed, one row per provider, ordered by provider name so an unchanged checkout stays byte-identical. Absent when no strict provider served the build, and absent from dumps written before this field existed. Computation mode is deliberately not here: it belongs to one refresh rather than to the facts, so recording it would make two dumps of the same unedited checkout differ. */ provenance?: ISamchonGraphDump.IProvenance[]; diff --git a/packages/graph/src/structures/ISamchonGraphTopology.ts b/packages/graph/src/structures/ISamchonGraphTopology.ts new file mode 100644 index 00000000..62710c40 --- /dev/null +++ b/packages/graph/src/structures/ISamchonGraphTopology.ts @@ -0,0 +1,65 @@ +import { RepositoryContextRelationKind } from "../typings"; +import { ISamchonRepositoryContextDump } from "./ISamchonRepositoryContextDump"; + +/** + * A bounded repository-topology projection kept separate from code semantics. + */ +export interface ISamchonGraphTopology { + /** Discriminator for repository topology. */ + type: "topology"; + + /** Version of this result contract. */ + schemaVersion: 1; + + /** Matching workspace, project, package, root, target, task and entry nodes. */ + nodes: ISamchonRepositoryContextDump.INode[]; + + /** Matching repository relations, including compatible file joins. */ + edges: ISamchonRepositoryContextDump.IEdge[]; + + /** Provider, authority, tool, universe and content claims for this result. */ + provenance: ISamchonRepositoryContextDump.IProvenance[]; + + /** Operation-scoped completeness for requested relation families. */ + coverage: ISamchonRepositoryContextDump.ICoverage[]; + + /** Topology publication generation that supplied this result. */ + generation: ISamchonRepositoryContextDump.IGeneration; + + /** Whether this result may join its file identities to the code generation. */ + join: ISamchonGraphTopology.IJoin; + + /** Whether more matching nodes existed beyond the requested limit. */ + truncated: boolean; +} + +export namespace ISamchonGraphTopology { + /** Ask for one bounded repository-context view. */ + export interface IRequest { + type: "topology"; + /** Optional exact node id, name or coordinate to orient around. */ + query?: string; + /** Optional relation families to retain. Empty or absent means all. */ + relations?: RepositoryContextRelationKind[]; + /** Maximum returned nodes. @default 100; maximum 500 */ + limit?: number; + + /** Maximum file joins returned after generation fencing. @default 50; maximum 500 */ + joinLimit?: number; + } + + /** Compatibility proof for file-level joins into the language graph. */ + export interface IJoin { + /** Whether file joins were admitted for this result. */ + state: "compatible" | "unavailable"; + + /** Repository-context input generation inspected by this result. */ + topologyInputGeneration: string; + + /** Stable code input generation fenced around the topology load. */ + codeInputGeneration?: string; + + /** Why joins are unavailable. */ + reason?: string; + } +} diff --git a/packages/graph/src/structures/ISamchonRepositoryContextDump.ts b/packages/graph/src/structures/ISamchonRepositoryContextDump.ts new file mode 100644 index 00000000..bda5187f --- /dev/null +++ b/packages/graph/src/structures/ISamchonRepositoryContextDump.ts @@ -0,0 +1,115 @@ +import { + RepositoryContextAuthority, + RepositoryContextCoverageState, + RepositoryContextNodeKind, + RepositoryContextRelationKind, +} from "../typings"; + +/** + * A repository-topology snapshot kept beside, never inside, the language graph. + */ +export interface ISamchonRepositoryContextDump { + /** Absolute repository root whose owning tools were queried. */ + project: string; + + /** Version of this normalized repository-context body. */ + schemaVersion: 1; + + /** Complete source/configuration generation fenced around this snapshot. */ + inputGeneration: string; + + /** Monotonic resident publication identity. */ + generation: ISamchonRepositoryContextDump.IGeneration; + + /** One claim per contributing repository-context provider. */ + provenance: ISamchonRepositoryContextDump.IProvenance[]; + + /** Exhaustive family coverage for every published provider target. */ + coverage: ISamchonRepositoryContextDump.ICoverage[]; + + /** Every normalized workspace, project, target, root, task and entrypoint. */ + nodes: ISamchonRepositoryContextDump.INode[]; + + /** Every normalized repository-topology relation and file join. */ + edges: ISamchonRepositoryContextDump.IEdge[]; + + /** Normalized code-file identities that `joins-file` edges may target. */ + files: string[]; + + /** Exact declared/model inputs consumed by this snapshot. */ + sources: ISamchonRepositoryContextDump.ISource[]; + + /** Non-fatal unavailable-model or partial-coverage explanations. */ + warnings: string[]; +} + +export namespace ISamchonRepositoryContextDump { + export interface IGeneration { + sequence: number; + token: string; + shards: IShard[]; + contentDigest: string; + } + + export interface IShard { + key: string; + digest: string; + } + + export interface IProvenance { + provider: string; + ecosystem: string; + authority: RepositoryContextAuthority; + tool: string; + toolVersion: string; + schemaVersion: number; + protocolVersion: number; + universe: string; + manifest: string; + content: string; + capabilities: string[]; + } + + export interface ICoverage { + provider: string; + ecosystem: string; + target: string; + family: RepositoryContextRelationKind; + state: RepositoryContextCoverageState; + } + + export interface INode { + id: string; + kind: RepositoryContextNodeKind; + name: string; + ecosystem: string; + coordinate: string; + configuration: string; + external: boolean; + /** Exact normalized source root whose current code files may be joined. */ + root?: string; + /** Exact normalized code file this node may join. */ + file?: string; + evidence?: IEvidence; + } + + export interface IEdge { + kind: RepositoryContextRelationKind; + from: string; + to: string; + evidence?: IEvidence; + } + + export interface IEvidence { + file: string; + startLine?: number; + startColumn?: number; + endLine?: number; + endColumn?: number; + } + + export interface ISource { + file: string; + digest: string; + } +} diff --git a/packages/graph/src/structures/index.ts b/packages/graph/src/structures/index.ts index 0ba92693..7fecf4d2 100644 --- a/packages/graph/src/structures/index.ts +++ b/packages/graph/src/structures/index.ts @@ -19,9 +19,11 @@ export * from "./ISamchonGraphOverview"; export * from "./ISamchonGraphSpan"; export * from "./ISamchonGraphTour"; export * from "./ISamchonGraphTrace"; +export * from "./ISamchonGraphTopology"; export * from "./ISamchonGraphApplication"; export * from "./ISamchonGraphCoverage"; export * from "./ISamchonGraphCoverageSummary"; export * from "./ISamchonGraphUnresolved"; export * from "./ISamchonGraphUnresolvedSummary"; export * from "./SamchonGraphNodeModifier"; +export * from "./ISamchonRepositoryContextDump"; diff --git a/packages/graph/src/typings/RepositoryContextAuthority.ts b/packages/graph/src/typings/RepositoryContextAuthority.ts new file mode 100644 index 00000000..6fa9177e --- /dev/null +++ b/packages/graph/src/typings/RepositoryContextAuthority.ts @@ -0,0 +1,5 @@ +/** Evidence level for repository topology facts. */ +export type RepositoryContextAuthority = + | "tool-resolved" + | "declared" + | "inferred"; diff --git a/packages/graph/src/typings/RepositoryContextCoverageState.ts b/packages/graph/src/typings/RepositoryContextCoverageState.ts new file mode 100644 index 00000000..acb1a126 --- /dev/null +++ b/packages/graph/src/typings/RepositoryContextCoverageState.ts @@ -0,0 +1,5 @@ +/** Whether one repository-topology family is complete in a named universe. */ +export type RepositoryContextCoverageState = + | "complete" + | "partial" + | "unsupported"; diff --git a/packages/graph/src/typings/RepositoryContextNodeKind.ts b/packages/graph/src/typings/RepositoryContextNodeKind.ts new file mode 100644 index 00000000..f32ad094 --- /dev/null +++ b/packages/graph/src/typings/RepositoryContextNodeKind.ts @@ -0,0 +1,11 @@ +/** Version-one repository-context ontology. */ +export type RepositoryContextNodeKind = + | "workspace" + | "project" + | "package" + | "source-set" + | "source-root" + | "generated-root" + | "build-target" + | "task" + | "entrypoint"; diff --git a/packages/graph/src/typings/RepositoryContextRelationKind.ts b/packages/graph/src/typings/RepositoryContextRelationKind.ts new file mode 100644 index 00000000..1d217335 --- /dev/null +++ b/packages/graph/src/typings/RepositoryContextRelationKind.ts @@ -0,0 +1,10 @@ +/** Version-one repository-context relationship vocabulary. */ +export type RepositoryContextRelationKind = + | "contains" + | "depends-on" + | "source-of" + | "test-of" + | "produces" + | "invokes" + | "entrypoint-of" + | "joins-file"; diff --git a/packages/graph/src/typings/index.ts b/packages/graph/src/typings/index.ts index 88b42fba..c78de113 100644 --- a/packages/graph/src/typings/index.ts +++ b/packages/graph/src/typings/index.ts @@ -3,3 +3,7 @@ export * from "./GRAPH_EDGE_KINDS"; export * from "./GraphLanguage"; export * from "./GraphNodeKind"; export * from "./GraphProviderAuthority"; +export * from "./RepositoryContextAuthority"; +export * from "./RepositoryContextCoverageState"; +export * from "./RepositoryContextNodeKind"; +export * from "./RepositoryContextRelationKind"; diff --git a/sidecars/gradle/RepositoryContext.java b/sidecars/gradle/RepositoryContext.java new file mode 100644 index 00000000..aaad3653 --- /dev/null +++ b/sidecars/gradle/RepositoryContext.java @@ -0,0 +1,95 @@ +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Comparator; +import java.util.List; +import org.gradle.tooling.GradleConnector; +import org.gradle.tooling.ProjectConnection; +import org.gradle.tooling.model.build.BuildEnvironment; +import org.gradle.tooling.model.GradleProject; +import org.gradle.tooling.model.GradleTask; +import org.gradle.tooling.model.idea.IdeaContentRoot; +import org.gradle.tooling.model.idea.IdeaDependency; +import org.gradle.tooling.model.idea.IdeaModule; +import org.gradle.tooling.model.idea.IdeaModuleDependency; +import org.gradle.tooling.model.idea.IdeaProject; +import org.gradle.tooling.model.idea.IdeaSourceDirectory; + +/** + * Read-only Gradle Tooling API exporter used by the repository-context plane. + * + * The caller opts in because loading a Gradle model evaluates project build + * configuration. This helper never runs a task and reuses the wrapper-aware + * Tooling API connection/daemon selected by Gradle itself. + */ +public final class RepositoryContext { + public static void main(String[] args) { + if (args.length != 1) { + throw new IllegalArgumentException("usage: RepositoryContext.java "); + } + File root = new File(args[0]).getAbsoluteFile(); + GradleConnector connector = + GradleConnector.newConnector().forProjectDirectory(root); + try (ProjectConnection connection = connector.connect()) { + BuildEnvironment environment = connection.getModel(BuildEnvironment.class); + line("V", environment.getGradle().getGradleVersion()); + IdeaProject idea = connection.getModel(IdeaProject.class); + List modules = new ArrayList<>(idea.getModules()); + modules.sort(Comparator.comparing(module -> module.getGradleProject().getPath())); + for (IdeaModule module : modules) { + GradleProject project = module.getGradleProject(); + line("M", project.getPath(), module.getName(), project.getProjectDirectory().getPath()); + List tasks = new ArrayList<>(project.getTasks()); + tasks.sort(Comparator.comparing(GradleTask::getPath)); + for (GradleTask task : tasks) { + line("T", project.getPath(), task.getPath(), task.getName()); + } + for (IdeaContentRoot content : module.getContentRoots()) { + source(project.getPath(), "source", content.getSourceDirectories()); + source(project.getPath(), "test", content.getTestDirectories()); + source(project.getPath(), "resource", content.getResourceDirectories()); + source(project.getPath(), "test-resource", content.getTestResourceDirectories()); + } + List dependencies = + new ArrayList<>(module.getDependencies()); + for (IdeaDependency dependency : dependencies) { + if (dependency instanceof IdeaModuleDependency) { + IdeaModuleDependency projectDependency = (IdeaModuleDependency) dependency; + line("D", project.getPath(), projectDependency.getTargetModuleName()); + } + } + } + } + } + + private static void source( + String project, + String kind, + Iterable directories) { + List sorted = new ArrayList<>(); + for (IdeaSourceDirectory directory : directories) { + sorted.add(directory); + } + sorted.sort(Comparator.comparing(directory -> directory.getDirectory().getPath())); + for (IdeaSourceDirectory directory : sorted) { + line( + "S", + project, + kind, + directory.getDirectory().getPath(), + Boolean.toString(directory.isGenerated())); + } + } + + private static void line(String kind, String... fields) { + StringBuilder out = new StringBuilder(kind); + for (String field : fields) { + out.append('\t').append( + Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(field.getBytes(StandardCharsets.UTF_8))); + } + System.out.println(out); + } +} diff --git a/tests/test-graph/src/features/test_application_exercises_every_request_branch.ts b/tests/test-graph/src/features/test_application_exercises_every_request_branch.ts index 0cff94f7..ab5197bf 100644 --- a/tests/test-graph/src/features/test_application_exercises_every_request_branch.ts +++ b/tests/test-graph/src/features/test_application_exercises_every_request_branch.ts @@ -12,6 +12,7 @@ export const test_application_exercises_every_request_branch = async () => { { type: "details", handles: ["Root.Service.run"], neighbors: true }, { type: "overview", aspect: "all" }, { type: "tour", reinterpretations: ["Root.Service.run"] }, + { type: "topology" }, { type: "escape", reason: "outside graph", nextStep: "answer without graph" }, ] as const; diff --git a/tests/test-graph/src/features/test_mcp_resident_close_handler_settles_once.ts b/tests/test-graph/src/features/test_mcp_resident_close_handler_settles_once.ts index dc8cffae..61723ad9 100644 --- a/tests/test-graph/src/features/test_mcp_resident_close_handler_settles_once.ts +++ b/tests/test-graph/src/features/test_mcp_resident_close_handler_settles_once.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; import { GraphPaths } from "../internal/GraphPaths"; +import { createCompositeResidentClose } from "../../../../packages/graph/src/mcp/createCompositeResidentClose"; export const test_mcp_resident_close_handler_settles_once = async () => { const module = (await import( @@ -47,4 +48,44 @@ export const test_mcp_resident_close_handler_settles_once = async () => { reports, [failure], ); + + const calls: string[] = []; + await createCompositeResidentClose([ + undefined, + { close: async () => void calls.push("code") }, + { close: async () => void calls.push("topology") }, + ]).close(); + TestValidator.equals( + "the composite closes every opened resident plane in order", + calls, + ["code", "topology"], + ); + + const firstFailure = new Error("code close failed"); + let topologyClosed = false; + await TestValidator.error( + "the composite retains the first failure while closing later planes", + () => + createCompositeResidentClose([ + { close: async () => Promise.reject(firstFailure) }, + { + close: async () => { + topologyClosed = true; + throw "topology close failed"; + }, + }, + ]).close(), + ); + TestValidator.equals( + "a first close failure does not skip the topology plane", + topologyClosed, + true, + ); + await TestValidator.error( + "a non-Error close failure is normalized", + () => + createCompositeResidentClose([ + { close: async () => Promise.reject("string close failure") }, + ]).close(), + ); }; diff --git a/tests/test-graph/src/features/test_mcp_topology_fences_file_joins_by_code_generation.ts b/tests/test-graph/src/features/test_mcp_topology_fences_file_joins_by_code_generation.ts new file mode 100644 index 00000000..11500fc7 --- /dev/null +++ b/tests/test-graph/src/features/test_mcp_topology_fences_file_joins_by_code_generation.ts @@ -0,0 +1,267 @@ +import { TestValidator } from "@nestia/e2e"; +import { + ISamchonRepositoryContextDump, + RepositoryContextProtocol, + SamchonGraphApplication, + SamchonGraphMemory, + SamchonRepositoryContextMemory, + repositoryContextFacts, +} from "@samchon/graph"; +import fs from "node:fs"; + +import { GraphFixtures } from "../internal/GraphFixtures"; + +const { repositoryContextCoverage, repositoryContextId } = + repositoryContextFacts; + +export const test_mcp_topology_fences_file_joins_by_code_generation = + async () => { + const fixture = GraphFixtures.createContractFixture(); + try { + const input = "a".repeat(64); + const graph = SamchonGraphMemory.from({ + ...fixture.dump, + generation: { input }, + }); + const topology = new SamchonRepositoryContextMemory( + topologyDump(fixture.dump.project), + ); + const application = new SamchonGraphApplication(graph, () => topology); + const compatible = await application.inspect_code_graph({ + question: "show repository packages and their source files", + draft: { reason: "repository orientation", type: "topology" }, + review: "topology is the typed repository plane", + request: { + type: "topology", + query: "source", + relations: ["joins-file"], + limit: 10, + }, + }); + TestValidator.equals( + "a stable code generation admits only joins to indexed code files", + [ + compatible.result.type, + compatible.result.type === "topology" + ? compatible.result.join.state + : undefined, + compatible.result.type === "topology" + ? compatible.result.edges.map((edge) => edge.to) + : [], + compatible.result.type === "topology" + ? compatible.result.coverage.map((row) => row.family) + : [], + ], + [ + "topology", + "compatible", + ["src/contract.ts"], + ["joins-file"], + ], + ); + + const bounded = await application.inspect_code_graph({ + question: "show one repository file join", + draft: { reason: "repository orientation", type: "topology" }, + review: "topology is the typed repository plane", + request: { + type: "topology", + relations: ["joins-file"], + joinLimit: 1, + }, + }); + TestValidator.equals( + "incompatible file identities are removed before the join bound is evaluated", + [ + bounded.result.type === "topology" + ? bounded.result.edges.filter( + (edge) => edge.kind === "joins-file", + ).length + : -1, + bounded.result.type === "topology" + ? bounded.result.truncated + : false, + ], + [1, false], + ); + + const legacy = SamchonGraphMemory.from({ + ...fixture.dump, + generation: undefined, + }); + const unavailable = await new SamchonGraphApplication( + legacy, + () => topology, + ).inspect_code_graph({ + question: "show repository topology", + draft: { reason: "repository orientation", type: "topology" }, + review: "topology is correct", + request: { type: "topology", limit: 1 }, + }); + TestValidator.equals( + "a legacy code dump cannot receive topology file joins", + [ + unavailable.result.type === "topology" + ? unavailable.result.join.state + : undefined, + unavailable.result.type === "topology" + ? unavailable.result.edges.some( + (edge) => edge.kind === "joins-file", + ) + : true, + unavailable.result.type === "topology" + ? unavailable.result.truncated + : false, + ], + ["unavailable", false, true], + ); + + let loads = 0; + const moved = SamchonGraphMemory.from({ + ...fixture.dump, + generation: { input: "moved".padEnd(64, "0") }, + }); + const stale = await new SamchonGraphApplication( + () => (loads++ === 0 ? graph : moved), + () => topology, + ).inspect_code_graph({ + question: "show repository topology", + draft: { reason: "repository orientation", type: "topology" }, + review: "topology is correct", + request: { type: "topology" }, + }); + TestValidator.equals( + "a code generation that moves across the topology load refuses stale file joins", + [ + stale.result.type === "topology" + ? stale.result.join.state + : undefined, + stale.result.type === "topology" + ? stale.result.edges.some((edge) => edge.kind === "joins-file") + : true, + ], + ["unavailable", false], + ); + + const emptyTopology = new SamchonRepositoryContextMemory({ + ...topology.dump, + provenance: [], + coverage: topology.dump.coverage.map((row) => ({ + ...row, + target: "unavailable", + state: "unsupported", + })), + nodes: [], + edges: [], + files: [], + }); + const providerUnavailable = await new SamchonGraphApplication( + graph, + () => emptyTopology, + ).inspect_code_graph({ + question: "show repository topology", + draft: { reason: "repository orientation", type: "topology" }, + review: "topology is correct", + request: { type: "topology" }, + }); + TestValidator.equals( + "an unavailable provider generation cannot claim join compatibility", + providerUnavailable.result.type === "topology" + ? providerUnavailable.result.join + : undefined, + { + state: "unavailable", + topologyInputGeneration: emptyTopology.dump.inputGeneration, + codeInputGeneration: input, + reason: + "No repository-context provider produced a compatible current generation.", + }, + ); + + await TestValidator.error( + "the topology branch fails explicitly without a repository source", + () => + new SamchonGraphApplication(graph).inspect_code_graph({ + question: "show repository topology", + draft: { reason: "repository orientation", type: "topology" }, + review: "topology is correct", + request: { type: "topology" }, + }), + ); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } + }; + +function topologyDump(project: string): ISamchonRepositoryContextDump { + const workspace = repositoryContextId("fixture", "workspace", "."); + const source = repositoryContextId("fixture", "source-root", "src"); + const nodes: ISamchonRepositoryContextDump.INode[] = [ + { + id: workspace, + kind: "workspace", + name: "fixture", + ecosystem: "fixture", + coordinate: ".", + configuration: "default", + external: false, + }, + { + id: source, + kind: "source-root", + name: "source", + ecosystem: "fixture", + coordinate: "src", + configuration: "default", + external: false, + }, + ]; + const edges: ISamchonRepositoryContextDump.IEdge[] = [ + { kind: "contains", from: workspace, to: source }, + { kind: "joins-file", from: source, to: "src/contract.ts" }, + { kind: "joins-file", from: source, to: "src/not-indexed.ts" }, + ]; + const coverage = repositoryContextCoverage( + "fixture-context", + "fixture", + "workspace", + ["contains", "joins-file"], + ); + const contentDigest = RepositoryContextProtocol.digest({ + nodes, + edges, + coverage, + }); + return { + project, + schemaVersion: 1, + inputGeneration: "b".repeat(64), + generation: { + sequence: 1, + token: "c".repeat(64), + shards: [{ key: "fixture", digest: "d".repeat(64) }], + contentDigest, + }, + provenance: [ + { + provider: "fixture-context", + ecosystem: "fixture", + authority: "declared", + tool: "fixture", + toolVersion: "1", + schemaVersion: 1, + protocolVersion: 1, + universe: "e".repeat(64), + manifest: "f".repeat(64), + content: contentDigest, + capabilities: ["fixture"], + }, + ], + coverage, + nodes, + edges, + files: ["src/contract.ts", "src/not-indexed.ts"], + sources: [{ file: "fixture.json", digest: "a".repeat(64) }], + warnings: [], + }; +} diff --git a/tests/test-graph/src/features/test_repository_context_adapters_preserve_authoritative_models.ts b/tests/test-graph/src/features/test_repository_context_adapters_preserve_authoritative_models.ts new file mode 100644 index 00000000..54c75119 --- /dev/null +++ b/tests/test-graph/src/features/test_repository_context_adapters_preserve_authoritative_models.ts @@ -0,0 +1,1318 @@ +import { TestValidator } from "@nestia/e2e"; +import { + RepositoryContextProtocol, + SamchonRepositoryContextMemory, + cargoRepositoryContextProvider, + cmakeRepositoryContextProvider, + gradleRepositoryContextProvider, + pnpmRepositoryContextProvider, +} from "@samchon/graph"; +import fs from "node:fs"; +import path from "node:path"; + +import { GraphPaths } from "../internal/GraphPaths"; +import { parseGradleRepositoryContextModel } from "../../../../packages/graph/src/repository/parseGradleRepositoryContextModel"; + +export const test_repository_context_adapters_preserve_authoritative_models = + async () => { + const root = GraphPaths.createTempDirectory( + "samchon-graph-repository-context-adapters-", + ); + try { + const pnpm = pnpmFixture(root); + const cargo = cargoFixture(root); + const gradle = gradleFixture(root); + const cmake = cmakeFixture(root); + + TestValidator.equals( + "repository-context adapters detect only their owning manifests", + [ + pnpmRepositoryContextProvider.detect(root), + cargoRepositoryContextProvider.detect(path.join(root, "cargo")), + gradleRepositoryContextProvider.detect(root), + cmakeRepositoryContextProvider.detect(path.join(root, "cmake")), + pnpmRepositoryContextProvider.detect(path.join(root, "absent")), + cargoRepositoryContextProvider.detect(path.join(root, "absent")), + gradleRepositoryContextProvider.detect(path.join(root, "absent")), + cmakeRepositoryContextProvider.detect(path.join(root, "absent")), + ], + [true, true, true, true, false, false, false, false], + ); + for (const [provider, providerRoot] of [ + [pnpmRepositoryContextProvider, root], + [cargoRepositoryContextProvider, path.join(root, "cargo")], + [gradleRepositoryContextProvider, root], + [cmakeRepositoryContextProvider, path.join(root, "cmake")], + ] as const) { + const session = provider.open({ + root: providerRoot, + env: process.env, + }); + TestValidator.equals( + `${provider.name} opens at generation zero`, + session.generation, + 0, + ); + await session.close(); + await TestValidator.error( + `${provider.name} refuses refresh after close`, + () => session.refresh(), + ); + } + + TestValidator.equals( + "pnpm preserves members, local dependencies, roots, tasks and entrypoints", + summarize(pnpm), + { + ecosystem: "pnpm", + nodeKinds: [ + "entrypoint", + "entrypoint", + "generated-root", + "package", + "package", + "source-root", + "source-root", + "task", + "workspace", + ], + edgeKinds: [ + "contains", + "contains", + "contains", + "contains", + "contains", + "contains", + "contains", + "contains", + "depends-on", + "entrypoint-of", + "entrypoint-of", + "joins-file", + "joins-file", + "source-of", + "source-of", + "source-of", + ], + files: ["apps/app/src/index.ts", "packages/lib/src/index.ts"], + coverage: 8, + }, + ); + TestValidator.equals( + "Cargo preserves packages, targets, dependencies, tests and source joins", + summarize(cargo), + { + ecosystem: "cargo", + nodeKinds: [ + "build-target", + "build-target", + "entrypoint", + "entrypoint", + "package", + "package", + "source-set", + "source-set", + "workspace", + ], + edgeKinds: [ + "contains", + "contains", + "contains", + "contains", + "contains", + "contains", + "contains", + "contains", + "depends-on", + "entrypoint-of", + "entrypoint-of", + "joins-file", + "joins-file", + "joins-file", + "joins-file", + "source-of", + "source-of", + "test-of", + ], + files: ["cargo/app/src/main.rs", "cargo/lib/src/lib.rs"], + coverage: 8, + }, + ); + TestValidator.equals( + "Gradle Tooling API preserves projects, project dependencies, tasks and roots", + summarize(gradle), + { + ecosystem: "gradle", + nodeKinds: [ + "build-target", + "build-target", + "project", + "project", + "source-root", + "source-root", + "task", + "task", + "workspace", + ], + edgeKinds: [ + "contains", + "contains", + "contains", + "contains", + "contains", + "contains", + "contains", + "contains", + "depends-on", + "source-of", + "source-of", + "test-of", + ], + files: [], + coverage: 8, + }, + ); + TestValidator.equals( + "Gradle source roots join against the current code generation without rescanning the Tooling API model", + joinedFiles(gradle, [ + "gradle/app/src/main/App.java", + "gradle/lib/src/test/LibTest.java", + ]), + [ + "gradle/app/src/main/App.java", + "gradle/lib/src/test/LibTest.java", + ], + ); + TestValidator.equals( + "code-file create, rename and delete recompute joins without changing the topology model", + [ + joinedFiles(gradle, ["gradle/app/src/main/Created.java"]), + joinedFiles(gradle, ["gradle/app/src/main/Renamed.java"]), + joinedFiles(gradle, []), + ], + [ + ["gradle/app/src/main/Created.java"], + ["gradle/app/src/main/Renamed.java"], + [], + ], + ); + TestValidator.equals( + "CMake File API preserves projects, targets, sources, artifacts and entrypoints", + summarize(cmake), + { + ecosystem: "cmake", + nodeKinds: [ + "build-target", + "entrypoint", + "generated-root", + "generated-root", + "project", + "source-root", + "workspace", + ], + edgeKinds: [ + "contains", + "contains", + "contains", + "contains", + "contains", + "contains", + "entrypoint-of", + "joins-file", + "joins-file", + "produces", + "source-of", + "source-of", + ], + files: [ + "cmake/build/generated/gen.c", + "cmake/src/main.c", + ], + coverage: 8, + }, + ); + TestValidator.equals( + "all first-slice adapters retain exhaustive topology coverage", + [pnpm, cargo, gradle, cmake].map( + (collection) => collection.shards[0]!.coverage.length, + ), + [8, 8, 8, 8], + ); + TestValidator.predicate( + "code contents are joins, not topology model inputs", + ![...pnpm.shards[0]!.sources, ...cargo.shards[0]!.sources].some( + (source) => + source.file.endsWith(".ts") || source.file.endsWith(".rs"), + ), + ); + TestValidator.equals( + "Cargo feature selections participate in repository identity", + cargo.shards[0]!.nodes + .filter((node) => node.name === "app") + .map((node) => node.configuration), + ["features=cli", "features=cli", "features=cli"], + ); + TestValidator.equals( + "exact manifest files synthesize joins against the current code generation", + joinedFiles(pnpm, [ + "apps/app/src/index.ts", + "packages/lib/src/index.ts", + ]), + [ + "apps/app/src/index.ts", + "apps/app/src/index.ts", + "packages/lib/src/index.ts", + "packages/lib/src/index.ts", + ], + ); + + const rootJoin = structuredClone(gradle); + rootJoin.shards[0]!.nodes[0]!.root = "."; + TestValidator.equals( + "a repository-root fact joins every current code file", + joinedFiles(rootJoin, ["at-root.ts", "nested/file.ts"]), + ["at-root.ts", "nested/file.ts"], + ); + TestValidator.equals( + "a query retains an inbound declared dependency edge", + topologyMemory(pnpm) + .inspect( + { + type: "topology", + query: "@fixture/lib", + relations: ["depends-on"], + }, + { + state: "unavailable", + topologyInputGeneration: "input", + codeInputGeneration: "code", + }, + ) + .edges.map((edge) => edge.kind), + ["depends-on"], + ); + + const ambiguousGradle = gradleAmbiguousDependencyFixture(root); + TestValidator.equals( + "an ambiguous Gradle module name degrades dependency coverage instead of inventing an edge", + [ + ambiguousGradle.shards[0]!.edges.some( + (edge) => edge.kind === "depends-on", + ), + ambiguousGradle.shards[0]!.coverage.find( + (row) => row.family === "depends-on", + )?.state, + ambiguousGradle.warnings.some((warning) => + warning.includes("ambiguous"), + ), + ], + [false, "partial", true], + ); + TestValidator.predicate( + "Gradle preserves generated roots and resolves one unambiguous module name", + gradleEdgeFixture(root).shards[0]!.edges.some( + (edge) => edge.kind === "depends-on", + ), + ); + TestValidator.predicate( + "pnpm preserves fallback identities, nested exports and non-path dependency rows", + pnpmEdgeFixture(root).shards[0]!.nodes.some( + (node) => node.kind === "entrypoint" && node.name.startsWith("exports"), + ), + ); + TestValidator.equals( + "pnpm falls back to package.json evidence when no workspace manifest is present", + pnpmNoWorkspaceFixture(root).shards[0]!.nodes[0]!.evidence?.file, + "package.json", + ); + TestValidator.predicate( + "Cargo distinguishes default configurations, external packages and unresolved metadata rows", + cargoEdgeFixtures(root).every( + (collection) => collection.shards[0]!.nodes.length > 1, + ), + ); + + TestValidator.error( + "Gradle Tooling API evaluation requires explicit opt-in", + () => + gradleRepositoryContextProvider.collect( + { root, env: process.env }, + () => ({ version: "", modules: [] }), + ), + ); + TestValidator.error( + "Gradle reports a missing Tooling API classpath without downloading it", + () => + gradleRepositoryContextProvider.collect({ + root, + env: { + ...process.env, + GRADLE_HOME: undefined, + SAMCHON_GRAPH_GRADLE_TOOLING_CLASSPATH: undefined, + SAMCHON_GRAPH_ALLOW_GRADLE_MODEL: "1", + }, + }), + ); + for (const env of [ + { + ...process.env, + SAMCHON_GRAPH_ALLOW_GRADLE_MODEL: "1", + SAMCHON_GRAPH_GRADLE_TOOLING_CLASSPATH: path.join(root, "missing.jar"), + JAVA_HOME: path.join(root, "missing-java"), + }, + { + ...process.env, + SAMCHON_GRAPH_ALLOW_GRADLE_MODEL: "1", + SAMCHON_GRAPH_GRADLE_TOOLING_CLASSPATH: undefined, + GRADLE_HOME: path.join(root, "missing-gradle"), + JAVA_HOME: path.join(root, "missing-java"), + }, + { + ...process.env, + SAMCHON_GRAPH_ALLOW_GRADLE_MODEL: "1", + SAMCHON_GRAPH_GRADLE_TOOLING_CLASSPATH: path.join(root, "missing.jar"), + JAVA_HOME: undefined, + }, + ]) { + TestValidator.error( + "Gradle surfaces a Tooling API process failure without a fallback", + () => gradleRepositoryContextProvider.collect({ root, env }), + ); + } + exerciseGradleModelParser(root); + + const toolDirectory = path.join(root, "tools"); + installFakeRepositoryTool(toolDirectory, "pnpm"); + installFakeRepositoryTool(toolDirectory, "cargo"); + const toolEnv = { + ...process.env, + PATH: `${toolDirectory}${path.delimiter}${process.env.PATH ?? ""}`, + }; + TestValidator.predicate( + "the pnpm process boundary accepts a valid resolved workspace model", + pnpmRepositoryContextProvider.collect({ + root, + env: { + ...toolEnv, + FIXTURE_TOOL_MODEL: JSON.stringify(pnpmModel(root)), + }, + }).shards[0]!.nodes.length > 1, + ); + TestValidator.predicate( + "the Cargo process boundary accepts a valid offline metadata model", + cargoRepositoryContextProvider.collect({ + root: path.join(root, "cargo"), + env: { + ...toolEnv, + FIXTURE_TOOL_MODEL: JSON.stringify(cargoModel(root)), + }, + }).shards[0]!.nodes.length > 1, + ); + for (const provider of [ + pnpmRepositoryContextProvider, + cargoRepositoryContextProvider, + ] as const) { + const providerRoot = + provider === pnpmRepositoryContextProvider + ? root + : path.join(root, "cargo"); + TestValidator.error(`${provider.name} rejects a failed tool`, () => + provider.collect({ + root: providerRoot, + env: { + ...toolEnv, + FIXTURE_TOOL_MODE: "failed", + }, + }), + ); + TestValidator.error(`${provider.name} rejects malformed tool JSON`, () => + provider.collect({ + root: providerRoot, + env: { + ...toolEnv, + FIXTURE_TOOL_MODE: "malformed", + }, + }), + ); + } + for (const [provider, invalidModels] of [ + [ + pnpmRepositoryContextProvider, + [JSON.stringify([{ path: "" }])], + ], + [ + cargoRepositoryContextProvider, + [ + JSON.stringify({ + packages: [], + workspace_members: "invalid", + workspace_root: "", + }), + JSON.stringify({ + packages: [], + workspace_members: [], + workspace_root: 1, + }), + ], + ], + ] as const) { + const providerRoot = + provider === pnpmRepositoryContextProvider + ? root + : path.join(root, "cargo"); + for (const model of invalidModels) { + TestValidator.error(`${provider.name} validates every model field`, () => + provider.collect({ + root: providerRoot, + env: { + ...toolEnv, + FIXTURE_TOOL_MODEL: model, + }, + }), + ); + } + } + for (const [provider, providerRoot, model] of [ + [pnpmRepositoryContextProvider, root, pnpmModel(root)], + [ + cargoRepositoryContextProvider, + path.join(root, "cargo"), + cargoModel(root), + ], + ] as const) { + TestValidator.equals( + `${provider.name} reports an unavailable version probe honestly`, + provider.collect({ + root: providerRoot, + env: { + ...toolEnv, + FIXTURE_TOOL_MODE: "version-failed", + FIXTURE_TOOL_MODEL: JSON.stringify(model), + }, + }).toolVersion, + "", + ); + } + + exerciseCmakeRefusals(root); + + const aborted = new AbortController(); + aborted.abort(); + for (const operation of [ + () => + pnpmRepositoryContextProvider.collect( + { root, env: process.env, signal: aborted.signal }, + () => [], + ), + () => + cargoRepositoryContextProvider.collect( + { root, env: process.env, signal: aborted.signal }, + () => cargoModel(root), + ), + () => + gradleRepositoryContextProvider.collect( + { + root, + env: { + ...process.env, + SAMCHON_GRAPH_ALLOW_GRADLE_MODEL: "1", + }, + signal: aborted.signal, + }, + () => ({ version: "1", modules: [] }), + ), + () => + cmakeRepositoryContextProvider.collect({ + root, + env: process.env, + signal: aborted.signal, + }), + ]) { + TestValidator.error("an adapter refuses a cancelled collection", operation); + } + + const cmakeList = path.join(root, "cmake", "CMakeLists.txt"); + const future = new Date(Date.now() + 2_000); + fs.utimesSync(cmakeList, future, future); + TestValidator.error( + "a stale CMake File API model is refused rather than joined to changed configuration", + () => + cmakeRepositoryContextProvider.collect({ + root, + env: { + ...process.env, + SAMCHON_GRAPH_CMAKE_REPLY: path.join( + root, + "cmake", + "build", + ".cmake", + "api", + "v1", + "reply", + ), + }, + }), + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }; + +function pnpmFixture(root: string) { + const app = path.join(root, "apps", "app"); + const library = path.join(root, "packages", "lib"); + write(path.join(root, "pnpm-workspace.yaml"), "packages:\n - apps/*\n - packages/*\n"); + write(path.join(root, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n"); + writeJson(path.join(root, "package.json"), { + name: "workspace", + private: true, + }); + writeJson(path.join(app, "package.json"), { + name: "@fixture/app", + files: ["src", "dist"], + main: "src/index.ts", + scripts: { build: "fixture" }, + }); + write(path.join(app, "src", "index.ts"), "export const app = 1;\n"); + writeJson(path.join(library, "package.json"), { + name: "@fixture/lib", + files: ["src"], + exports: "./src/index.ts", + }); + write(path.join(library, "src", "index.ts"), "export const lib = 1;\n"); + return pnpmRepositoryContextProvider.collect( + { root, env: process.env }, + () => pnpmModel(root), + ); +} + +function pnpmModel(root: string) { + return [ + { + name: "@fixture/app", + path: path.join(root, "apps", "app"), + dependencies: { + "@fixture/lib": { path: path.join(root, "packages", "lib") }, + }, + }, + { name: "@fixture/lib", path: path.join(root, "packages", "lib") }, + ]; +} + +function pnpmEdgeFixture(root: string) { + const first = path.join(root, "edge", "first"); + const second = path.join(root, "edge", "second"); + writeJson(path.join(first, "package.json"), { + files: ["", "*", "!private", path.resolve(first, "absolute"), "src"], + typings: "types.d.ts", + bin: "cli.js", + exports: { + ".": { + import: "esm.js", + ignored: null, + }, + "./feature": "feature.js", + }, + }); + writeJson(path.join(second, "package.json"), { + bin: { second: "second.js" }, + }); + return pnpmRepositoryContextProvider.collect( + { root, env: process.env }, + () => [ + { + path: first, + dependencies: { missingPath: {} }, + devDependencies: { absentWorkspace: { path: path.join(root, "absent") } }, + }, + { name: "fallback-name", path: second }, + ], + ); +} + +function pnpmNoWorkspaceFixture(root: string) { + const workspace = path.join(root, "pnpm-no-workspace"); + writeJson(path.join(workspace, "package.json"), { + name: "no-workspace-manifest", + }); + return pnpmRepositoryContextProvider.collect( + { root: workspace, env: process.env }, + () => [{ name: "no-workspace-manifest", path: workspace }], + ); +} + +function cargoFixture(root: string) { + const workspace = path.join(root, "cargo"); + const app = path.join(workspace, "app"); + const library = path.join(workspace, "lib"); + write(path.join(workspace, "Cargo.toml"), "[workspace]\nmembers=[]\n"); + write(path.join(workspace, "Cargo.lock"), ""); + write(path.join(app, "Cargo.toml"), "[package]\nname='app'\nversion='1.0.0'\n"); + write(path.join(app, "src", "main.rs"), "fn main() {}\n"); + write(path.join(library, "Cargo.toml"), "[package]\nname='lib'\nversion='1.0.0'\n"); + write(path.join(library, "src", "lib.rs"), "#[test] fn works() {}\n"); + return cargoRepositoryContextProvider.collect( + { root, env: process.env }, + () => cargoModel(root), + ); +} + +function cargoModel(root: string) { + const workspace = path.join(root, "cargo"); + const app = path.join(workspace, "app"); + const library = path.join(workspace, "lib"); + return { + workspace_root: workspace, + workspace_members: ["app 1", "lib 1"], + packages: [ + { + id: "app 1", + name: "app", + version: "1.0.0", + manifest_path: path.join(app, "Cargo.toml"), + targets: [ + { + name: "app", + kind: ["bin"], + crate_types: ["bin"], + src_path: path.join(app, "src", "main.rs"), + }, + ], + }, + { + id: "lib 1", + name: "lib", + version: "1.0.0", + manifest_path: path.join(library, "Cargo.toml"), + targets: [ + { + name: "lib-test", + kind: ["test"], + crate_types: ["bin"], + src_path: path.join(library, "src", "lib.rs"), + }, + ], + }, + ], + resolve: { + nodes: [ + { id: "app 1", dependencies: ["lib 1"], features: ["cli"] }, + { id: "lib 1", dependencies: [] }, + ], + }, + }; +} + +function cargoEdgeFixtures(root: string) { + const workspace = path.join(root, "cargo"); + const external = path.join(root, "cargo-external"); + write( + path.join(external, "Cargo.toml"), + "[package]\nname='external'\nversion='1.0.0'\n", + ); + write(path.join(external, "src", "lib.rs"), "pub fn library() {}\n"); + write(path.join(external, "examples", "demo.rs"), "fn main() {}\n"); + const model = cargoModel(root); + const externalPackage = { + id: "external 1", + name: "external", + version: "1.0.0", + manifest_path: path.join(external, "Cargo.toml"), + targets: [ + { + name: "library", + kind: ["lib"], + crate_types: ["lib"], + src_path: path.join(external, "src", "lib.rs"), + }, + { + name: "demo", + kind: ["example"], + crate_types: ["bin"], + src_path: path.join(external, "examples", "demo.rs"), + }, + ], + }; + return [ + cargoRepositoryContextProvider.collect( + { root: workspace, env: process.env }, + () => ({ + ...model, + workspace_root: workspace, + packages: [externalPackage], + workspace_members: [], + resolve: null, + }), + ), + cargoRepositoryContextProvider.collect( + { root: workspace, env: process.env }, + () => ({ + ...model, + workspace_root: workspace, + packages: [...model.packages, externalPackage], + resolve: { + nodes: [ + ...model.resolve.nodes, + { id: "absent 1", dependencies: ["external 1"] }, + ], + }, + }), + ), + ]; +} + +function gradleAmbiguousDependencyFixture(root: string) { + const directory = path.join(root, "gradle"); + return gradleRepositoryContextProvider.collect( + { + root, + env: { + ...process.env, + SAMCHON_GRAPH_ALLOW_GRADLE_MODEL: "1", + }, + }, + () => ({ + version: "9.1", + modules: [ + { + path: ":app", + name: "app", + directory, + dependencies: ["shared"], + sources: [], + tasks: [], + }, + { + path: ":left", + name: "shared", + directory, + dependencies: [], + sources: [], + tasks: [], + }, + { + path: ":right", + name: "shared", + directory, + dependencies: [], + sources: [], + tasks: [], + }, + ], + }), + ); +} + +function gradleFixture(root: string) { + const workspace = path.join(root, "gradle"); + const app = path.join(workspace, "app"); + const library = path.join(workspace, "lib"); + write(path.join(root, "settings.gradle.kts"), "rootProject.name = \"fixture\"\n"); + write(path.join(app, "build.gradle.kts"), ""); + write(path.join(library, "build.gradle.kts"), ""); + write(path.join(app, "src", "main", "App.java"), "class App {}\n"); + write(path.join(library, "src", "test", "LibTest.java"), "class LibTest {}\n"); + return gradleRepositoryContextProvider.collect( + { + root, + env: { + ...process.env, + SAMCHON_GRAPH_ALLOW_GRADLE_MODEL: "1", + }, + }, + () => ({ + version: "9.1", + modules: [ + { + path: ":app", + name: "app", + directory: app, + dependencies: [":lib"], + sources: [ + { + kind: "source", + directory: path.join(app, "src", "main"), + generated: false, + }, + ], + tasks: [{ path: ":app:build", name: "build" }], + }, + { + path: ":lib", + name: "lib", + directory: library, + dependencies: [], + sources: [ + { + kind: "test", + directory: path.join(library, "src", "test"), + generated: false, + }, + ], + tasks: [{ path: ":lib:test", name: "test" }], + }, + ], + }), + ); +} + +function gradleEdgeFixture(root: string) { + const workspace = path.join(root, "gradle-edge"); + const app = path.join(workspace, "app"); + const library = path.join(workspace, "library"); + write(path.join(workspace, "settings.gradle"), "rootProject.name='edge'\n"); + write(path.join(app, "build.gradle"), ""); + write(path.join(library, "build.gradle"), ""); + return gradleRepositoryContextProvider.collect( + { + root: workspace, + env: { + ...process.env, + SAMCHON_GRAPH_ALLOW_GRADLE_MODEL: "1", + }, + }, + () => ({ + version: "9.1", + modules: [ + { + path: ":app", + name: "app", + directory: app, + dependencies: ["library"], + sources: [ + { + kind: "generated", + directory: path.join(app, "build", "generated"), + generated: true, + }, + ], + tasks: [], + }, + { + path: ":library", + name: "library", + directory: library, + dependencies: [], + sources: [], + tasks: [], + }, + ], + }), + ); +} + +function cmakeFixture(root: string) { + const workspace = path.join(root, "cmake"); + const reply = path.join(workspace, "build", ".cmake", "api", "v1", "reply"); + write(path.join(workspace, "CMakeLists.txt"), "add_executable(app src/main.c)\n"); + write(path.join(workspace, "src", "main.c"), "int main(void) { return 0; }\n"); + writeJson(path.join(reply, "index-1.json"), { + cmake: { version: { string: "4.0.0" } }, + reply: { + "codemodel-v2": { jsonFile: "codemodel.json" }, + "cmakeFiles-v1": { jsonFile: "cmakeFiles.json" }, + }, + }); + writeJson(path.join(reply, "cmakeFiles.json"), { + paths: { source: workspace, build: path.join(workspace, "build") }, + inputs: [{ path: "CMakeLists.txt" }], + }); + writeJson(path.join(reply, "codemodel.json"), { + paths: { source: workspace, build: path.join(workspace, "build") }, + configurations: [ + { + name: "Debug", + projects: [{ name: "fixture", directoryIndexes: [0], targetIndexes: [0] }], + directories: [ + { + source: ".", + build: ".", + projectIndex: 0, + targetIndexes: [0], + }, + ], + targets: [ + { + name: "app", + id: "app::1", + directoryIndex: 0, + projectIndex: 0, + jsonFile: "target-app.json", + }, + ], + }, + ], + }); + writeJson(path.join(reply, "target-app.json"), { + name: "app", + id: "app::1", + type: "EXECUTABLE", + paths: { source: workspace, build: path.join(workspace, "build") }, + sources: [ + { path: "src/main.c" }, + { + path: path.join(workspace, "build", "generated", "gen.c"), + isGenerated: true, + }, + ], + dependencies: [], + artifacts: [{ path: "bin/app" }], + }); + return cmakeRepositoryContextProvider.collect({ + root, + env: { + ...process.env, + SAMCHON_GRAPH_CMAKE_REPLY: reply, + }, + }); +} + +function summarize( + collection: ReturnType, +) { + const shard = collection.shards[0]!; + return { + ecosystem: shard.nodes[0]!.ecosystem, + nodeKinds: shard.nodes.map((node) => node.kind).sort(), + edgeKinds: shard.edges.map((edge) => edge.kind).sort(), + files: shard.files, + coverage: shard.coverage.length, + }; +} + +function joinedFiles( + collection: ReturnType, + codeFiles: readonly string[], +): string[] { + return topologyMemory(collection) + .inspect( + { type: "topology", relations: ["joins-file"], limit: 500 }, + { + state: "compatible", + topologyInputGeneration: "input", + codeInputGeneration: "code", + }, + new Set(codeFiles), + ) + .edges.map((edge) => edge.to) + .sort(); +} + +function topologyMemory( + collection: ReturnType, +): SamchonRepositoryContextMemory { + const shard = collection.shards[0]!; + const contentDigest = RepositoryContextProtocol.contentDigest(shard); + return new SamchonRepositoryContextMemory({ + project: ".", + schemaVersion: 1, + inputGeneration: "input", + generation: { + sequence: 1, + token: "topology", + shards: [ + { + key: shard.key, + digest: RepositoryContextProtocol.shardDigest(shard), + }, + ], + contentDigest, + }, + provenance: [], + coverage: shard.coverage, + nodes: shard.nodes, + edges: shard.edges, + files: shard.files, + sources: shard.sources, + warnings: [], + }); +} + +function exerciseGradleModelParser(root: string): void { + const encode = (value: string): string => + Buffer.from(value, "utf8").toString("base64url"); + const row = (kind: string, ...fields: string[]): string => + [kind, ...fields.map(encode)].join("\t"); + const output = [ + "", + row("V", "9.1"), + row("M", ":app", "app", root), + row("D", ":app", ":lib"), + row("S", ":app", "main", path.join(root, "src"), "true"), + row("T", ":app", ":app:build", "build"), + ].join("\r\n"); + TestValidator.equals( + "the Gradle sidecar framing preserves every supported record", + parseGradleRepositoryContextModel(output), + { + version: "9.1", + modules: [ + { + path: ":app", + name: "app", + directory: root, + dependencies: [":lib"], + sources: [ + { + kind: "main", + directory: path.join(root, "src"), + generated: true, + }, + ], + tasks: [{ path: ":app:build", name: "build" }], + }, + ], + }, + ); + for (const malformed of [ + "", + row("V", "9.1"), + row("M", ":app", "app"), + [row("V", "9.1"), row("D", ":absent", ":lib")].join("\n"), + [row("V", "9.1"), row("S", ":absent", "main", root, "false")].join( + "\n", + ), + [row("V", "9.1"), row("T", ":absent", ":task", "task")].join("\n"), + [row("V", "9.1"), row("X", "unknown")].join("\n"), + ]) { + TestValidator.error("malformed Gradle sidecar framing is refused", () => + parseGradleRepositoryContextModel(malformed), + ); + } +} + +function exerciseCmakeRefusals(root: string): void { + const absentRoot = path.join(root, "cmake-absent"); + write(path.join(absentRoot, "CMakeLists.txt"), "project(absent)\n"); + TestValidator.error("CMake never creates a missing File API query", () => + cmakeRepositoryContextProvider.collect({ + root: absentRoot, + env: process.env, + }), + ); + + const emptyReply = path.join( + root, + "cmake-empty", + ".cmake", + "api", + "v1", + "reply", + ); + fs.mkdirSync(emptyReply, { recursive: true }); + TestValidator.error("CMake requires an existing File API index", () => + cmakeRepositoryContextProvider.collect({ + root, + env: { + ...process.env, + SAMCHON_GRAPH_CMAKE_REPLY: emptyReply, + }, + }), + ); + + const missingReferences = cmakeScenario(root, "missing-references", { + index: {}, + configurations: [{ name: "", projects: [], directories: [], targets: [] }], + }); + TestValidator.error( + "CMake requires both codemodel-v2 and cmakeFiles-v1 replies", + () => + cmakeRepositoryContextProvider.collect({ + root, + env: { + ...process.env, + SAMCHON_GRAPH_CMAKE_REPLY: missingReferences, + }, + }), + ); + + const emptyConfigurations = cmakeScenario(root, "empty-configurations", { + configurations: [], + }); + TestValidator.error("CMake refuses an empty codemodel", () => + cmakeRepositoryContextProvider.collect({ + root, + env: { + ...process.env, + SAMCHON_GRAPH_CMAKE_REPLY: emptyConfigurations, + }, + }), + ); + + const configurations = [ + { name: "Debug", projects: [], directories: [], targets: [] }, + { name: "Release", projects: [], directories: [], targets: [] }, + ]; + const multiple = cmakeScenario(root, "multiple-configurations", { + configurations, + }); + TestValidator.error( + "CMake requires an explicit choice for multiple configurations", + () => + cmakeRepositoryContextProvider.collect({ + root, + env: { + ...process.env, + SAMCHON_GRAPH_CMAKE_REPLY: multiple, + }, + }), + ); + TestValidator.error("CMake refuses an absent requested configuration", () => + cmakeRepositoryContextProvider.collect({ + root, + env: { + ...process.env, + SAMCHON_GRAPH_CMAKE_REPLY: multiple, + SAMCHON_GRAPH_CMAKE_CONFIGURATION: "Absent", + }, + }), + ); + TestValidator.equals( + "CMake publishes only the explicitly selected configuration", + cmakeRepositoryContextProvider + .collect({ + root, + env: { + ...process.env, + SAMCHON_GRAPH_CMAKE_REPLY: multiple, + SAMCHON_GRAPH_CMAKE_CONFIGURATION: "Release", + }, + }) + .shards.map((shard) => shard.target), + ["Release"], + ); + + const objectReply = cmakeScenario(root, "object-references", { + index: { + objects: [ + { kind: "codemodel", jsonFile: "codemodel.json" }, + { kind: "cmakeFiles", jsonFile: "cmakeFiles.json" }, + ], + }, + configurations: [ + { + name: "", + projects: [{ name: "fixture", directoryIndexes: [], targetIndexes: [0, 1] }], + directories: [], + targets: [ + { + name: "app", + id: "app", + directoryIndex: 0, + projectIndex: 0, + jsonFile: "app.json", + }, + { + name: "library", + id: "library", + directoryIndex: 0, + projectIndex: 0, + jsonFile: "library.json", + }, + ], + }, + ], + targets: { + "app.json": { + name: "app", + id: "app", + type: "EXECUTABLE", + dependencies: [{ id: "library" }], + }, + "library.json": { + name: "library", + id: "library", + type: "STATIC_LIBRARY", + }, + }, + }); + const objectModel = cmakeRepositoryContextProvider.collect({ + root, + env: { + ...process.env, + SAMCHON_GRAPH_CMAKE_REPLY: objectReply, + }, + }); + TestValidator.equals( + "CMake accepts the object index form, default configuration and target dependencies", + [ + objectModel.toolVersion, + objectModel.target, + objectModel.shards[0]!.edges.some( + (edge) => edge.kind === "depends-on", + ), + ], + ["", "default", true], + ); +} + +function cmakeScenario( + root: string, + name: string, + options: { + index?: Record; + configurations: Array>; + targets?: Record>; + }, +): string { + const source = path.join(root, `cmake-${name}`); + const build = path.join(source, "build"); + const reply = path.join(build, ".cmake", "api", "v1", "reply"); + write(path.join(source, "CMakeLists.txt"), `project(${name})\n`); + writeJson(path.join(reply, "cmakeFiles.json"), { + paths: { source, build }, + inputs: [{ path: "CMakeLists.txt" }], + }); + writeJson(path.join(reply, "codemodel.json"), { + paths: { source, build }, + configurations: options.configurations, + }); + for (const [file, target] of Object.entries(options.targets ?? {})) { + writeJson(path.join(reply, file), { + paths: { source, build }, + ...target, + }); + } + writeJson(path.join(reply, "index-1.json"), { + ...(options.index ?? { + reply: { + "codemodel-v2": { jsonFile: "codemodel.json" }, + "cmakeFiles-v1": { jsonFile: "cmakeFiles.json" }, + }, + }), + }); + return reply; +} + +function installFakeRepositoryTool(directory: string, name: string): void { + fs.mkdirSync(directory, { recursive: true }); + const source = [ + "#!/usr/bin/env node", + 'const mode = process.env.FIXTURE_TOOL_MODE ?? "valid";', + 'if (process.argv.includes("--version")) { if (mode === "version-failed") process.exit(2); console.log("fixture 1.0.0"); process.exit(0); }', + 'if (mode === "failed") { console.error("fixture tool failed"); process.exit(2); }', + 'if (mode === "malformed") { console.log("{}"); process.exit(0); }', + 'console.log(process.env.FIXTURE_TOOL_MODEL ?? "{}");', + ].join("\n"); + if (process.platform === "win32") { + const script = path.join(directory, `${name}.cjs`); + write(script, source); + write( + path.join(directory, `${name}.cmd`), + `@node "%~dp0\\${name}.cjs" %*\r\n`, + ); + } else { + const executable = path.join(directory, name); + write(executable, source); + fs.chmodSync(executable, 0o755); + } +} + +function write(file: string, content: string): void { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content); +} + +function writeJson(file: string, value: unknown): void { + write(file, JSON.stringify(value)); +} diff --git a/tests/test-graph/src/features/test_repository_context_protocol_commits_atomic_shards.ts b/tests/test-graph/src/features/test_repository_context_protocol_commits_atomic_shards.ts new file mode 100644 index 00000000..544787d0 --- /dev/null +++ b/tests/test-graph/src/features/test_repository_context_protocol_commits_atomic_shards.ts @@ -0,0 +1,603 @@ +import { TestValidator } from "@nestia/e2e"; +import { + RepositoryContextProtocol, + repositoryContextFacts, +} from "@samchon/graph"; + +const { repositoryContextCoverage, repositoryContextId } = + repositoryContextFacts; + +export const test_repository_context_protocol_commits_atomic_shards = + async () => { + const store = new RepositoryContextProtocol.Store(); + const initialFrames = transaction(1); + const initial = store.apply(initialFrames); + TestValidator.equals( + "the initial repository context generation is complete", + [ + initial.generation.sequence, + initial.nodes.map((node) => node.kind), + initial.edges.map((edge) => edge.kind), + initial.coverage.length, + initial.files, + initial.sources, + ], + [ + 1, + ["workspace", "source-root"], + ["contains", "joins-file"], + RepositoryContextProtocol.RELATION_KINDS.length, + ["src/main.ts"], + [{ file: "workspace.json", digest: "a".repeat(64) }], + ], + ); + TestValidator.error("a published topology snapshot is immutable", () => { + initial.nodes.push(initial.nodes[0]!); + }); + TestValidator.error("conflicting duplicate manifest sources are refused", () => + RepositoryContextProtocol.manifestDigest([ + { file: "same", digest: "a".repeat(64) }, + { file: "same", digest: "b".repeat(64) }, + ]), + ); + TestValidator.error("an invalid initial base is refused", () => + new RepositoryContextProtocol.Store().apply( + transaction(2, initial), + ), + ); + + const unchanged = store.apply(transaction(2, initial)); + TestValidator.equals( + "a valid empty delta advances only the generation", + [ + unchanged.generation.sequence, + unchanged.generation.shards, + unchanged.nodes, + ], + [2, initial.generation.shards, initial.nodes], + ); + + const prior = store.current; + const invalid = [ + [] as RepositoryContextProtocol.Frame[], + mutate(transaction(3, unchanged), (frames) => { + frames[0] = frames.at(-1)!; + }), + mutate(transaction(3, unchanged), (frames) => { + frames[frames.length - 1] = frames[0]!; + }), + mutate(transaction(3, unchanged), (frames) => { + frames.pop(); + }), + mutate(transaction(3, unchanged), (frames) => { + (frames[1] as RepositoryContextProtocol.IBegin).baseSequence = 1; + }), + mutate(transaction(3, unchanged), (frames) => { + (frames.at(-1) as RepositoryContextProtocol.ICommit).generation = + "other"; + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const upsert = frames[2] as RepositoryContextProtocol.IUpsertShard; + upsert.digest = "b".repeat(64); + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + frames.splice(3, 0, structuredClone(frames[2]!)); + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const upsert = frames[2] as RepositoryContextProtocol.IUpsertShard; + upsert.shard.edges[0]!.to = "missing"; + refresh(frames); + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const hello = frames[0] as RepositoryContextProtocol.IHello; + hello.authority = "inferred"; + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const hello = frames[0] as RepositoryContextProtocol.IHello; + (hello as { authority: string }).authority = "guessed"; + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const upsert = frames[2] as RepositoryContextProtocol.IUpsertShard; + (upsert.shard.nodes[0] as { kind: string }).kind = "solution"; + refresh(frames); + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const upsert = frames[2] as RepositoryContextProtocol.IUpsertShard; + (upsert.shard.coverage[0] as { state: string }).state = "unknown"; + refresh(frames); + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const begin = frames[1] as RepositoryContextProtocol.IBegin; + begin.manifest = "c".repeat(64); + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const hello = frames[0] as RepositoryContextProtocol.IHello; + hello.toolVersion = "changed"; + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const hello = frames[0] as RepositoryContextProtocol.IHello; + hello.protocolVersion = 0 as 1; + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const hello = frames[0] as RepositoryContextProtocol.IHello; + hello.provider = ""; + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const hello = frames[0] as RepositoryContextProtocol.IHello; + hello.supportedFamilies.push("contains"); + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const hello = frames[0] as RepositoryContextProtocol.IHello; + (hello.supportedFamilies as string[]).push("invented"); + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const hello = frames[0] as RepositoryContextProtocol.IHello; + hello.capabilities.push("fixture"); + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const begin = frames[1] as RepositoryContextProtocol.IBegin; + begin.sequence = 0; + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const begin = frames[1] as RepositoryContextProtocol.IBegin; + begin.generation = ""; + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const begin = frames[1] as RepositoryContextProtocol.IBegin; + begin.manifest = "invalid"; + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const begin = frames[1] as RepositoryContextProtocol.IBegin; + delete begin.baseGeneration; + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const begin = frames[1] as RepositoryContextProtocol.IBegin; + begin.baseSequence = 0; + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const begin = frames[1] as RepositoryContextProtocol.IBegin; + begin.baseGeneration = ""; + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const upsert = changedUpsert(frames); + upsert.shard.key = ""; + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const upsert = changedUpsert(frames); + upsert.shard.target = "other"; + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const upsert = changedUpsert(frames); + upsert.shard.nodes[0]!.name = ""; + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const upsert = changedUpsert(frames); + upsert.shard.nodes[0]!.ecosystem = "other"; + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const upsert = changedUpsert(frames); + upsert.shard.nodes.push(structuredClone(upsert.shard.nodes[0]!)); + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const upsert = changedUpsert(frames); + upsert.shard.nodes[0]!.root = "src"; + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const upsert = changedUpsert(frames); + upsert.shard.nodes[1]!.root = ""; + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const upsert = changedUpsert(frames); + upsert.shard.nodes[1]!.file = ""; + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const upsert = changedUpsert(frames); + upsert.shard.nodes[0]!.evidence = { file: "", startLine: 1 }; + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const upsert = changedUpsert(frames); + upsert.shard.nodes[0]!.evidence = { + file: "workspace.json", + startLine: 0, + }; + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const upsert = changedUpsert(frames); + (upsert.shard.edges[0] as { kind: string }).kind = "invokes"; + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const upsert = changedUpsert(frames); + upsert.shard.edges[0]!.from = ""; + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const upsert = changedUpsert(frames); + upsert.shard.edges.push(structuredClone(upsert.shard.edges[0]!)); + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const upsert = changedUpsert(frames); + upsert.shard.coverage.push( + structuredClone(upsert.shard.coverage[0]!), + ); + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const upsert = changedUpsert(frames); + upsert.shard.coverage.pop(); + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const upsert = changedUpsert(frames); + upsert.shard.files.push(upsert.shard.files[0]!); + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const upsert = changedUpsert(frames); + upsert.shard.sources[0]!.digest = "invalid"; + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const upsert = changedUpsert(frames); + upsert.shard.sources.push( + structuredClone(upsert.shard.sources[0]!), + ); + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + frames.splice(2, 0, { + type: "deleteShard", + key: "absent", + }); + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + frames.splice(2, 0, frames[0]!); + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const commit = frames.at(-1) as RepositoryContextProtocol.ICommit; + commit.shards = []; + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const commit = frames.at(-1) as RepositoryContextProtocol.ICommit; + commit.contentDigest = "0".repeat(64); + }), + ]; + for (const frames of invalid) { + TestValidator.error( + "a malformed repository context transaction is rejected", + () => store.apply(frames), + ); + TestValidator.equals( + "a rejected transaction retains the prior generation", + store.current, + prior, + ); + } + const aborted = new AbortController(); + aborted.abort(); + TestValidator.error("a cancelled transaction is rejected", () => + store.apply(transaction(3, unchanged, changedShard()), { + signal: aborted.signal, + }), + ); + + const changed = store.apply(transaction(3, unchanged, changedShard())); + TestValidator.equals( + "a changed shard replaces one atomic topology generation", + [changed.generation.sequence, changed.nodes.at(-1)?.name], + [3, "source"], + ); + + const multiStore = new RepositoryContextProtocol.Store(); + const first = validShard(); + const second = secondaryShard(); + const multi = multiStore.apply(initialTransaction(1, [first, second])); + const deleted = multiStore.apply(deleteTransaction(2, multi, first, second)); + TestValidator.equals( + "a valid delete delta removes exactly one committed shard", + [ + multi.generation.shards.map((shard) => shard.key), + deleted.generation.shards.map((shard) => shard.key), + deleted.nodes.map((node) => node.name), + ], + [ + ["fixture:secondary", "fixture:workspace"], + ["fixture:workspace"], + ["fixture", "src"], + ], + ); + + const conflictingSource = secondaryShard(); + const conflictingFrames = initialTransaction(1, [ + validShard(), + conflictingSource, + ]); + const conflictingUpsert = conflictingFrames.find( + (frame): frame is RepositoryContextProtocol.IUpsertShard => + frame.type === "upsertShard" && + frame.shard.key === conflictingSource.key, + )!; + conflictingUpsert.shard.sources = [ + { file: "workspace.json", digest: "b".repeat(64) }, + ]; + conflictingUpsert.digest = RepositoryContextProtocol.shardDigest( + conflictingUpsert.shard, + ); + const conflictingCommit = conflictingFrames.at( + -1, + ) as RepositoryContextProtocol.ICommit; + conflictingCommit.shards = conflictingFrames + .filter( + (frame): frame is RepositoryContextProtocol.IUpsertShard => + frame.type === "upsertShard", + ) + .map((frame) => ({ key: frame.shard.key, digest: frame.digest })) + .sort((left, right) => + left.key < right.key ? -1 : left.key > right.key ? 1 : 0, + ); + TestValidator.error("cross-shard source disagreement is refused", () => + new RepositoryContextProtocol.Store().apply(conflictingFrames), + ); + const duplicateNode = secondaryShard(); + duplicateNode.nodes = [structuredClone(validShard().nodes[0]!)]; + TestValidator.error("cross-shard duplicate nodes are refused", () => + new RepositoryContextProtocol.Store().apply( + initialTransaction(1, [validShard(), duplicateNode]), + ), + ); + const duplicateEdge = secondaryShard(); + duplicateEdge.nodes = []; + duplicateEdge.edges = [structuredClone(validShard().edges[0]!)]; + TestValidator.error("cross-shard duplicate edges are refused", () => + new RepositoryContextProtocol.Store().apply( + initialTransaction(1, [validShard(), duplicateEdge]), + ), + ); + }; + +function transaction( + sequence: number, + base?: RepositoryContextProtocol.ISnapshot, + shard?: RepositoryContextProtocol.IShard, +): RepositoryContextProtocol.Frame[] { + const selected = shard ?? validShard(); + const manifest = RepositoryContextProtocol.manifestDigest(selected.sources); + const generation = `generation-${String(sequence)}`; + const includeShard = + base === undefined || + RepositoryContextProtocol.shardDigest(selected) !== + base.generation.shards[0]?.digest; + return [ + hello(), + { + type: "begin", + sequence, + generation, + ...(base !== undefined + ? { + baseSequence: base.generation.sequence, + baseGeneration: base.generation.token, + } + : {}), + inputGeneration: RepositoryContextProtocol.digest({ sequence, manifest }), + universe: "fixture-universe", + target: "workspace", + manifest, + }, + ...(includeShard + ? [ + { + type: "upsertShard" as const, + digest: RepositoryContextProtocol.shardDigest(selected), + shard: selected, + }, + ] + : []), + { + type: "commit", + sequence, + generation, + shards: [ + { + key: selected.key, + digest: RepositoryContextProtocol.shardDigest(selected), + }, + ], + contentDigest: RepositoryContextProtocol.contentDigest(selected), + }, + ]; +} + +function changedUpsert( + frames: RepositoryContextProtocol.Frame[], +): RepositoryContextProtocol.IUpsertShard { + return frames.find( + (frame): frame is RepositoryContextProtocol.IUpsertShard => + frame.type === "upsertShard", + )!; +} + +function hello(): RepositoryContextProtocol.IHello { + return { + type: "hello", + protocolVersion: 1, + schemaVersion: 1, + producerSchemaVersion: 1, + provider: "fixture-context", + ecosystem: "fixture", + authority: "declared", + tool: "fixture-model", + toolVersion: "1.0.0", + supportedFamilies: ["contains", "joins-file"], + capabilities: ["fixture"], + }; +} + +function validShard(): RepositoryContextProtocol.IShard { + const workspace = repositoryContextId("fixture", "workspace", "."); + const source = repositoryContextId("fixture", "source-root", "src"); + return { + key: "fixture:workspace", + target: "workspace", + nodes: [ + { + id: workspace, + kind: "workspace", + name: "fixture", + ecosystem: "fixture", + coordinate: ".", + configuration: "default", + external: false, + }, + { + id: source, + kind: "source-root", + name: "src", + ecosystem: "fixture", + coordinate: "src", + configuration: "default", + external: false, + }, + ], + edges: [ + { kind: "contains", from: workspace, to: source }, + { kind: "joins-file", from: source, to: "src/main.ts" }, + ], + coverage: repositoryContextCoverage( + "fixture-context", + "fixture", + "workspace", + ["contains", "joins-file"], + ), + files: ["src/main.ts"], + sources: [{ file: "workspace.json", digest: "a".repeat(64) }], + }; +} + +function changedShard(): RepositoryContextProtocol.IShard { + const shard = validShard(); + shard.nodes[1]!.name = "source"; + return shard; +} + +function secondaryShard(): RepositoryContextProtocol.IShard { + const project = repositoryContextId("fixture", "project", "secondary"); + return { + key: "fixture:secondary", + target: "workspace", + nodes: [ + { + id: project, + kind: "project", + name: "secondary", + ecosystem: "fixture", + coordinate: "secondary", + configuration: "default", + external: false, + }, + ], + edges: [], + coverage: repositoryContextCoverage( + "fixture-context", + "fixture", + "workspace", + ["contains", "joins-file"], + ), + files: [], + sources: [{ file: "secondary.json", digest: "b".repeat(64) }], + }; +} + +function initialTransaction( + sequence: number, + shards: readonly RepositoryContextProtocol.IShard[], +): RepositoryContextProtocol.Frame[] { + const sources = shards.flatMap((shard) => shard.sources); + const manifest = RepositoryContextProtocol.manifestDigest(sources); + const generation = `multi-generation-${String(sequence)}`; + return [ + hello(), + { + type: "begin", + sequence, + generation, + inputGeneration: RepositoryContextProtocol.digest({ sequence, manifest }), + universe: "fixture-universe", + target: "workspace", + manifest, + }, + ...shards.map((shard) => ({ + type: "upsertShard" as const, + digest: RepositoryContextProtocol.shardDigest(shard), + shard, + })), + { + type: "commit", + sequence, + generation, + shards: shards + .map((shard) => ({ + key: shard.key, + digest: RepositoryContextProtocol.shardDigest(shard), + })) + .sort((left, right) => + left.key < right.key ? -1 : left.key > right.key ? 1 : 0, + ), + contentDigest: RepositoryContextProtocol.contentDigest({ + nodes: shards.flatMap((shard) => shard.nodes), + edges: shards.flatMap((shard) => shard.edges), + coverage: shards.flatMap((shard) => shard.coverage), + }), + }, + ]; +} + +function deleteTransaction( + sequence: number, + base: RepositoryContextProtocol.ISnapshot, + retained: RepositoryContextProtocol.IShard, + removed: RepositoryContextProtocol.IShard, +): RepositoryContextProtocol.Frame[] { + const generation = `multi-generation-${String(sequence)}`; + const manifest = RepositoryContextProtocol.manifestDigest(retained.sources); + return [ + hello(), + { + type: "begin", + sequence, + generation, + baseSequence: base.generation.sequence, + baseGeneration: base.generation.token, + inputGeneration: RepositoryContextProtocol.digest({ sequence, manifest }), + universe: "fixture-universe", + target: "workspace", + manifest, + }, + { type: "deleteShard", key: removed.key }, + { + type: "commit", + sequence, + generation, + shards: [ + { + key: retained.key, + digest: RepositoryContextProtocol.shardDigest(retained), + }, + ], + contentDigest: RepositoryContextProtocol.contentDigest(retained), + }, + ]; +} + +function mutate( + frames: RepositoryContextProtocol.Frame[], + operation: (frames: RepositoryContextProtocol.Frame[]) => void, +): RepositoryContextProtocol.Frame[] { + const cloned = structuredClone(frames); + operation(cloned); + return cloned; +} + +function refresh(frames: RepositoryContextProtocol.Frame[]): void { + const upsert = frames.find( + (frame): frame is RepositoryContextProtocol.IUpsertShard => + frame.type === "upsertShard", + )!; + upsert.digest = RepositoryContextProtocol.shardDigest(upsert.shard); + const commit = frames.at(-1) as RepositoryContextProtocol.ICommit; + commit.shards = [{ key: upsert.shard.key, digest: upsert.digest }]; + commit.contentDigest = RepositoryContextProtocol.contentDigest(upsert.shard); +} diff --git a/tests/test-graph/src/features/test_resident_repository_context_is_atomic_and_retryable.ts b/tests/test-graph/src/features/test_resident_repository_context_is_atomic_and_retryable.ts new file mode 100644 index 00000000..2e849c30 --- /dev/null +++ b/tests/test-graph/src/features/test_resident_repository_context_is_atomic_and_retryable.ts @@ -0,0 +1,536 @@ +import { TestValidator } from "@nestia/e2e"; +import { + IRepositoryContextProvider, + RepositoryContextProtocol, + createRepositoryContextSession, + createResidentRepositoryContextMemorySource, + createResidentRepositoryContextSource, + repositoryContextFacts, + validateRepositoryContextProviders, +} from "@samchon/graph"; +import fs from "node:fs"; +import path from "node:path"; + +import { GraphPaths } from "../internal/GraphPaths"; + +const { + repositoryContextCoverage, + repositoryContextId, + repositoryContextSource, +} = repositoryContextFacts; + +export const test_resident_repository_context_is_atomic_and_retryable = + async () => { + const root = GraphPaths.createTempDirectory( + "samchon-graph-resident-repository-context-", + ); + const input = path.join(root, "context.json"); + let invocations = 0; + let failure = false; + try { + write(input, { name: "initial", file: "src/main.ts" }); + write(path.join(root, "src", "main.ts"), {}); + fs.mkdirSync(path.join(root, "members"), { recursive: true }); + const provider = fakeProvider(() => { + invocations += 1; + if (failure) throw new Error("fixture model failed"); + const model = JSON.parse(fs.readFileSync(input, "utf8")) as { + name: string; + file: string; + }; + return collection(root, model); + }); + const resident = createResidentRepositoryContextSource( + root, + process.env, + [provider], + ); + const initial = await resident.load(); + const unchanged = await resident.load(); + TestValidator.equals( + "a validated no-op reuses the exact topology generation without invoking the model", + [ + sourceName(initial), + initial.generation.sequence, + unchanged === initial, + invocations, + ], + ["initial", 1, true, 1], + ); + + write(input, { name: "changed", file: "src/main.ts" }); + const changed = await resident.load(); + TestValidator.equals( + "a manifest edit replaces one atomic topology generation", + [ + sourceName(changed), + changed.generation.sequence, + invocations, + ], + ["changed", 2, 2], + ); + + write(input, { name: "broken", file: "src/main.ts" }); + failure = true; + const unavailable = await resident.load(); + TestValidator.equals( + "a changed provider input that cannot be modeled publishes explicit unavailability without stale facts", + [ + sourceName(unavailable), + unavailable.coverage.every( + (row) => + row.provider === "fixture-context" && + row.target === "unavailable" && + row.state === "unsupported", + ), + unavailable.warnings.some((warning) => + warning.includes("fixture model failed"), + ), + unavailable.generation.sequence, + ], + [undefined, true, true, 3], + ); + const sameFailure = await resident.load(); + TestValidator.equals( + "an identical repeated failure does not publish another generation", + [sameFailure === unavailable, sameFailure.generation.sequence], + [true, 3], + ); + + write(input, { name: "still-broken", file: "src/main.ts" }); + const movedFailure = await resident.load(); + TestValidator.equals( + "a different failed input still advances the unavailable generation", + [ + movedFailure === unavailable, + movedFailure.generation.sequence, + sourceName(movedFailure), + ], + [false, 4, undefined], + ); + + failure = false; + write(input, { name: "recovered", file: "src/main.ts" }); + const recovered = await resident.load(); + TestValidator.equals( + "the next successful retry atomically replaces retained context", + [ + sourceName(recovered), + recovered.warnings.length, + recovered.generation.sequence, + ], + ["recovered", 0, 5], + ); + + write(input, { name: "cancelled", file: "src/main.ts" }); + const aborted = new AbortController(); + aborted.abort(); + await TestValidator.error("a cancelled topology refresh rejects", () => + resident.load({ signal: aborted.signal }), + ); + TestValidator.equals( + "cancellation leaves the prior generation reachable", + sourceName(await resident.load()), + "cancelled", + ); + + const createdMember = path.join(root, "members", "created"); + const renamedMember = path.join(root, "members", "renamed"); + fs.mkdirSync(createdMember); + const afterCreate = await resident.load(); + fs.renameSync(createdMember, renamedMember); + const afterRename = await resident.load(); + fs.rmdirSync(renamedMember); + const afterDelete = await resident.load(); + TestValidator.equals( + "member create, rename and delete each replace one complete input generation", + [ + afterCreate.generation.sequence, + afterRename.generation.sequence, + afterDelete.generation.sequence, + sourceName(afterDelete), + ], + [7, 8, 9, "cancelled"], + ); + + await resident.close(); + await TestValidator.error( + "a closed topology source refuses new loads", + () => resident.load(), + ); + TestValidator.error("duplicate registry names are refused", () => + validateRepositoryContextProviders([provider, provider]), + ); + TestValidator.error("blank registry ecosystems are refused", () => + validateRepositoryContextProviders([ + { ...provider, name: "blank-ecosystem", ecosystem: "" }, + ]), + ); + TestValidator.error("empty registry relation contracts are refused", () => + validateRepositoryContextProviders([ + { ...provider, name: "empty-families", families: [] }, + ]), + ); + + const memoryResident = { + load: async () => initial, + close: async () => {}, + }; + const loadMemory = + createResidentRepositoryContextMemorySource(memoryResident); + const memoryOne = await loadMemory(); + const memoryTwo = await loadMemory(); + TestValidator.equals( + "resident topology memory is reused for the exact dump identity", + memoryOne === memoryTwo, + true, + ); + memoryResident.load = async () => recovered; + TestValidator.equals( + "a replacement dump receives a replacement topology memory", + (await loadMemory()) === memoryOne, + false, + ); + + TestValidator.error("adapter source disagreement is refused", () => + repositoryContextFacts.uniqueRepositorySources([ + { file: "same", digest: "a".repeat(64) }, + { file: "same", digest: "b".repeat(64) }, + ]), + ); + + const movingInput = path.join(root, "moving.json"); + write(movingInput, { state: 1 }); + const movingProvider = fakeProvider( + () => { + const model = collection(root, { + name: "moving", + file: "src/main.ts", + }); + model.shards[0]!.sources = [ + repositoryContextSource(root, movingInput), + ]; + write(movingInput, { state: 2 }); + return model; + }, + ["moving.json"], + ); + const movingSession = movingProvider.open({ root, env: process.env }); + await TestValidator.error( + "a provider input moving during collection refuses the generation", + () => movingSession.refresh(), + ); + await movingSession.close(); + + const duplicateResident = createResidentRepositoryContextSource( + root, + process.env, + [provider, provider], + ); + await TestValidator.error( + "duplicate facts across providers are refused", + () => duplicateResident.load(), + ); + await duplicateResident.close(); + + const snapshotSession = provider.open({ root, env: process.env }); + const canonicalSnapshot = (await snapshotSession.refresh()).snapshot; + await snapshotSession.close(); + const disagreeingSnapshot = structuredClone(canonicalSnapshot); + disagreeingSnapshot.sources[0]!.digest = "f".repeat(64); + const disagreeingResident = createResidentRepositoryContextSource( + root, + process.env, + [ + snapshotProvider("source-left", canonicalSnapshot), + snapshotProvider("source-right", disagreeingSnapshot), + ], + ); + await TestValidator.error( + "provider source disagreement is refused before publication", + () => disagreeingResident.load(), + ); + await disagreeingResident.close(); + + const closeFailure = createResidentRepositoryContextSource( + root, + process.env, + [closingProvider("close-error", "fixture close failed")], + ); + await TestValidator.error("provider close failures are surfaced", () => + closeFailure.close(), + ); + const multipleCloseFailures = createResidentRepositoryContextSource( + root, + process.env, + [ + closingProvider("close-first", new Error("first close failed")), + closingProvider("close-second", "second close failed"), + ], + ); + await TestValidator.error( + "the first provider close failure survives later close failures", + () => multipleCloseFailures.close(), + ); + + const nonErrorFailure = createResidentRepositoryContextSource( + root, + process.env, + [ + fakeProvider(() => { + throw "non-error model failure"; + }), + ], + ); + TestValidator.predicate( + "non-Error provider failures are normalized into explicit unavailability", + (await nonErrorFailure.load()).warnings.some((warning) => + warning.includes("non-error model failure"), + ), + ); + await nonErrorFailure.close(); + + const modeEnv = { ...process.env }; + const modeSession = provider.open({ root, env: modeEnv }); + TestValidator.equals( + "a first provider collection is initial", + (await modeSession.refresh()).mode, + "initial", + ); + modeEnv.PATH = `${modeEnv.PATH ?? ""}${path.delimiter}changed`; + TestValidator.equals( + "an environment-only input change reuses the same universe incrementally", + (await modeSession.refresh()).mode, + "incremental", + ); + await modeSession.close(); + + const noPathSession = provider.open({ + root, + env: { ...process.env, PATH: undefined }, + }); + await noPathSession.refresh(); + await noPathSession.close(); + TestValidator.predicate( + "root-relative input identity is stable without PATH", + createRepositoryContextSession.observeInputGeneration( + root, + ["."], + undefined, + { PATH: undefined }, + ).length === 64, + ); + + let includeSecondShard = true; + const shardSession = createRepositoryContextSession( + { + name: "shard-removal", + ecosystem: "fixture", + authority: "declared", + families: ["contains", "joins-file"], + buildInputs: ["context.json"], + }, + { root, env: process.env }, + () => { + const result = collection(root, { + name: "sharded", + file: "src/main.ts", + }); + if (includeSecondShard) { + result.shards.push({ + key: "fixture:secondary", + target: "workspace", + nodes: [ + { + id: repositoryContextId("fixture", "project", "secondary"), + kind: "project", + name: "secondary", + ecosystem: "fixture", + coordinate: "secondary", + configuration: "default", + external: false, + }, + ], + edges: [], + coverage: repositoryContextCoverage( + "shard-removal", + "fixture", + "workspace", + ["contains", "joins-file"], + ), + files: [], + sources: [repositoryContextSource(root, "context.json")], + }); + } + result.shards[0]!.coverage = repositoryContextCoverage( + "shard-removal", + "fixture", + "workspace", + ["contains", "joins-file"], + ); + return result; + }, + ); + TestValidator.equals( + "the initial session can own multiple atomic shards", + (await shardSession.refresh()).snapshot.generation.shards.length, + 2, + ); + includeSecondShard = false; + write(input, { name: "one-shard", file: "src/main.ts" }); + TestValidator.equals( + "a later collection emits the removed shard delta", + (await shardSession.refresh()).snapshot.generation.shards.length, + 1, + ); + await shardSession.close(); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }; + +function fakeProvider( + collect: IRepositoryContextProvider.Collector, + buildInputs: readonly string[] = [ + "context.json", + "undeclared-by-collector.json", + ], +): IRepositoryContextProvider { + const provider: IRepositoryContextProvider = { + name: "fixture-context", + ecosystem: "fixture", + authority: "declared", + families: ["contains", "joins-file"], + buildInputs, + detect: () => true, + open: (props) => + createRepositoryContextSession(provider, props, collect), + }; + return provider; +} + +function closingProvider( + name: string, + failure: unknown, +): IRepositoryContextProvider { + return { + name, + ecosystem: "fixture", + authority: "declared", + families: ["contains"], + buildInputs: [], + detect: () => true, + open: ({ root }) => ({ + kind: "repository-context", + provider: name, + ecosystem: "fixture", + root, + generation: 0, + current: undefined, + refresh: async () => { + throw new Error("unused"); + }, + close: async () => { + throw failure; + }, + }), + }; +} + +function snapshotProvider( + name: string, + snapshot: RepositoryContextProtocol.ISnapshot, +): IRepositoryContextProvider { + return { + name, + ecosystem: "fixture", + authority: "declared", + families: ["contains", "joins-file"], + buildInputs: [], + detect: () => true, + open: ({ root }) => ({ + kind: "repository-context", + provider: name, + ecosystem: "fixture", + root, + generation: snapshot.generation.sequence, + current: snapshot, + refresh: async () => ({ + changed: true, + generation: snapshot.generation.sequence, + mode: "full", + snapshot, + warnings: [], + }), + close: async () => {}, + }), + }; +} + +function collection( + root: string, + model: { name: string; file: string }, +): IRepositoryContextProvider.ICollection { + const workspace = repositoryContextId("fixture", "workspace", "."); + const source = repositoryContextId("fixture", "source-root", "src"); + const shard = { + key: "fixture:workspace", + target: "workspace", + nodes: [ + { + id: workspace, + kind: "workspace" as const, + name: "fixture", + ecosystem: "fixture", + coordinate: ".", + configuration: "default", + external: false, + }, + { + id: source, + kind: "source-root" as const, + name: model.name, + ecosystem: "fixture", + coordinate: "src", + configuration: "default", + external: false, + }, + ], + edges: [ + { kind: "contains" as const, from: workspace, to: source }, + { kind: "joins-file" as const, from: source, to: model.file }, + ], + coverage: repositoryContextCoverage( + "fixture-context", + "fixture", + "workspace", + ["contains", "joins-file"], + ), + files: [model.file], + sources: [ + repositoryContextSource(root, "context.json"), + repositoryContextSource(root, "members"), + ], + }; + return { + producerSchemaVersion: 1, + tool: "fixture-model", + toolVersion: "1.0.0", + capabilities: ["fixture"], + universe: RepositoryContextProtocol.digest(shard.sources), + target: "workspace", + shards: [shard], + warnings: [], + }; +} + +function write(file: string, value: unknown): void { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify(value)); +} + +function sourceName(snapshot: { + nodes: readonly { kind: string; name: string }[]; +}): string | undefined { + return snapshot.nodes.find((node) => node.kind === "source-root")?.name; +} diff --git a/tests/test-graph/src/internal/ContractGraph.ts b/tests/test-graph/src/internal/ContractGraph.ts index da261841..e61703eb 100644 --- a/tests/test-graph/src/internal/ContractGraph.ts +++ b/tests/test-graph/src/internal/ContractGraph.ts @@ -1,10 +1,37 @@ -import { SamchonGraphMemory, SamchonGraphApplication } from "@samchon/graph"; +import { + SamchonGraphMemory, + SamchonGraphApplication, + SamchonRepositoryContextMemory, +} from "@samchon/graph"; import type { ISamchonGraphApplication } from "@samchon/graph"; import { GraphFixtures } from "./GraphFixtures"; -const createApplication = (): SamchonGraphApplication => - new SamchonGraphApplication(SamchonGraphMemory.from(GraphFixtures.createContractFixture().dump)); +const createApplication = (): SamchonGraphApplication => { + const fixture = GraphFixtures.createContractFixture(); + return new SamchonGraphApplication( + SamchonGraphMemory.from(fixture.dump), + () => + new SamchonRepositoryContextMemory({ + project: fixture.root, + schemaVersion: 1, + inputGeneration: "a".repeat(64), + generation: { + sequence: 1, + token: "b".repeat(64), + shards: [], + contentDigest: "c".repeat(64), + }, + provenance: [], + coverage: [], + nodes: [], + edges: [], + files: [], + sources: [], + warnings: [], + }), + ); +}; const call = ( app: SamchonGraphApplication, diff --git a/tests/test-graph/src/internal/ContractParity.ts b/tests/test-graph/src/internal/ContractParity.ts index 6b600ff0..520d8ef2 100644 --- a/tests/test-graph/src/internal/ContractParity.ts +++ b/tests/test-graph/src/internal/ContractParity.ts @@ -444,6 +444,64 @@ export namespace ContractParity { 'import { ISamchonGraphUnresolvedSummary } from "./ISamchonGraphUnresolvedSummary";', ].join("\n"), }, + { + reason: + "#159 adds a typed repository-topology branch while keeping its fact plane separate from code-semantic structures.", + from: [ + 'import { ISamchonGraphTrace } from "./ISamchonGraphTrace";', + 'import { ISamchonGraphUnresolvedSummary } from "./ISamchonGraphUnresolvedSummary";', + ].join("\n"), + to: [ + 'import { ISamchonGraphTrace } from "./ISamchonGraphTrace";', + 'import { ISamchonGraphTopology } from "./ISamchonGraphTopology";', + 'import { ISamchonGraphUnresolvedSummary } from "./ISamchonGraphUnresolvedSummary";', + ].join("\n"), + }, + { + reason: + "#159 documents topology as the request for declared or owning-tool repository orientation, distinct from symbol semantics.", + layer: "prose", + from: + "- `overview`: project layers and folder structure. - `escape`: the answer is outside the graph", + to: + "- `overview`: project layers and folder structure. - `topology`: workspace, package, target, task, source-root, entrypoint, and project-dependency orientation from declared or owning-tool models. - `escape`: the answer is outside the graph", + }, + { + reason: + "#159 lists the new typed branch in the public method's operation guide.", + layer: "prose", + from: + "- `overview`: the project's layers and folder structure Every fact", + to: + "- `overview`: the project's layers and folder structure - `topology`: repository workspaces, packages, roots, targets, tasks, and dependencies Every fact", + }, + { + reason: + "The method guide keeps the unchanged tour meaning within the MCP schema generator's description limit.", + layer: "prose", + from: + "- `tour`: architecture, the runtime flow from the public API to the code that does the work, nearby paths, and the tests to read — a whole orientation in one call - `trace`:", + to: + "- `tour`: architecture, runtime flow, nearby paths, and tests - `trace`:", + }, + { + reason: + "#159 adds the versioned repository-topology request to the existing single MCP tool.", + from: "| ISamchonGraphEscape.IRequest;", + to: [ + "| ISamchonGraphTopology.IRequest", + "| ISamchonGraphEscape.IRequest;", + ].join("\n"), + }, + { + reason: + "#159 returns the typed repository-topology result beside the existing code-semantic result branches.", + from: "| ISamchonGraphEscape;", + to: [ + "| ISamchonGraphTopology", + "| ISamchonGraphEscape;", + ].join("\n"), + }, { reason: "The compiler resolves a fact and verifies it; the index checks it. The same guarantee, named for the authority that gives it.", @@ -503,7 +561,7 @@ export namespace ContractParity { "There is no compiler to own the index; the repository's own program index answers the question.", layer: "prose", from: "Answer a __LANG__ question from the compiler's own index of this repository.", - to: "Answer a __LANG__ question from this repository's own program index.", + to: "Answer a __LANG__ question from the repository's program index.", }, { reason: @@ -514,10 +572,10 @@ export namespace ContractParity { }, { reason: - "No authority: the sentence is reworded with no change of meaning (a comma becomes `or`, `in` becomes `inside`).", + "No authority: the sentence is shortened without changing the boundary between graph facts and source text.", layer: "prose", from: "Read a file for what the graph does not carry: a body, the text in a span.", - to: "Read a file for what the graph does not carry: a body or the text inside a span.", + to: "Read source only for a body or span text.", }, { reason: @@ -547,7 +605,7 @@ export namespace ContractParity { from: "The graph holds every symbol, call, type, decorator and test, each with its file and line, resolved from the source on disk now. Submit exactly one request:", to: - "The graph returns proved indexed facts plus structured coverage and uncertainty. Submit exactly one request:", + "The graph returns proved facts with coverage and uncertainty. Submit one request:", }, { reason: @@ -661,6 +719,33 @@ export namespace ContractParity { 'indexer: "lsp" | "static" | "hybrid";', ].join("\n"), }, + { + reason: + "#159 adds a deterministic code-input generation so an MCP request can fence a topology load between two code refreshes before admitting file joins.", + from: 'indexer: "lsp" | "static" | "hybrid";', + to: [ + 'indexer: "lsp" | "static" | "hybrid";', + "generation?: {", + "input: string;", + "};", + ].join("\n"), + }, + { + reason: + "The generation shape above is structural; this prose rule records that it exists for cross-plane fencing and remains optional only for legacy dumps.", + layer: "prose", + from: [ + "generation?: {", + "input: string;", + "};", + ].join("\n"), + to: [ + "/** Complete coordinator input generation used to fence code/topology joins. Absent only on dumps written before cross-plane generation fencing. */", + "generation?: {", + "input: string;", + "};", + ].join("\n"), + }, { reason: "The reference proves one TypeScript Program. The public multi-language dump cannot present one provider's proof as authority for every language; #66 owns the provider registry and its eventual public provenance shape. Reduce the reviewed prose block to its code before removing the same exact structure at both fidelities.", diff --git a/tests/test-graph/src/internal/GraphFixtures.ts b/tests/test-graph/src/internal/GraphFixtures.ts index 94af2a81..6536c9b8 100644 --- a/tests/test-graph/src/internal/GraphFixtures.ts +++ b/tests/test-graph/src/internal/GraphFixtures.ts @@ -56,6 +56,7 @@ const GRAPH_REQUEST_TYPES = [ "details", "overview", "tour", + "topology", "escape", ]; From edd8bc94217f16241797a97ec964be4705370b08 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 31 Jul 2026 17:42:11 +0900 Subject: [PATCH 11/52] fix: close repository topology truth gaps Repair cancellation retry, fact authority, CMake reply validation, and bounded result truth after the pushed-commit early-warning pass. Close #159: Explore repository-context providers for repository-wide topology --- packages/graph/src/SamchonGraphApplication.ts | 25 ++- .../repository/RepositoryContextProtocol.ts | 10 + .../SamchonRepositoryContextMemory.ts | 29 ++- .../cargoRepositoryContextProvider.ts | 67 +++++- .../cmakeRepositoryContextProvider.ts | 104 ++++++++-- .../createResidentRepositoryContextSource.ts | 30 ++- .../gradleRepositoryContextProvider.ts | 56 +++++- .../pnpmRepositoryContextProvider.ts | 46 ++++- .../ISamchonRepositoryContextDump.ts | 4 + ...gy_fences_file_joins_by_code_generation.ts | 72 +++++-- ..._adapters_preserve_authoritative_models.ts | 110 +++++++++- ..._context_protocol_commits_atomic_shards.ts | 29 ++- ...ository_context_is_atomic_and_retryable.ts | 190 +++++++++++++++++- 13 files changed, 685 insertions(+), 87 deletions(-) diff --git a/packages/graph/src/SamchonGraphApplication.ts b/packages/graph/src/SamchonGraphApplication.ts index db4bbfcd..43c6a6a0 100644 --- a/packages/graph/src/SamchonGraphApplication.ts +++ b/packages/graph/src/SamchonGraphApplication.ts @@ -160,22 +160,27 @@ export class SamchonGraphApplication implements ISamchonGraphApplication { ? "No repository-context provider produced a compatible current generation." : "The code generation moved while topology was loading, or the code dump predates cross-plane generation fencing.", }; + const result = topology.inspect( + props.request, + join, + new Set( + graph.nodes + .filter((node) => node.kind === "file") + .map((node) => node.file), + ), + ); return { audit: "Repository topology is returned from declared or owning-tool models; file joins are included only when the code generation stayed stable across the topology load.", next: resultNext( "answer", - "The requested repository orientation is present in this topology result.", - ), - result: topology.inspect( - props.request, - join, - new Set( - graph.nodes - .filter((node) => node.kind === "file") - .map((node) => node.file), - ), + result.nodes.length === 0 + ? "No repository topology node matched the requested query or available provider facts." + : result.truncated + ? "The requested repository orientation is present, and the result states that its configured bounds truncated additional facts." + : "The requested repository orientation is present in this topology result.", ), + result, }; } default: diff --git a/packages/graph/src/repository/RepositoryContextProtocol.ts b/packages/graph/src/repository/RepositoryContextProtocol.ts index 2be6afe5..c4f3528c 100644 --- a/packages/graph/src/repository/RepositoryContextProtocol.ts +++ b/packages/graph/src/repository/RepositoryContextProtocol.ts @@ -451,6 +451,11 @@ export namespace RepositoryContextProtocol { `repository context protocol: unknown node kind ${node.kind}`, ); } + if (!AUTHORITIES.includes(node.authority)) { + throw new Error( + `repository context protocol: unknown node authority ${node.id}`, + ); + } if (node.root !== undefined) { assertText(node.root, "node root"); if ( @@ -470,6 +475,11 @@ export namespace RepositoryContextProtocol { } const edgeKeys = new Set(); for (const edge of shard.edges) { + if (!AUTHORITIES.includes(edge.authority)) { + throw new Error( + `repository context protocol: unknown edge authority ${edge.kind}`, + ); + } if (!hello.supportedFamilies.includes(edge.kind)) { throw new Error( `repository context protocol: unadvertised edge family ${edge.kind}`, diff --git a/packages/graph/src/repository/SamchonRepositoryContextMemory.ts b/packages/graph/src/repository/SamchonRepositoryContextMemory.ts index 2baed167..a1f66ca0 100644 --- a/packages/graph/src/repository/SamchonRepositoryContextMemory.ts +++ b/packages/graph/src/repository/SamchonRepositoryContextMemory.ts @@ -69,15 +69,16 @@ export class SamchonRepositoryContextMemory { .filter((node) => selected.has(node.id)) .slice(0, limit); const retained = new Set(nodes.map((node) => node.id)); + const retainedEdges = edges.filter( + (edge) => + retained.has(edge.from) && + (edge.kind === "joins-file" || retained.has(edge.to)), + ); return { type: "topology", schemaVersion: 1, nodes, - edges: edges.filter( - (edge) => - retained.has(edge.from) && - (edge.kind === "joins-file" || retained.has(edge.to)), - ), + edges: retainedEdges, provenance: this.dump.provenance.map((row) => ({ ...row })), coverage: this.dump.coverage .filter((row) => families === undefined || families.has(row.family)) @@ -88,7 +89,9 @@ export class SamchonRepositoryContextMemory { }, join, truncated: - seeds.length > limit || matchingJoins.length > joinLimit, + seeds.length > limit || + matchingJoins.length > joinLimit || + retainedEdges.length < edges.length, }; } } @@ -106,12 +109,14 @@ function withCodeJoins( ); for (const node of nodes) { if (node.file !== undefined && codeFiles.has(node.file)) { - add(node.id, node.file); + add(node.id, node.file, node.authority); } if (node.root !== undefined) { const prefix = node.root === "." ? "" : `${node.root.replace(/\/$/, "")}/`; for (const file of codeFiles) { - if (prefix === "" || file.startsWith(prefix)) add(node.id, file); + if (prefix === "" || file.startsWith(prefix)) { + add(node.id, file, node.authority); + } } } } @@ -122,8 +127,12 @@ function withCodeJoins( compare(left.to, right.to), ); - function add(from: string, to: string): void { - const edge = { kind: "joins-file" as const, from, to }; + function add( + from: string, + to: string, + authority: ISamchonRepositoryContextDump.IEdge["authority"], + ): void { + const edge = { authority, kind: "joins-file" as const, from, to }; rows.set(`${edge.kind}\0${edge.from}\0${edge.to}`, edge); } } diff --git a/packages/graph/src/repository/cargoRepositoryContextProvider.ts b/packages/graph/src/repository/cargoRepositoryContextProvider.ts index 9dac19f6..c4bed4d4 100644 --- a/packages/graph/src/repository/cargoRepositoryContextProvider.ts +++ b/packages/graph/src/repository/cargoRepositoryContextProvider.ts @@ -97,6 +97,7 @@ function collectCargoRepositoryContext( const nodes: ISamchonRepositoryContextDump.INode[] = [ { id: workspaceId, + authority: "tool-resolved", kind: "workspace", name: path.basename(metadata.workspace_root), ecosystem: ECOSYSTEM, @@ -137,6 +138,7 @@ function collectCargoRepositoryContext( sources.push(repositoryContextSource(props.root, pkg.manifest_path)); nodes.push({ id: packageId, + authority: "tool-resolved", kind: "package", name: pkg.name, ecosystem: ECOSYSTEM, @@ -145,7 +147,14 @@ function collectCargoRepositoryContext( external: !member, evidence: repositoryContextEvidence(props.root, pkg.manifest_path), }); - if (member) edges.push({ kind: "contains", from: workspaceId, to: packageId }); + if (member) { + edges.push({ + authority: "tool-resolved", + kind: "contains", + from: workspaceId, + to: packageId, + }); + } appendCargoTargets( props.root, pkg, @@ -164,7 +173,14 @@ function collectCargoRepositoryContext( compareRepositoryText, )) { const to = packageIds.get(dependency); - if (to !== undefined) edges.push({ kind: "depends-on", from, to }); + if (to !== undefined) { + edges.push({ + authority: "tool-resolved", + kind: "depends-on", + from, + to, + }); + } } } @@ -254,6 +270,7 @@ function appendCargoTargets( nodes.push( { id: targetId, + authority: "tool-resolved", kind: "build-target", name: target.name, ecosystem: ECOSYSTEM, @@ -265,6 +282,7 @@ function appendCargoTargets( }, { id: sourceSetId, + authority: "tool-resolved", kind: "source-set", name: target.kind.join("+"), ecosystem: ECOSYSTEM, @@ -276,10 +294,26 @@ function appendCargoTargets( }, ); edges.push( - { kind: "contains", from: packageId, to: targetId }, - { kind: "contains", from: targetId, to: sourceSetId }, - { kind: "source-of", from: sourceSetId, to: packageId }, { + authority: "tool-resolved", + kind: "contains", + from: packageId, + to: targetId, + }, + { + authority: "tool-resolved", + kind: "contains", + from: targetId, + to: sourceSetId, + }, + { + authority: "tool-resolved", + kind: "source-of", + from: sourceSetId, + to: packageId, + }, + { + authority: "tool-resolved", kind: "joins-file", from: sourceSetId, to: file, @@ -287,7 +321,12 @@ function appendCargoTargets( ); files.add(file); if (target.kind.includes("test") || target.kind.includes("bench")) { - edges.push({ kind: "test-of", from: sourceSetId, to: packageId }); + edges.push({ + authority: "tool-resolved", + kind: "test-of", + from: sourceSetId, + to: packageId, + }); } if ( target.kind.some((kind) => @@ -302,6 +341,7 @@ function appendCargoTargets( ); nodes.push({ id: entrypointId, + authority: "tool-resolved", kind: "entrypoint", name: target.name, ecosystem: ECOSYSTEM, @@ -312,9 +352,20 @@ function appendCargoTargets( evidence, }); edges.push( - { kind: "contains", from: targetId, to: entrypointId }, - { kind: "entrypoint-of", from: entrypointId, to: targetId }, { + authority: "tool-resolved", + kind: "contains", + from: targetId, + to: entrypointId, + }, + { + authority: "tool-resolved", + kind: "entrypoint-of", + from: entrypointId, + to: targetId, + }, + { + authority: "tool-resolved", kind: "joins-file", from: entrypointId, to: file, diff --git a/packages/graph/src/repository/cmakeRepositoryContextProvider.ts b/packages/graph/src/repository/cmakeRepositoryContextProvider.ts index 5f639669..f8d41b73 100644 --- a/packages/graph/src/repository/cmakeRepositoryContextProvider.ts +++ b/packages/graph/src/repository/cmakeRepositoryContextProvider.ts @@ -22,7 +22,11 @@ const ECOSYSTEM = "cmake"; interface ICmakeIndex { cmake?: { version?: { string?: string } }; reply?: Record; - objects?: Array<{ kind?: string; jsonFile?: string }>; + objects?: Array<{ + kind?: string; + version?: { major?: number; minor?: number }; + jsonFile?: string; + }>; } interface ICmakeCodemodel { @@ -108,10 +112,16 @@ function collectCmakeRepositoryContext( } const indexFile = latestIndex(reply); const index = readJson(indexFile); - const codemodelRef = objectReference(index, "codemodel", "codemodel-v2"); + const codemodelRef = objectReference( + index, + "codemodel", + 2, + "codemodel-v2", + ); const cmakeFilesRef = objectReference( index, "cmakeFiles", + 1, "cmakeFiles-v1", ); if (codemodelRef === undefined || cmakeFilesRef === undefined) { @@ -179,10 +189,14 @@ function collectCmakeRepositoryContext( function objectReference( index: ICmakeIndex, kind: string, + major: number, replyPrefix: string, ): string | undefined { return ( - index.objects?.find((entry) => entry.kind === kind)?.jsonFile ?? + index.objects?.find( + (entry) => + entry.kind === kind && entry.version?.major === major, + )?.jsonFile ?? Object.entries(index.reply ?? {}).find(([key]) => key.startsWith(replyPrefix), )?.[1].jsonFile @@ -210,6 +224,7 @@ function cmakeConfigurationShard( const nodes: ISamchonRepositoryContextDump.INode[] = [ { id: workspaceId, + authority: "tool-resolved", kind: "workspace", name: path.basename(codemodel.paths.source), ecosystem: ECOSYSTEM, @@ -233,7 +248,10 @@ function cmakeConfigurationShard( ), ), ]; - assertCmakeReplyFresh(indexFile, sources, root); + // cmakeFiles-v1 is the owning tool's complete configuration-input list. + // Check every one of those inputs, including included `.cmake` modules, + // instead of guessing freshness from CMakeLists.txt names. + assertCmakeReplyFresh(indexFile, modelInputs.slice(1), root); const projectIds = new Map(); const targetIds = new Map(); @@ -247,6 +265,7 @@ function cmakeConfigurationShard( projectIds.set(index, projectId); nodes.push({ id: projectId, + authority: "tool-resolved", kind: "project", name: project.name, ecosystem: ECOSYSTEM, @@ -255,7 +274,12 @@ function cmakeConfigurationShard( external: false, evidence, }); - edges.push({ kind: "contains", from: workspaceId, to: projectId }); + edges.push({ + authority: "tool-resolved", + kind: "contains", + from: workspaceId, + to: projectId, + }); }); const details = new Map(); @@ -274,6 +298,7 @@ function cmakeConfigurationShard( targetIds.set(summary.id, targetId); nodes.push({ id: targetId, + authority: "tool-resolved", kind: "build-target", name: summary.name, ecosystem: ECOSYSTEM, @@ -282,7 +307,12 @@ function cmakeConfigurationShard( external: false, evidence, }); - edges.push({ kind: "contains", from: projectId, to: targetId }); + edges.push({ + authority: "tool-resolved", + kind: "contains", + from: projectId, + to: targetId, + }); appendCmakeSources( root, target, @@ -302,6 +332,7 @@ function cmakeConfigurationShard( ); nodes.push({ id: entrypointId, + authority: "tool-resolved", kind: "entrypoint", name: detail.name, ecosystem: ECOSYSTEM, @@ -311,8 +342,18 @@ function cmakeConfigurationShard( evidence, }); edges.push( - { kind: "contains", from: targetId, to: entrypointId }, - { kind: "entrypoint-of", from: entrypointId, to: targetId }, + { + authority: "tool-resolved", + kind: "contains", + from: targetId, + to: entrypointId, + }, + { + authority: "tool-resolved", + kind: "entrypoint-of", + from: entrypointId, + to: targetId, + }, ); } for (const artifact of detail.artifacts ?? []) { @@ -329,6 +370,7 @@ function cmakeConfigurationShard( ); nodes.push({ id: generatedId, + authority: "tool-resolved", kind: "generated-root", name: path.basename(path.dirname(artifactPath)), ecosystem: ECOSYSTEM, @@ -338,8 +380,18 @@ function cmakeConfigurationShard( evidence, }); edges.push( - { kind: "contains", from: targetId, to: generatedId }, - { kind: "produces", from: targetId, to: generatedId }, + { + authority: "tool-resolved", + kind: "contains", + from: targetId, + to: generatedId, + }, + { + authority: "tool-resolved", + kind: "produces", + from: targetId, + to: generatedId, + }, ); } } @@ -347,7 +399,14 @@ function cmakeConfigurationShard( const from = targetIds.get(id)!; for (const dependency of detail.dependencies ?? []) { const to = targetIds.get(dependency.id); - if (to !== undefined) edges.push({ kind: "depends-on", from, to }); + if (to !== undefined) { + edges.push({ + authority: "tool-resolved", + kind: "depends-on", + from, + to, + }); + } } } return { @@ -412,6 +471,7 @@ function appendCmakeSources( ); nodes.push({ id: sourceId, + authority: "tool-resolved", kind: row.generated ? "generated-root" : "source-root", name: path.basename(directory), ecosystem: ECOSYSTEM, @@ -424,13 +484,28 @@ function appendCmakeSources( ), }); edges.push( - { kind: "contains", from: targetId, to: sourceId }, - { kind: "source-of", from: sourceId, to: projectId }, + { + authority: "tool-resolved", + kind: "contains", + from: targetId, + to: sourceId, + }, + { + authority: "tool-resolved", + kind: "source-of", + from: sourceId, + to: projectId, + }, ); for (const file of row.files) { const joined = repositoryContextFile(root, file); files.add(joined); - edges.push({ kind: "joins-file", from: sourceId, to: joined }); + edges.push({ + authority: "tool-resolved", + kind: "joins-file", + from: sourceId, + to: joined, + }); } } } @@ -442,7 +517,6 @@ function assertCmakeReplyFresh( ): void { const replyTime = fs.statSync(indexFile).mtimeMs; for (const source of sources) { - if (!source.file.endsWith("CMakeLists.txt")) continue; const file = path.resolve(root, source.file); if (fs.existsSync(file) && fs.statSync(file).mtimeMs > replyTime) { throw new Error( diff --git a/packages/graph/src/repository/createResidentRepositoryContextSource.ts b/packages/graph/src/repository/createResidentRepositoryContextSource.ts index a044b28d..ef0b5344 100644 --- a/packages/graph/src/repository/createResidentRepositoryContextSource.ts +++ b/packages/graph/src/repository/createResidentRepositoryContextSource.ts @@ -28,7 +28,7 @@ export function createResidentRepositoryContextSource( let sequence = 0; let queue = Promise.resolve(); let closed = false; - let priorFailures: string[] = []; + let priorStates: string[] = []; return { load(options = {}) { @@ -40,11 +40,9 @@ export function createResidentRepositoryContextSource( warnings: string[]; }> = []; const failures: IProviderFailure[] = []; - let changed = current === undefined; for (const row of sessions) { try { const refresh = await row.session.refresh(options); - changed ||= refresh.changed; snapshots.push({ provider: row.provider, snapshot: refresh.snapshot, @@ -65,19 +63,19 @@ export function createResidentRepositoryContextSource( }); } } + const states = [ + ...snapshots.map(snapshotIdentity), + ...failures.map(failureIdentity), + ].sort(compareRepositoryText); if ( - !changed && - sameStrings( - failures.map(failureIdentity), - priorFailures, - ) && + sameStrings(states, priorStates) && current !== undefined ) { return current; } const next = assemble(project, sequence + 1, snapshots, failures); sequence = next.generation.sequence; - priorFailures = failures.map(failureIdentity); + priorStates = states; current = next; return next; }); @@ -144,6 +142,20 @@ function failureIdentity(failure: IProviderFailure): string { ].join("\0"); } +function snapshotIdentity(snapshot: { + provider: IRepositoryContextProvider; + snapshot: RepositoryContextProtocol.ISnapshot; + warnings: readonly string[]; +}): string { + return RepositoryContextProtocol.digest({ + provider: snapshot.provider.name, + inputGeneration: snapshot.snapshot.begin.inputGeneration, + generation: snapshot.snapshot.generation.token, + content: snapshot.snapshot.generation.contentDigest, + warnings: [...snapshot.warnings].sort(compareRepositoryText), + }); +} + function assemble( project: string, sequence: number, diff --git a/packages/graph/src/repository/gradleRepositoryContextProvider.ts b/packages/graph/src/repository/gradleRepositoryContextProvider.ts index 50ed60aa..514144a7 100644 --- a/packages/graph/src/repository/gradleRepositoryContextProvider.ts +++ b/packages/graph/src/repository/gradleRepositoryContextProvider.ts @@ -77,6 +77,7 @@ function collectGradleRepositoryContext( const nodes: ISamchonRepositoryContextDump.INode[] = [ { id: workspaceId, + authority: "tool-resolved", kind: "workspace", name: path.basename(props.root), ecosystem: ECOSYSTEM, @@ -118,6 +119,7 @@ function collectGradleRepositoryContext( nodes.push( { id: projectId, + authority: "tool-resolved", kind: "project", name: module.name, ecosystem: ECOSYSTEM, @@ -128,6 +130,7 @@ function collectGradleRepositoryContext( }, { id: buildTargetId, + authority: "tool-resolved", kind: "build-target", name: module.path, ecosystem: ECOSYSTEM, @@ -138,8 +141,18 @@ function collectGradleRepositoryContext( }, ); edges.push( - { kind: "contains", from: workspaceId, to: projectId }, - { kind: "contains", from: projectId, to: buildTargetId }, + { + authority: "tool-resolved", + kind: "contains", + from: workspaceId, + to: projectId, + }, + { + authority: "tool-resolved", + kind: "contains", + from: projectId, + to: buildTargetId, + }, ); for (const source of module.sources) { const coordinate = `${module.path}:${repositoryContextFile( @@ -153,6 +166,7 @@ function collectGradleRepositoryContext( ); nodes.push({ id: sourceId, + authority: "tool-resolved", kind: source.generated ? "generated-root" : "source-root", name: path.basename(source.directory), ecosystem: ECOSYSTEM, @@ -163,11 +177,26 @@ function collectGradleRepositoryContext( evidence, }); edges.push( - { kind: "contains", from: buildTargetId, to: sourceId }, - { kind: "source-of", from: sourceId, to: projectId }, + { + authority: "tool-resolved", + kind: "contains", + from: buildTargetId, + to: sourceId, + }, + { + authority: "tool-resolved", + kind: "source-of", + from: sourceId, + to: projectId, + }, ); if (source.kind.startsWith("test")) { - edges.push({ kind: "test-of", from: sourceId, to: projectId }); + edges.push({ + authority: "tool-resolved", + kind: "test-of", + from: sourceId, + to: projectId, + }); } } for (const task of module.tasks) { @@ -178,6 +207,7 @@ function collectGradleRepositoryContext( ); nodes.push({ id: taskId, + authority: "tool-resolved", kind: "task", name: task.name, ecosystem: ECOSYSTEM, @@ -186,7 +216,12 @@ function collectGradleRepositoryContext( external: false, evidence, }); - edges.push({ kind: "contains", from: projectId, to: taskId }); + edges.push({ + authority: "tool-resolved", + kind: "contains", + from: projectId, + to: taskId, + }); } } @@ -198,7 +233,14 @@ function collectGradleRepositoryContext( const to = projectIds.get(dependency) ?? (candidates.length === 1 ? candidates[0] : undefined); - if (to !== undefined) edges.push({ kind: "depends-on", from, to }); + if (to !== undefined) { + edges.push({ + authority: "tool-resolved", + kind: "depends-on", + from, + to, + }); + } else unresolvedDependencies += 1; } } diff --git a/packages/graph/src/repository/pnpmRepositoryContextProvider.ts b/packages/graph/src/repository/pnpmRepositoryContextProvider.ts index 70b6fe65..83d59053 100644 --- a/packages/graph/src/repository/pnpmRepositoryContextProvider.ts +++ b/packages/graph/src/repository/pnpmRepositoryContextProvider.ts @@ -97,6 +97,7 @@ function collectPnpmRepositoryContext( const nodes: ISamchonRepositoryContextDump.INode[] = [ { id: workspace, + authority: "tool-resolved", kind: "workspace", name: path.basename(props.root), ecosystem: ECOSYSTEM, @@ -131,6 +132,8 @@ function collectPnpmRepositoryContext( sources.push(repositoryContextSource(props.root, manifestFile)); nodes.push({ id: packageId, + authority: + manifest.name === undefined ? "tool-resolved" : "declared", kind: "package", name: manifest.name ?? entry.name ?? path.basename(absolute), ecosystem: ECOSYSTEM, @@ -139,7 +142,12 @@ function collectPnpmRepositoryContext( external: false, evidence: repositoryContextEvidence(props.root, manifestFile), }); - edges.push({ kind: "contains", from: workspace, to: packageId }); + edges.push({ + authority: "tool-resolved", + kind: "contains", + from: workspace, + to: packageId, + }); appendManifestFacts( props.root, absolute, @@ -165,7 +173,12 @@ function collectPnpmRepositoryContext( if (dependency.path === undefined) continue; const target = packageIds.get(path.resolve(dependency.path)); if (target !== undefined) { - edges.push({ kind: "depends-on", from, to: target }); + edges.push({ + authority: "tool-resolved", + kind: "depends-on", + from, + to: target, + }); } } } @@ -232,6 +245,7 @@ function appendManifestFacts( ); nodes.push({ id, + authority: "declared", kind: generated ? "generated-root" : "source-root", name: rootName, ecosystem: ECOSYSTEM, @@ -241,8 +255,10 @@ function appendManifestFacts( root: repositoryContextFile(root, path.resolve(packageRoot, rootName)), evidence, }); - edges.push({ kind: "contains", from: packageId, to: id }); - edges.push({ kind: "source-of", from: id, to: packageId }); + edges.push( + { authority: "declared", kind: "contains", from: packageId, to: id }, + { authority: "declared", kind: "source-of", from: id, to: packageId }, + ); } for (const [name, target] of entrypoints(manifest)) { const coordinate = `${repositoryContextFile(root, packageRoot)}:${name}`; @@ -255,6 +271,7 @@ function appendManifestFacts( files.add(file); nodes.push({ id, + authority: "declared", kind: "entrypoint", name, ecosystem: ECOSYSTEM, @@ -264,9 +281,16 @@ function appendManifestFacts( file, evidence, }); - edges.push({ kind: "contains", from: packageId, to: id }); - edges.push({ kind: "entrypoint-of", from: id, to: packageId }); - edges.push({ kind: "joins-file", from: id, to: file }); + edges.push( + { authority: "declared", kind: "contains", from: packageId, to: id }, + { + authority: "declared", + kind: "entrypoint-of", + from: id, + to: packageId, + }, + { authority: "declared", kind: "joins-file", from: id, to: file }, + ); } for (const name of Object.keys(manifest.scripts ?? {}).sort( compareRepositoryText, @@ -275,6 +299,7 @@ function appendManifestFacts( const id = repositoryContextId(ECOSYSTEM, "task", coordinate); nodes.push({ id, + authority: "declared", kind: "task", name, ecosystem: ECOSYSTEM, @@ -283,7 +308,12 @@ function appendManifestFacts( external: false, evidence, }); - edges.push({ kind: "contains", from: packageId, to: id }); + edges.push({ + authority: "declared", + kind: "contains", + from: packageId, + to: id, + }); } } diff --git a/packages/graph/src/structures/ISamchonRepositoryContextDump.ts b/packages/graph/src/structures/ISamchonRepositoryContextDump.ts index bda5187f..0650889d 100644 --- a/packages/graph/src/structures/ISamchonRepositoryContextDump.ts +++ b/packages/graph/src/structures/ISamchonRepositoryContextDump.ts @@ -80,6 +80,8 @@ export namespace ISamchonRepositoryContextDump { export interface INode { id: string; + /** Authority that establishes this exact node fact. */ + authority: RepositoryContextAuthority; kind: RepositoryContextNodeKind; name: string; ecosystem: string; @@ -94,6 +96,8 @@ export namespace ISamchonRepositoryContextDump { } export interface IEdge { + /** Authority that establishes this exact relation fact. */ + authority: RepositoryContextAuthority; kind: RepositoryContextRelationKind; from: string; to: string; diff --git a/tests/test-graph/src/features/test_mcp_topology_fences_file_joins_by_code_generation.ts b/tests/test-graph/src/features/test_mcp_topology_fences_file_joins_by_code_generation.ts index 11500fc7..122bc60f 100644 --- a/tests/test-graph/src/features/test_mcp_topology_fences_file_joins_by_code_generation.ts +++ b/tests/test-graph/src/features/test_mcp_topology_fences_file_joins_by_code_generation.ts @@ -85,6 +85,29 @@ export const test_mcp_topology_fences_file_joins_by_code_generation = [1, false], ); + const endpointBounded = await application.inspect_code_graph({ + question: "show the fixture workspace relation", + draft: { reason: "repository orientation", type: "topology" }, + review: "topology is the typed repository plane", + request: { + type: "topology", + query: "fixture", + relations: ["contains"], + limit: 1, + }, + }); + TestValidator.equals( + "dropping a relation endpoint at the node bound reports truncation", + endpointBounded.result.type === "topology" + ? [ + endpointBounded.result.nodes.length, + endpointBounded.result.edges.length, + endpointBounded.result.truncated, + ] + : [], + [1, 0, true], + ); + const legacy = SamchonGraphMemory.from({ ...fixture.dump, generation: undefined, @@ -166,16 +189,22 @@ export const test_mcp_topology_fences_file_joins_by_code_generation = }); TestValidator.equals( "an unavailable provider generation cannot claim join compatibility", - providerUnavailable.result.type === "topology" - ? providerUnavailable.result.join - : undefined, - { - state: "unavailable", - topologyInputGeneration: emptyTopology.dump.inputGeneration, - codeInputGeneration: input, - reason: - "No repository-context provider produced a compatible current generation.", - }, + [ + providerUnavailable.result.type === "topology" + ? providerUnavailable.result.join + : undefined, + providerUnavailable.next.reason, + ], + [ + { + state: "unavailable", + topologyInputGeneration: emptyTopology.dump.inputGeneration, + codeInputGeneration: input, + reason: + "No repository-context provider produced a compatible current generation.", + }, + "No repository topology node matched the requested query or available provider facts.", + ], ); await TestValidator.error( @@ -199,6 +228,7 @@ function topologyDump(project: string): ISamchonRepositoryContextDump { const nodes: ISamchonRepositoryContextDump.INode[] = [ { id: workspace, + authority: "declared", kind: "workspace", name: "fixture", ecosystem: "fixture", @@ -208,6 +238,7 @@ function topologyDump(project: string): ISamchonRepositoryContextDump { }, { id: source, + authority: "declared", kind: "source-root", name: "source", ecosystem: "fixture", @@ -217,9 +248,24 @@ function topologyDump(project: string): ISamchonRepositoryContextDump { }, ]; const edges: ISamchonRepositoryContextDump.IEdge[] = [ - { kind: "contains", from: workspace, to: source }, - { kind: "joins-file", from: source, to: "src/contract.ts" }, - { kind: "joins-file", from: source, to: "src/not-indexed.ts" }, + { + authority: "declared", + kind: "contains", + from: workspace, + to: source, + }, + { + authority: "declared", + kind: "joins-file", + from: source, + to: "src/contract.ts", + }, + { + authority: "declared", + kind: "joins-file", + from: source, + to: "src/not-indexed.ts", + }, ]; const coverage = repositoryContextCoverage( "fixture-context", diff --git a/tests/test-graph/src/features/test_repository_context_adapters_preserve_authoritative_models.ts b/tests/test-graph/src/features/test_repository_context_adapters_preserve_authoritative_models.ts index 54c75119..ed955f97 100644 --- a/tests/test-graph/src/features/test_repository_context_adapters_preserve_authoritative_models.ts +++ b/tests/test-graph/src/features/test_repository_context_adapters_preserve_authoritative_models.ts @@ -98,6 +98,14 @@ export const test_repository_context_adapters_preserve_authoritative_models = coverage: 8, }, ); + TestValidator.equals( + "pnpm distinguishes tool-resolved workspace facts from declared manifest facts", + authoritySummary(pnpm), + { + nodes: { declared: 8, "tool-resolved": 1 }, + edges: { declared: 13, "tool-resolved": 3 }, + }, + ); TestValidator.equals( "Cargo preserves packages, targets, dependencies, tests and source joins", summarize(cargo), @@ -1185,8 +1193,16 @@ function exerciseCmakeRefusals(root: string): void { const objectReply = cmakeScenario(root, "object-references", { index: { objects: [ - { kind: "codemodel", jsonFile: "codemodel.json" }, - { kind: "cmakeFiles", jsonFile: "cmakeFiles.json" }, + { + kind: "codemodel", + version: { major: 2, minor: 8 }, + jsonFile: "codemodel.json", + }, + { + kind: "cmakeFiles", + version: { major: 1, minor: 1 }, + jsonFile: "cmakeFiles.json", + }, ], }, configurations: [ @@ -1244,6 +1260,70 @@ function exerciseCmakeRefusals(root: string): void { ], ["", "default", true], ); + + const wrongObjectVersions = cmakeScenario( + root, + "wrong-object-versions", + { + index: { + objects: [ + { + kind: "codemodel", + version: { major: 1, minor: 0 }, + jsonFile: "codemodel.json", + }, + { + kind: "cmakeFiles", + jsonFile: "cmakeFiles.json", + }, + ], + }, + configurations: [ + { name: "", projects: [], directories: [], targets: [] }, + ], + }, + ); + TestValidator.error( + "CMake object references must declare the requested reply major versions", + () => + cmakeRepositoryContextProvider.collect({ + root, + env: { + ...process.env, + SAMCHON_GRAPH_CMAKE_REPLY: wrongObjectVersions, + }, + }), + ); + + const includedInputReply = cmakeScenario(root, "included-input", { + configurations: [ + { name: "", projects: [], directories: [], targets: [] }, + ], + inputs: [ + { path: "CMakeLists.txt" }, + { path: "cmake/options.cmake" }, + ], + }); + const includedInput = path.join( + root, + "cmake-included-input", + "cmake", + "options.cmake", + ); + write(includedInput, "set(FIXTURE_OPTION ON)\n"); + const future = new Date(Date.now() + 2_000); + fs.utimesSync(includedInput, future, future); + TestValidator.error( + "CMake refuses a File API model older than any owning cmakeFiles input", + () => + cmakeRepositoryContextProvider.collect({ + root, + env: { + ...process.env, + SAMCHON_GRAPH_CMAKE_REPLY: includedInputReply, + }, + }), + ); } function cmakeScenario( @@ -1253,6 +1333,7 @@ function cmakeScenario( index?: Record; configurations: Array>; targets?: Record>; + inputs?: Array<{ path: string }>; }, ): string { const source = path.join(root, `cmake-${name}`); @@ -1261,7 +1342,7 @@ function cmakeScenario( write(path.join(source, "CMakeLists.txt"), `project(${name})\n`); writeJson(path.join(reply, "cmakeFiles.json"), { paths: { source, build }, - inputs: [{ path: "CMakeLists.txt" }], + inputs: options.inputs ?? [{ path: "CMakeLists.txt" }], }); writeJson(path.join(reply, "codemodel.json"), { paths: { source, build }, @@ -1316,3 +1397,26 @@ function write(file: string, content: string): void { function writeJson(file: string, value: unknown): void { write(file, JSON.stringify(value)); } + +function authoritySummary( + collection: ReturnType, +): { + nodes: Record; + edges: Record; +} { + const count = ( + rows: readonly { authority: string }[], + ): Record => + Object.fromEntries( + [...rows] + .reduce((output, row) => { + output.set(row.authority, (output.get(row.authority) ?? 0) + 1); + return output; + }, new Map()) + .entries(), + ); + return { + nodes: count(collection.shards.flatMap((shard) => shard.nodes)), + edges: count(collection.shards.flatMap((shard) => shard.edges)), + }; +} diff --git a/tests/test-graph/src/features/test_repository_context_protocol_commits_atomic_shards.ts b/tests/test-graph/src/features/test_repository_context_protocol_commits_atomic_shards.ts index 544787d0..45f37adb 100644 --- a/tests/test-graph/src/features/test_repository_context_protocol_commits_atomic_shards.ts +++ b/tests/test-graph/src/features/test_repository_context_protocol_commits_atomic_shards.ts @@ -101,6 +101,12 @@ export const test_repository_context_protocol_commits_atomic_shards = (upsert.shard.nodes[0] as { kind: string }).kind = "solution"; refresh(frames); }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const upsert = frames[2] as RepositoryContextProtocol.IUpsertShard; + (upsert.shard.nodes[0] as { authority: string }).authority = + "guessed"; + refresh(frames); + }), mutate(transaction(3, unchanged, changedShard()), (frames) => { const upsert = frames[2] as RepositoryContextProtocol.IUpsertShard; (upsert.shard.coverage[0] as { state: string }).state = "unknown"; @@ -205,6 +211,12 @@ export const test_repository_context_protocol_commits_atomic_shards = const upsert = changedUpsert(frames); (upsert.shard.edges[0] as { kind: string }).kind = "invokes"; }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const upsert = changedUpsert(frames); + (upsert.shard.edges[0] as { authority: string }).authority = + "guessed"; + refresh(frames); + }), mutate(transaction(3, unchanged, changedShard()), (frames) => { const upsert = changedUpsert(frames); upsert.shard.edges[0]!.from = ""; @@ -435,6 +447,7 @@ function validShard(): RepositoryContextProtocol.IShard { nodes: [ { id: workspace, + authority: "declared", kind: "workspace", name: "fixture", ecosystem: "fixture", @@ -444,6 +457,7 @@ function validShard(): RepositoryContextProtocol.IShard { }, { id: source, + authority: "declared", kind: "source-root", name: "src", ecosystem: "fixture", @@ -453,8 +467,18 @@ function validShard(): RepositoryContextProtocol.IShard { }, ], edges: [ - { kind: "contains", from: workspace, to: source }, - { kind: "joins-file", from: source, to: "src/main.ts" }, + { + authority: "declared", + kind: "contains", + from: workspace, + to: source, + }, + { + authority: "declared", + kind: "joins-file", + from: source, + to: "src/main.ts", + }, ], coverage: repositoryContextCoverage( "fixture-context", @@ -481,6 +505,7 @@ function secondaryShard(): RepositoryContextProtocol.IShard { nodes: [ { id: project, + authority: "declared", kind: "project", name: "secondary", ecosystem: "fixture", diff --git a/tests/test-graph/src/features/test_resident_repository_context_is_atomic_and_retryable.ts b/tests/test-graph/src/features/test_resident_repository_context_is_atomic_and_retryable.ts index 2e849c30..cdfb46fd 100644 --- a/tests/test-graph/src/features/test_resident_repository_context_is_atomic_and_retryable.ts +++ b/tests/test-graph/src/features/test_resident_repository_context_is_atomic_and_retryable.ts @@ -252,6 +252,74 @@ export const test_resident_repository_context_is_atomic_and_retryable = ); await disagreeingResident.close(); + const midRefreshAbort = new AbortController(); + const leftInitial = retargetSnapshot( + canonicalSnapshot, + "left-initial", + "left initial", + "1", + 1, + ); + const leftAdvanced = retargetSnapshot( + canonicalSnapshot, + "left-advanced", + "left advanced", + "2", + 2, + ); + const rightInitial = retargetSnapshot( + canonicalSnapshot, + "right-initial", + "right initial", + "3", + 1, + ); + const rightAdvanced = retargetSnapshot( + canonicalSnapshot, + "right-advanced", + "right advanced", + "4", + 2, + ); + const interruptedResident = createResidentRepositoryContextSource( + root, + process.env, + [ + advancingSnapshotProvider( + "advancing-left", + leftInitial, + leftAdvanced, + ), + advancingSnapshotProvider( + "advancing-right", + rightInitial, + rightAdvanced, + midRefreshAbort, + ), + ], + ); + const beforeInterruption = await interruptedResident.load(); + await TestValidator.error( + "a cancellation after one provider advances rejects the composite generation", + () => + interruptedResident.load({ signal: midRefreshAbort.signal }), + ); + const afterInterruption = await interruptedResident.load(); + TestValidator.equals( + "a retry publishes provider states committed before the cancelled composite refresh", + [ + sourceNames(beforeInterruption), + sourceNames(afterInterruption), + afterInterruption.generation.sequence, + ], + [ + ["left initial", "right initial"], + ["left advanced", "right advanced"], + 2, + ], + ); + await interruptedResident.close(); + const closeFailure = createResidentRepositoryContextSource( root, process.env, @@ -343,6 +411,7 @@ export const test_resident_repository_context_is_atomic_and_retryable = nodes: [ { id: repositoryContextId("fixture", "project", "secondary"), + authority: "declared", kind: "project", name: "secondary", ecosystem: "fixture", @@ -467,6 +536,102 @@ function snapshotProvider( }; } +function advancingSnapshotProvider( + name: string, + initial: RepositoryContextProtocol.ISnapshot, + advanced: RepositoryContextProtocol.ISnapshot, + abort?: AbortController, +): IRepositoryContextProvider { + return { + name, + ecosystem: "fixture", + authority: "declared", + families: ["contains", "joins-file"], + buildInputs: [], + detect: () => true, + open: ({ root }) => { + let calls = 0; + let current = initial; + return { + kind: "repository-context", + provider: name, + ecosystem: "fixture", + root, + get generation() { + return current.generation.sequence; + }, + get current() { + return current; + }, + refresh: async () => { + calls += 1; + if (calls === 1) { + return refreshResult(initial, true); + } + if (calls === 2) { + current = advanced; + if (abort !== undefined) { + abort.abort(); + throw new Error("fixture cancelled after provider commit"); + } + return refreshResult(advanced, true); + } + return refreshResult(current, false); + }, + close: async () => {}, + }; + }, + }; +} + +function refreshResult( + snapshot: RepositoryContextProtocol.ISnapshot, + changed: boolean, +) { + return { + changed, + generation: snapshot.generation.sequence, + mode: "full" as const, + snapshot, + warnings: [], + }; +} + +function retargetSnapshot( + input: RepositoryContextProtocol.ISnapshot, + key: string, + sourceName: string, + digestCharacter: string, + sequence: number, +): RepositoryContextProtocol.ISnapshot { + const snapshot = structuredClone(input); + const identities = new Map( + snapshot.nodes.map((node) => [node.id, `${node.id}:${key}`]), + ); + for (const node of snapshot.nodes) { + node.id = identities.get(node.id)!; + if (node.kind === "source-root") node.name = sourceName; + } + for (const edge of snapshot.edges) { + edge.from = identities.get(edge.from) ?? edge.from; + edge.to = identities.get(edge.to) ?? edge.to; + } + snapshot.sources = [ + { + file: `${key}.json`, + digest: digestCharacter.repeat(64), + }, + ]; + snapshot.hello.provider = key; + snapshot.begin.sequence = sequence; + snapshot.begin.inputGeneration = digestCharacter.repeat(64); + snapshot.begin.manifest = digestCharacter.repeat(64); + snapshot.generation.sequence = sequence; + snapshot.generation.token = digestCharacter.repeat(64); + snapshot.generation.contentDigest = digestCharacter.repeat(64); + return snapshot; +} + function collection( root: string, model: { name: string; file: string }, @@ -479,6 +644,7 @@ function collection( nodes: [ { id: workspace, + authority: "declared", kind: "workspace" as const, name: "fixture", ecosystem: "fixture", @@ -488,6 +654,7 @@ function collection( }, { id: source, + authority: "declared", kind: "source-root" as const, name: model.name, ecosystem: "fixture", @@ -497,8 +664,18 @@ function collection( }, ], edges: [ - { kind: "contains" as const, from: workspace, to: source }, - { kind: "joins-file" as const, from: source, to: model.file }, + { + authority: "declared" as const, + kind: "contains" as const, + from: workspace, + to: source, + }, + { + authority: "declared" as const, + kind: "joins-file" as const, + from: source, + to: model.file, + }, ], coverage: repositoryContextCoverage( "fixture-context", @@ -534,3 +711,12 @@ function sourceName(snapshot: { }): string | undefined { return snapshot.nodes.find((node) => node.kind === "source-root")?.name; } + +function sourceNames(snapshot: { + nodes: readonly { kind: string; name: string }[]; +}): string[] { + return snapshot.nodes + .filter((node) => node.kind === "source-root") + .map((node) => node.name) + .sort(); +} From 15fc4d558b3900ff6c9770799657edfb6f95293c Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 31 Jul 2026 18:00:31 +0900 Subject: [PATCH 12/52] fix: preserve bounded topology truth Reject inferred v1 facts and stale missing CMake inputs, retain exact-query seeds, and describe every truncation boundary. --- .../repository/RepositoryContextProtocol.ts | 10 ++++++ .../SamchonRepositoryContextMemory.ts | 13 +++++--- .../cmakeRepositoryContextProvider.ts | 7 +++- .../src/structures/ISamchonGraphTopology.ts | 2 +- ...gy_fences_file_joins_by_code_generation.ts | 7 ++-- ..._adapters_preserve_authoritative_models.ts | 32 +++++++++++++++++++ ..._context_protocol_commits_atomic_shards.ts | 10 ++++++ 7 files changed, 72 insertions(+), 9 deletions(-) diff --git a/packages/graph/src/repository/RepositoryContextProtocol.ts b/packages/graph/src/repository/RepositoryContextProtocol.ts index c4f3528c..3d75f2a3 100644 --- a/packages/graph/src/repository/RepositoryContextProtocol.ts +++ b/packages/graph/src/repository/RepositoryContextProtocol.ts @@ -456,6 +456,11 @@ export namespace RepositoryContextProtocol { `repository context protocol: unknown node authority ${node.id}`, ); } + if (node.authority === "inferred") { + throw new Error( + `repository context protocol: version 1 refuses inferred node authority ${node.id}`, + ); + } if (node.root !== undefined) { assertText(node.root, "node root"); if ( @@ -480,6 +485,11 @@ export namespace RepositoryContextProtocol { `repository context protocol: unknown edge authority ${edge.kind}`, ); } + if (edge.authority === "inferred") { + throw new Error( + `repository context protocol: version 1 refuses inferred edge authority ${edge.kind}`, + ); + } if (!hello.supportedFamilies.includes(edge.kind)) { throw new Error( `repository context protocol: unadvertised edge family ${edge.kind}`, diff --git a/packages/graph/src/repository/SamchonRepositoryContextMemory.ts b/packages/graph/src/repository/SamchonRepositoryContextMemory.ts index a1f66ca0..df26ef8b 100644 --- a/packages/graph/src/repository/SamchonRepositoryContextMemory.ts +++ b/packages/graph/src/repository/SamchonRepositoryContextMemory.ts @@ -45,7 +45,8 @@ export class SamchonRepositoryContextMemory { node.name.toLowerCase().includes(query) || node.coordinate.toLowerCase().includes(query), ); - const selected = new Set(seeds.slice(0, limit).map((node) => node.id)); + const boundedSeeds = seeds.slice(0, limit); + const selected = new Set(boundedSeeds.map((node) => node.id)); const matchingEdges = availableEdges.filter( (edge) => (families === undefined || families.has(edge.kind)) && @@ -65,9 +66,13 @@ export class SamchonRepositoryContextMemory { if (this.nodesById.has(edge.from)) selected.add(edge.from); if (this.nodesById.has(edge.to)) selected.add(edge.to); } - const nodes = this.dump.nodes - .filter((node) => selected.has(node.id)) - .slice(0, limit); + const seedIds = new Set(boundedSeeds.map((node) => node.id)); + const nodes = [ + ...boundedSeeds, + ...this.dump.nodes.filter( + (node) => selected.has(node.id) && !seedIds.has(node.id), + ), + ].slice(0, limit); const retained = new Set(nodes.map((node) => node.id)); const retainedEdges = edges.filter( (edge) => diff --git a/packages/graph/src/repository/cmakeRepositoryContextProvider.ts b/packages/graph/src/repository/cmakeRepositoryContextProvider.ts index f8d41b73..6945bc40 100644 --- a/packages/graph/src/repository/cmakeRepositoryContextProvider.ts +++ b/packages/graph/src/repository/cmakeRepositoryContextProvider.ts @@ -518,7 +518,12 @@ function assertCmakeReplyFresh( const replyTime = fs.statSync(indexFile).mtimeMs; for (const source of sources) { const file = path.resolve(root, source.file); - if (fs.existsSync(file) && fs.statSync(file).mtimeMs > replyTime) { + if (!fs.existsSync(file)) { + throw new Error( + `CMake File API input ${source.file} is missing; reconfigure the project before repository-context indexing.`, + ); + } + if (fs.statSync(file).mtimeMs > replyTime) { throw new Error( `CMake File API reply predates ${source.file}; reconfigure the project before repository-context indexing.`, ); diff --git a/packages/graph/src/structures/ISamchonGraphTopology.ts b/packages/graph/src/structures/ISamchonGraphTopology.ts index 62710c40..c39c8cd8 100644 --- a/packages/graph/src/structures/ISamchonGraphTopology.ts +++ b/packages/graph/src/structures/ISamchonGraphTopology.ts @@ -29,7 +29,7 @@ export interface ISamchonGraphTopology { /** Whether this result may join its file identities to the code generation. */ join: ISamchonGraphTopology.IJoin; - /** Whether more matching nodes existed beyond the requested limit. */ + /** Whether a requested node, relation or file-join bound omitted facts. */ truncated: boolean; } diff --git a/tests/test-graph/src/features/test_mcp_topology_fences_file_joins_by_code_generation.ts b/tests/test-graph/src/features/test_mcp_topology_fences_file_joins_by_code_generation.ts index 122bc60f..1d976700 100644 --- a/tests/test-graph/src/features/test_mcp_topology_fences_file_joins_by_code_generation.ts +++ b/tests/test-graph/src/features/test_mcp_topology_fences_file_joins_by_code_generation.ts @@ -86,12 +86,12 @@ export const test_mcp_topology_fences_file_joins_by_code_generation = ); const endpointBounded = await application.inspect_code_graph({ - question: "show the fixture workspace relation", + question: "show the source relation", draft: { reason: "repository orientation", type: "topology" }, review: "topology is the typed repository plane", request: { type: "topology", - query: "fixture", + query: "source", relations: ["contains"], limit: 1, }, @@ -101,11 +101,12 @@ export const test_mcp_topology_fences_file_joins_by_code_generation = endpointBounded.result.type === "topology" ? [ endpointBounded.result.nodes.length, + endpointBounded.result.nodes[0]?.name, endpointBounded.result.edges.length, endpointBounded.result.truncated, ] : [], - [1, 0, true], + [1, "source", 0, true], ); const legacy = SamchonGraphMemory.from({ diff --git a/tests/test-graph/src/features/test_repository_context_adapters_preserve_authoritative_models.ts b/tests/test-graph/src/features/test_repository_context_adapters_preserve_authoritative_models.ts index ed955f97..a7b5b6d1 100644 --- a/tests/test-graph/src/features/test_repository_context_adapters_preserve_authoritative_models.ts +++ b/tests/test-graph/src/features/test_repository_context_adapters_preserve_authoritative_models.ts @@ -1324,6 +1324,38 @@ function exerciseCmakeRefusals(root: string): void { }, }), ); + + const renamedInput = path.join( + root, + "cmake-renamed-input", + "cmake", + "options.cmake", + ); + write(renamedInput, "set(FIXTURE_OPTION ON)\n"); + const renamedInputReply = cmakeScenario(root, "renamed-input", { + configurations: [ + { name: "", projects: [], directories: [], targets: [] }, + ], + inputs: [ + { path: "CMakeLists.txt" }, + { path: "cmake/options.cmake" }, + ], + }); + fs.renameSync( + renamedInput, + path.join(path.dirname(renamedInput), "renamed-options.cmake"), + ); + TestValidator.error( + "CMake refuses a File API model whose owning input was renamed or deleted", + () => + cmakeRepositoryContextProvider.collect({ + root, + env: { + ...process.env, + SAMCHON_GRAPH_CMAKE_REPLY: renamedInputReply, + }, + }), + ); } function cmakeScenario( diff --git a/tests/test-graph/src/features/test_repository_context_protocol_commits_atomic_shards.ts b/tests/test-graph/src/features/test_repository_context_protocol_commits_atomic_shards.ts index 45f37adb..54ce92e0 100644 --- a/tests/test-graph/src/features/test_repository_context_protocol_commits_atomic_shards.ts +++ b/tests/test-graph/src/features/test_repository_context_protocol_commits_atomic_shards.ts @@ -107,6 +107,11 @@ export const test_repository_context_protocol_commits_atomic_shards = "guessed"; refresh(frames); }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const upsert = frames[2] as RepositoryContextProtocol.IUpsertShard; + upsert.shard.nodes[0]!.authority = "inferred"; + refresh(frames); + }), mutate(transaction(3, unchanged, changedShard()), (frames) => { const upsert = frames[2] as RepositoryContextProtocol.IUpsertShard; (upsert.shard.coverage[0] as { state: string }).state = "unknown"; @@ -217,6 +222,11 @@ export const test_repository_context_protocol_commits_atomic_shards = "guessed"; refresh(frames); }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const upsert = changedUpsert(frames); + upsert.shard.edges[0]!.authority = "inferred"; + refresh(frames); + }), mutate(transaction(3, unchanged, changedShard()), (frames) => { const upsert = changedUpsert(frames); upsert.shard.edges[0]!.from = ""; From 2f3a8b22fcd13c2ff0bf81e51d89788048069989 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 31 Jul 2026 18:54:48 +0900 Subject: [PATCH 13/52] fix: enforce exact topology freshness --- .../SamchonRepositoryContextMemory.ts | 4 +- .../cmakeRepositoryContextProvider.ts | 30 ++++++++++--- ...gy_fences_file_joins_by_code_generation.ts | 15 +++++++ ..._adapters_preserve_authoritative_models.ts | 42 +++++++++++++++++++ 4 files changed, 83 insertions(+), 8 deletions(-) diff --git a/packages/graph/src/repository/SamchonRepositoryContextMemory.ts b/packages/graph/src/repository/SamchonRepositoryContextMemory.ts index df26ef8b..4508b8a9 100644 --- a/packages/graph/src/repository/SamchonRepositoryContextMemory.ts +++ b/packages/graph/src/repository/SamchonRepositoryContextMemory.ts @@ -42,8 +42,8 @@ export class SamchonRepositoryContextMemory { : this.dump.nodes.filter( (node) => node.id.toLowerCase() === query || - node.name.toLowerCase().includes(query) || - node.coordinate.toLowerCase().includes(query), + node.name.toLowerCase() === query || + node.coordinate.toLowerCase() === query, ); const boundedSeeds = seeds.slice(0, limit); const selected = new Set(boundedSeeds.map((node) => node.id)); diff --git a/packages/graph/src/repository/cmakeRepositoryContextProvider.ts b/packages/graph/src/repository/cmakeRepositoryContextProvider.ts index 6945bc40..378338a6 100644 --- a/packages/graph/src/repository/cmakeRepositoryContextProvider.ts +++ b/packages/graph/src/repository/cmakeRepositoryContextProvider.ts @@ -190,16 +190,15 @@ function objectReference( index: ICmakeIndex, kind: string, major: number, - replyPrefix: string, + replyKey: string, ): string | undefined { return ( index.objects?.find( (entry) => entry.kind === kind && entry.version?.major === major, )?.jsonFile ?? - Object.entries(index.reply ?? {}).find(([key]) => - key.startsWith(replyPrefix), - )?.[1].jsonFile + Object.entries(index.reply ?? {}).find(([key]) => key === replyKey)?.[1] + .jsonFile ); } @@ -569,12 +568,31 @@ function locateReply( function latestIndex(reply: string): string { const files = fs .readdirSync(reply) - .filter((file) => /^index-.*\.json$/.test(file)) - .sort(compareRepositoryText); + .filter((file) => /^(?:index|error)-.*\.json$/.test(file)) + .map((file) => ({ + file, + generation: file.slice(file.indexOf("-") + 1), + })) + .sort((left, right) => { + const generation = compareRepositoryText( + left.generation, + right.generation, + ); + return generation !== 0 + ? generation + : Number(left.file.startsWith("error-")) - + Number(right.file.startsWith("error-")); + }) + .map((entry) => entry.file); const latest = files.at(-1); if (latest === undefined) { throw new Error("CMake File API reply directory has no index"); } + if (latest.startsWith("error-")) { + throw new Error( + `CMake File API latest reply reports a configuration error: ${latest}`, + ); + } return path.join(reply, latest); } diff --git a/tests/test-graph/src/features/test_mcp_topology_fences_file_joins_by_code_generation.ts b/tests/test-graph/src/features/test_mcp_topology_fences_file_joins_by_code_generation.ts index 1d976700..01b4b7e2 100644 --- a/tests/test-graph/src/features/test_mcp_topology_fences_file_joins_by_code_generation.ts +++ b/tests/test-graph/src/features/test_mcp_topology_fences_file_joins_by_code_generation.ts @@ -225,6 +225,11 @@ export const test_mcp_topology_fences_file_joins_by_code_generation = function topologyDump(project: string): ISamchonRepositoryContextDump { const workspace = repositoryContextId("fixture", "workspace", "."); + const sourceHelper = repositoryContextId( + "fixture", + "source-root", + "source-helper", + ); const source = repositoryContextId("fixture", "source-root", "src"); const nodes: ISamchonRepositoryContextDump.INode[] = [ { @@ -237,6 +242,16 @@ function topologyDump(project: string): ISamchonRepositoryContextDump { configuration: "default", external: false, }, + { + id: sourceHelper, + authority: "declared", + kind: "source-root", + name: "source-helper", + ecosystem: "fixture", + coordinate: "source-helper", + configuration: "default", + external: false, + }, { id: source, authority: "declared", diff --git a/tests/test-graph/src/features/test_repository_context_adapters_preserve_authoritative_models.ts b/tests/test-graph/src/features/test_repository_context_adapters_preserve_authoritative_models.ts index a7b5b6d1..8add7c76 100644 --- a/tests/test-graph/src/features/test_repository_context_adapters_preserve_authoritative_models.ts +++ b/tests/test-graph/src/features/test_repository_context_adapters_preserve_authoritative_models.ts @@ -1134,6 +1134,48 @@ function exerciseCmakeRefusals(root: string): void { }), ); + const wrongReplyVersions = cmakeScenario(root, "wrong-reply-versions", { + index: { + reply: { + "codemodel-v20": { jsonFile: "codemodel.json" }, + "cmakeFiles-v10": { jsonFile: "cmakeFiles.json" }, + }, + }, + configurations: [{ name: "", projects: [], directories: [], targets: [] }], + }); + TestValidator.error( + "CMake stateless reply keys must match the requested major versions exactly", + () => + cmakeRepositoryContextProvider.collect({ + root, + env: { + ...process.env, + SAMCHON_GRAPH_CMAKE_REPLY: wrongReplyVersions, + }, + }), + ); + + const failedReply = cmakeScenario(root, "failed-latest-reply", { + configurations: [{ name: "", projects: [], directories: [], targets: [] }], + }); + writeJson(path.join(failedReply, "error-1.json"), { + error: "fixture same-generation failure", + }); + writeJson(path.join(failedReply, "error-9999.json"), { + error: "fixture configure failed", + }); + TestValidator.error( + "CMake refuses an error reply newer than the last successful index", + () => + cmakeRepositoryContextProvider.collect({ + root, + env: { + ...process.env, + SAMCHON_GRAPH_CMAKE_REPLY: failedReply, + }, + }), + ); + const emptyConfigurations = cmakeScenario(root, "empty-configurations", { configurations: [], }); From da540fdc525cf5a459aacb92e95b2af27d649f63 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 31 Jul 2026 19:11:06 +0900 Subject: [PATCH 14/52] fix: preserve exact topology case --- .../repository/SamchonRepositoryContextMemory.ts | 8 ++++---- ...pology_fences_file_joins_by_code_generation.ts | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/graph/src/repository/SamchonRepositoryContextMemory.ts b/packages/graph/src/repository/SamchonRepositoryContextMemory.ts index 4508b8a9..a907cdd1 100644 --- a/packages/graph/src/repository/SamchonRepositoryContextMemory.ts +++ b/packages/graph/src/repository/SamchonRepositoryContextMemory.ts @@ -31,7 +31,7 @@ export class SamchonRepositoryContextMemory { request.relations === undefined || request.relations.length === 0 ? undefined : new Set(request.relations); - const query = request.query?.trim().toLowerCase(); + const query = request.query?.trim(); const availableEdges = join.state === "compatible" ? withCodeJoins(this.dump.edges, this.dump.nodes, codeFiles) @@ -41,9 +41,9 @@ export class SamchonRepositoryContextMemory { ? this.dump.nodes : this.dump.nodes.filter( (node) => - node.id.toLowerCase() === query || - node.name.toLowerCase() === query || - node.coordinate.toLowerCase() === query, + node.id === query || + node.name === query || + node.coordinate === query, ); const boundedSeeds = seeds.slice(0, limit); const selected = new Set(boundedSeeds.map((node) => node.id)); diff --git a/tests/test-graph/src/features/test_mcp_topology_fences_file_joins_by_code_generation.ts b/tests/test-graph/src/features/test_mcp_topology_fences_file_joins_by_code_generation.ts index 01b4b7e2..9a70ad9e 100644 --- a/tests/test-graph/src/features/test_mcp_topology_fences_file_joins_by_code_generation.ts +++ b/tests/test-graph/src/features/test_mcp_topology_fences_file_joins_by_code_generation.ts @@ -230,6 +230,11 @@ function topologyDump(project: string): ISamchonRepositoryContextDump { "source-root", "source-helper", ); + const upperSource = repositoryContextId( + "fixture", + "source-root", + "Source", + ); const source = repositoryContextId("fixture", "source-root", "src"); const nodes: ISamchonRepositoryContextDump.INode[] = [ { @@ -252,6 +257,16 @@ function topologyDump(project: string): ISamchonRepositoryContextDump { configuration: "default", external: false, }, + { + id: upperSource, + authority: "declared", + kind: "source-root", + name: "Source", + ecosystem: "fixture", + coordinate: "Source", + configuration: "default", + external: false, + }, { id: source, authority: "declared", From efae059ad9687fe17d97d11ad3d85002767263e8 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 31 Jul 2026 19:35:21 +0900 Subject: [PATCH 15/52] docs: publish provider support truth Close #143: [Docs] The README documents two strict providers while the registry ships eleven --- .github/workflows/test.yml | 3 + README.md | 140 ++++- docs/provider-support.json | 314 ++++++++++ package.json | 2 + packages/graph/build/provider-support.mjs | 559 ++++++++++++++++++ packages/graph/package.json | 4 +- packages/graph/src/provider/IGraphProvider.ts | 15 + .../graph/src/provider/go/goGraphProvider.ts | 42 +- .../src/provider/lua/luaGraphProvider.ts | 23 +- .../src/provider/rust/rustScipProvider.ts | 109 +++- .../provider/scip/standardScipProviders.ts | 243 ++++---- .../src/provider/sidecar/sidecarProvider.ts | 4 + .../ttscgraph/resolveTtscGraphCommand.ts | 25 +- .../provider/ttscgraph/ttscGraphProvider.ts | 2 + .../provider/ttscgraph/ttscGraphResolution.ts | 6 + ..._manifest_matches_registry_and_evidence.ts | 87 +++ ...me_names_a_published_ttsc_install_range.ts | 6 +- 17 files changed, 1405 insertions(+), 179 deletions(-) create mode 100644 docs/provider-support.json create mode 100644 packages/graph/build/provider-support.mjs create mode 100644 packages/graph/src/provider/ttscgraph/ttscGraphResolution.ts create mode 100644 tests/test-graph/src/features/test_provider_support_manifest_matches_registry_and_evidence.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a04be1e0..b299d5be 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -81,6 +81,9 @@ jobs: - name: Build run: pnpm run build + - name: Check provider support + run: pnpm provider-support + - name: Test Go sidecar working-directory: sidecars/go run: go test ./... diff --git a/README.md b/README.md index 5d313370..5d85ce25 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ `@samchon/graph` is an MCP server that gives AI agents a code graph instead of source files. -It indexes a codebase in 16 languages into a graph of declarations and their relationships, and answers an agent's code questions from that index through a single tool. A compiler-owned provider supplies semantic edges where one is available, then the language server, and finally the separately packaged `@samchon/graph-sitter` best-effort fallback. +It indexes a codebase in 16 languages into a graph of declarations and the relationships each selected provider can defend, then answers an agent's code questions from that index through a single tool. Registered strict providers have compiler, analyzer, or semantic-index authority and may decline a build; the ordinary language server and separately packaged `@samchon/graph-sitter` remain explicit lower-authority fallbacks. Coding agents normally answer a code question by grepping the repository and reading file after file into context, and that reading is most of the token bill. The graph removes the need for it, and its own answers stay small in turn: they carry names, signatures, relationships, and source spans, never file bodies. @@ -41,7 +41,7 @@ A language server improves the graph with semantically resolved edges. Install t | Language | Server | Install | |---|---|---| -| TypeScript | `ttscgraph` / `ttscserver` | `npm i -D ttsc@^0.20.1 typescript` | +| TypeScript | `ttscserver` | `npm i -D ttsc@^0.23.0 typescript` | | Python | `pyright-langserver` | `npm i -D pyright` | | Go | `gopls` | `go install golang.org/x/tools/gopls@latest` | | Rust | `rust-analyzer` | `rustup component add rust-analyzer` | @@ -59,6 +59,114 @@ A language server improves the graph with semantically resolved edges. Install t Each server must be on `PATH`. If none is present for a file's language, that language falls back to the static indexer automatically. + +### Strict provider support + +_Generated from [`docs/provider-support.json`](https://github.com/samchon/compiler-graph/blob/master/docs/provider-support.json); do not edit this block by hand._ + +Strict selection is per registered provider and may decline for missing tools, incompatible options, or incomplete build metadata. Authority grades differ. A provider's `facts` list means it can defend those edge families; it is not a universal-completeness claim. Strict dumps carry provider/tool provenance plus universe, input-manifest, and content digests. The MCP result reports operation coverage and uncertainty, but does not promise #63's future complete producer-owned per-generation coverage contract. Generic language-server and static fallbacks remain valid lower-authority results and are identified as such. + +#### Capability + +| Provider | Languages | Authority | Defensible facts | Evidence | +| --- | --- | --- | --- | --- | +| `ttscgraph` | `typescript` | `compiler` | `exports`, `calls`, `accesses`, `instantiates`, `type_ref`, `extends`, `implements`, `overrides`, `renders` | [upstream](https://github.com/samchon/ttsc) / [route #63](https://github.com/samchon/compiler-graph/issues/63) | +| `samchon-graph-go` | `go` | `compiler` | `contains`, `exports`, `imports`, `calls`, `accesses`, `instantiates`, `type_ref`, `implements`, `dispatches`, `tests`, `references` | [upstream](https://github.com/scip-code/scip-go) / [route #63](https://github.com/samchon/compiler-graph/issues/63) | +| `samchon-graph-lua` | `lua` | `analyzer` | `references` | [upstream](https://github.com/LuaLS/lua-language-server) / [route #83](https://github.com/samchon/compiler-graph/issues/83) | +| `rust-analyzer-scip` | `rust` | `semantic-index` | `contains`, `references` | [upstream](https://github.com/rust-lang/rust-analyzer) / [route #72](https://github.com/samchon/compiler-graph/issues/72) | +| `scip-clang` | `c`, `cpp` | `semantic-index` | **none** | [upstream](https://github.com/sourcegraph/scip-clang) / [route #73](https://github.com/samchon/compiler-graph/issues/73) | +| `scip-java` | `java`, `kotlin` | `semantic-index` | `contains`, `references` | [upstream](https://github.com/scip-code/scip-java) / [route #74](https://github.com/samchon/compiler-graph/issues/74) / [route #76](https://github.com/samchon/compiler-graph/issues/76) | +| `scip-dotnet` | `csharp` | `semantic-index` | **none** | [upstream](https://github.com/sourcegraph/scip-dotnet) / [route #75](https://github.com/samchon/compiler-graph/issues/75) | +| `scip-python` | `python` | `semantic-index` | `references` | [upstream](https://github.com/sourcegraph/scip-python) / [route #80](https://github.com/samchon/compiler-graph/issues/80) | +| `scip-ruby` | `ruby` | `semantic-index` | **none** | [upstream](https://github.com/sourcegraph/scip-ruby) / [route #81](https://github.com/samchon/compiler-graph/issues/81) | +| `scip-dart` | `dart` | `semantic-index` | **none** | [upstream](https://pub.dev/packages/scip_dart) / [route #84](https://github.com/samchon/compiler-graph/issues/84) | +| `scip-php` | `php` | `semantic-index` | **none** | [upstream](https://github.com/davidrjenni/scip-php) / [route #82](https://github.com/samchon/compiler-graph/issues/82) | + +#### Lifecycle + +These are current implementation modes, not future route claims. Preparation and native/export/resident phases are stated separately because the [experiment catalog](https://github.com/samchon/compiler-graph/blob/master/tests/experiment/src/catalog.mjs) and [cold measurement artifact](https://github.com/samchon/compiler-graph/blob/master/tests/benchmark/results/graph.json) prove different boundaries; the artifact reports whole end-to-end cells, not isolated phase timings. + +| Provider | Mode | Preparation | Native analysis | Export and merge | Reuse or resident state | +| --- | --- | --- | --- | --- | --- | +| `ttscgraph` | `resident-no-op-reuse; full-rebuild-on-change` | A matching ttsc/TypeScript project and tsconfig/jsconfig/package inputs. | The target project's ttsc checker owns one resident compiler process. | Each changed response serializes and validates one complete compiler dump before publication. | An identical producer generation reuses the exact dump; changed work is not yet proportional to the invalidated closure. | +| `samchon-graph-go` | `unchanged-snapshot-reuse; full-rebuild-on-change` | Go workspace/module inputs, selected GOOS/GOARCH/cgo environment, embedded files and vendored inputs. | The shipped exporter runs one compiler-owned go/packages batch against the selected build universe. | A changed-input batch emits and validates one whole-workspace graph before snapshot publication. | Unchanged inputs reuse the validated snapshot; no resident go/packages checker survives changed builds. | +| `samchon-graph-lua` | `unchanged-snapshot-reuse; full-rebuild-on-change` | LuaLS workspace configuration and the shipped readable exporter. | LuaLS analyzes the workspace and the shipped exporter asks its semantic VM for declaration references. | A changed-input run publishes one references-only whole-workspace graph. | Unchanged inputs reuse the validated snapshot; the current exporter is not a resident incremental session. | +| `rust-analyzer-scip` | `unchanged-snapshot-reuse; full-rebuild-on-change` | Cargo metadata/config, lock/toolchain inputs, target/features/cfg and build-script/proc-macro universe. | Stock rust-analyzer produces one batch SCIP artifact for the selected Cargo universe. | The decoder maps the complete artifact to a contains/references graph before atomic snapshot publication. | Unchanged inputs reuse the validated snapshot; no rust-analyzer semantic session remains resident. | +| `scip-clang` | `unchanged-snapshot-reuse; full-rebuild-on-change` | A valid compilation database and every named compiler/working directory/generated build input. | scip-clang runs one batch over the exact compilation database and its per-unit compiler commands. | The complete decoded artifact publishes declarations but no currently defensible edge family; producer scheduling can move header selection. | Unchanged inputs reuse the validated snapshot; every changed build reruns the batch producer. | +| `scip-java` | `unchanged-snapshot-reuse; full-rebuild-on-change` | Maven or Gradle project metadata, dependency/classpath state and Java/Kotlin compiler inputs. | scip-java drives the selected Maven or Gradle build and its Java/Kotlin producers as one batch. | The complete decoded artifact is merged as a contains/references graph before atomic publication. | Unchanged inputs reuse the validated snapshot; no javac, kotlinc or build session remains resident. | +| `scip-dotnet` | `unchanged-snapshot-reuse; full-rebuild-on-change` | Solution/project/TFM/NuGet/MSBuild inputs and a resolvable SDK. | scip-dotnet loads and analyzes the selected solution through one batch producer run. | The complete decoded artifact publishes declarations but no currently defensible edge family. | Unchanged inputs reuse the validated snapshot; no Roslyn workspace remains resident. | +| `scip-python` | `unchanged-snapshot-reuse; full-rebuild-on-change` | Python project/config/environment/import/stub inputs. | scip-python runs its bundled Pyright-based analysis once for the selected project environment. | The complete decoded artifact publishes a references-only project graph. | Unchanged inputs reuse the validated snapshot; no Pyright analysis session remains resident. | +| `scip-ruby` | `unchanged-snapshot-reuse; full-rebuild-on-change` | Gem/Bundler/Sorbet/RBI configuration inputs. | scip-ruby performs one full-project batch using the selected Ruby, Bundler and Sorbet inputs. | The complete decoded artifact publishes declarations but no currently defensible edge family. | Unchanged inputs reuse the validated snapshot; no Ruby or Sorbet index remains resident. | +| `scip-dart` | `unchanged-snapshot-reuse; full-rebuild-on-change` | pubspec/lock, analysis options and resolved package configuration. | scip_dart performs one full-project batch using the resolved Dart package universe. | The complete decoded artifact publishes declarations but no currently defensible edge family. | Unchanged inputs reuse the validated snapshot; this is not resident Analysis Server state. | +| `scip-php` | `unchanged-snapshot-reuse; full-rebuild-on-change` | Composer manifest/lock/autoload and PHP/PHPStan configuration inputs. | The project-local scip-php producer performs one full-project batch through Composer/PHP inputs. | The complete decoded artifact publishes declarations but no currently defensible edge family. | Unchanged inputs reuse the validated snapshot; no PHPStan or Composer analysis session remains resident. | + +#### Installation and selection + +The troubleshooting table names the ordinary language-server/static fallback for each row. Resolution metadata is shared with the shipped registry and checked in CI. + +| Provider | Install | Commands | Overrides | Resolution order | Project preparation | Platforms | +| --- | --- | --- | --- | --- | --- | --- | +| `ttscgraph` | `npm i -D ttsc@^0.23.0 typescript` | `ttscgraph`, `ttscserver` | `TTSC_GRAPH_BINARY` | Absolute `TTSC_GRAPH_BINARY`, target-project `ttsc` package/binary, target-project `.bin`, then PATH/global compatibility fallback. | A matching ttsc/TypeScript project and tsconfig/jsconfig/package inputs. | `linux`, `macos`, `windows` | +| `samchon-graph-go` | Go 1.25+; the package ships the Go exporter source. Install corroboration with `go install github.com/scip-code/scip-go/cmd/scip-go@v0.2.7`. | `samchon-graph-go`, `go`, `scip-go` | `SAMCHON_GRAPH_GO`, `SAMCHON_GRAPH_GO_TOOLCHAIN`, `SAMCHON_GRAPH_SCIP_GO` | Project/PATH `samchon-graph-go`, then the shipped source runner through Go; absolute environment overrides take precedence. | Go workspace/module inputs, selected GOOS/GOARCH/cgo environment, embedded files and vendored inputs. | `linux`, `macos`, `windows` | +| `samchon-graph-lua` | Install `lua-language-server`; the package ships `sidecars/lua/export.lua`. | `lua-language-server` | `SAMCHON_GRAPH_LUA`, `SAMCHON_GRAPH_LUA_EXPORTER` | Absolute `SAMCHON_GRAPH_LUA`, then project/PATH LuaLS; the shipped exporter may be replaced by `SAMCHON_GRAPH_LUA_EXPORTER`. | LuaLS workspace configuration and the shipped readable exporter. | `linux`, `macos`, `windows` | +| `rust-analyzer-scip` | `rustup component add rust-analyzer`; install the `scip` decoder and provide matching rustc/Cargo. | `rust-analyzer`, `scip`, `rustc`, `cargo` | `SAMCHON_GRAPH_RUST_ANALYZER`, `SAMCHON_GRAPH_SCIP`, `SAMCHON_GRAPH_RUSTC`, `SAMCHON_GRAPH_CARGO` | Project-local tools precede PATH; each absolute environment override replaces only its named tool. | Cargo metadata/config, lock/toolchain inputs, target/features/cfg and build-script/proc-macro universe. | `linux`, `macos`, `windows` | +| `scip-clang` | Install `scip-clang` 0.4.0 and the `scip` decoder; provide a generated `compile_commands.json`. | `scip-clang`, `scip`, `cc` | `SAMCHON_GRAPH_SCIP_CLANG`, `SAMCHON_GRAPH_SCIP` | Project-local producer/decoder precede PATH; `SAMCHON_GRAPH_SCIP_CLANG` and `SAMCHON_GRAPH_SCIP` select absolute tools. Compilers come from the compilation database. | A valid compilation database and every named compiler/working directory/generated build input. | `linux`, `macos`, `windows-when-installed` | +| `scip-java` | Install `scip-java` 0.13.1, the `scip` decoder and a compatible JDK; Kotlin experiments pin a compatible source build. | `scip-java`, `scip`, `java` | `SAMCHON_GRAPH_SCIP_JAVA`, `SAMCHON_GRAPH_SCIP`, `SAMCHON_GRAPH_JAVA_TOOLCHAIN` | Project-local producer/decoder/JDK precede PATH; absolute environment overrides select each tool. | Maven or Gradle project metadata, dependency/classpath state and Java/Kotlin compiler inputs. | `linux`, `macos`, `windows` | +| `scip-dotnet` | `dotnet tool install --global scip-dotnet`; install the `scip` decoder and matching .NET SDK. | `scip-dotnet`, `scip`, `dotnet` | `SAMCHON_GRAPH_SCIP_DOTNET`, `SAMCHON_GRAPH_SCIP`, `SAMCHON_GRAPH_DOTNET_TOOLCHAIN` | Project-local producer/decoder/toolchain precede PATH; absolute environment overrides select each tool. | Solution/project/TFM/NuGet/MSBuild inputs and a resolvable SDK. | `linux`, `macos`, `windows` | +| `scip-python` | `npm install -g @sourcegraph/scip-python@0.6.6`; install the `scip` decoder and select Python. | `scip-python`, `scip`, `python3`, `python`, `py` | `SAMCHON_GRAPH_SCIP_PYTHON`, `SAMCHON_GRAPH_SCIP`, `SAMCHON_GRAPH_PYTHON_TOOLCHAIN` | Project-local producer/decoder/interpreter precede PATH; Python aliases are tried in order and absolute overrides select each tool. | Python project/config/environment/import/stub inputs. | `linux`, `macos`, `windows` | +| `scip-ruby` | Install the pinned `scip-ruby` 0.4.7 release binary, the `scip` decoder and matching Ruby/Bundler. | `scip-ruby`, `scip`, `ruby` | `SAMCHON_GRAPH_SCIP_RUBY`, `SAMCHON_GRAPH_SCIP`, `SAMCHON_GRAPH_RUBY_TOOLCHAIN` | Project-local producer/decoder/Ruby precede PATH; absolute environment overrides select each tool. | Gem/Bundler/Sorbet/RBI configuration inputs. | `linux`, `macos`, `windows-when-installed` | +| `scip-dart` | `dart pub global activate scip_dart 1.6.2`; install the `scip` decoder and Dart SDK. | `scip_dart`, `scip`, `dart` | `SAMCHON_GRAPH_SCIP_DART`, `SAMCHON_GRAPH_SCIP`, `SAMCHON_GRAPH_DART_TOOLCHAIN` | Project-local producer/decoder/Dart precede PATH; absolute environment overrides select each tool. | pubspec/lock, analysis options and resolved package configuration. | `linux`, `macos`, `windows` | +| `scip-php` | Install the project-local scip-php dependency with Composer, expose `vendor/bin/scip-php`, and install the `scip` decoder. | `scip-php`, `scip`, `php` | `SAMCHON_GRAPH_SCIP_PHP`, `SAMCHON_GRAPH_SCIP`, `SAMCHON_GRAPH_PHP_TOOLCHAIN` | Project `vendor/bin` precedes PATH; absolute producer/decoder/PHP overrides select each tool. | Composer manifest/lock/autoload and PHP/PHPStan configuration inputs. | `linux`, `macos`, `windows` | + +#### Verified cold index cells + +These are exact same-run cold end-to-end strict/strict-disabled pairs from [`tests/benchmark/results/graph.json`](https://github.com/samchon/compiler-graph/blob/master/tests/benchmark/results/graph.json), produced by [the pinned workflow run](https://github.com/samchon/compiler-graph/actions/runs/30448033020). They do not prove warm or semantic-incremental behavior. A zero-fact strict provider is not called semantically complete. Ruby and Dart report only that both whole cells exceeded the 1,800-second guard; that limit is not an isolated producer duration. + +| Project | Strict provider | Strict cell | Strict-disabled cell | +| --- | --- | --- | --- | +| `excalidraw` | `ttscgraph` | 5,340.296 ms | 2,977.720 ms | +| `gin` | `samchon-graph-go` | 38,097.048 ms | 687.107 ms | +| `lualine` | `samchon-graph-lua` | 18,889.245 ms | 27,848.007 ms | +| `tokio` | `rust-analyzer-scip` | 55,238.180 ms | 229,860.996 ms | +| `redis` | `scip-clang` | 22,794.688 ms | 262,905.796 ms | +| `leveldb` | `scip-clang` | 8,352.928 ms | 26,451.952 ms | +| `gson` | `scip-java` | 88,653.499 ms | 231,398.489 ms | +| `koin` | `scip-java` | 211,263.800 ms | 967,711.761 ms | +| `serilog` | `scip-dotnet` | 20,498.324 ms | 25,085.071 ms | +| `flask` | `scip-python` | 10,628.897 ms | 748.454 ms | +| `sinatra` | `scip-ruby` | did not finish before 1,800 s | did not finish before 1,800 s | +| `darthttp` | `scip-dart` | did not finish before 1,800 s | did not finish before 1,800 s | +| `slim` | `scip-php` | 3,771.828 ms | 9,611.108 ms | + +#### Troubleshooting + +A strict result's provenance name must equal the provider below. If it is absent, use the commands and overrides in the installation table, then follow the explicit decline reason; the fallback is still usable but does not inherit strict authority. + +| Languages | Expected provenance | Common boundary | Decline and fallback | +| --- | --- | --- | --- | +| `typescript` | `ttscgraph` | The current producer does not yet make changed-response work proportional to the compiler invalidated closure. | `ttscserver`, then `@samchon/graph-sitter`; capped or incompatible requests decline explicitly. | +| `go` | `samchon-graph-go` | Current changed-input export is a full rebuild and does not retain a resident `go/packages` checker session. | `gopls`, then `@samchon/graph-sitter`; unavailable toolchain or corroborator declines explicitly. | +| `lua` | `samchon-graph-lua` | The current exporter calls `vm.getRefs` per declaration and proves references only; #83 replaces it with an occurrence-oriented resident traversal. | Generic LuaLS, then `@samchon/graph-sitter`; capped requests or a missing exporter decline explicitly. | +| `rust` | `rust-analyzer-scip` | Stock rust-analyzer SCIP has empty relationships/diagnostics and is navigation evidence, not the final HIR graph. | Generic rust-analyzer, then `@samchon/graph-sitter`; any missing analyzer/decoder/rustc/Cargo component declines. | +| `c`, `cpp` | `scip-clang` | The current artifact proves declarations but no graph edge family because enclosing attribution and type-definition relationships are absent. | `clangd`, then `@samchon/graph-sitter`; missing/invalid compilation metadata declines explicitly. | +| `java`, `kotlin` | `scip-java` | The released Gradle path disables configuration cache and runs `clean scipCompileAll`; it is navigation fallback for #74/#76, not compiler-owned calls or accesses. | `jdtls` or `kotlin-language-server`, then `@samchon/graph-sitter`. | +| `csharp` | `scip-dotnet` | The artifact proves declarations but no graph edge family and can log MSBuildWorkspace failures after publishing. | `csharp-ls`, then `@samchon/graph-sitter`. | +| `python` | `scip-python` | The bundled historical Pyright core proves references only and can recover from malformed pyproject configuration with defaults. | `pyright-langserver`, then `@samchon/graph-sitter`. | +| `ruby` | `scip-ruby` | The current artifact proves no graph edge family; it does not expose structural coverage, Sorbet sigils or typed unresolved sites. | `ruby-lsp`, then `@samchon/graph-sitter`. | +| `dart` | `scip-dart` | The current artifact proves no graph edge family and is not resident Analysis Server state. | Dart Analysis Server, then `@samchon/graph-sitter`. | +| `php` | `scip-php` | The current raw parser/Composer artifact proves no graph edge family and has no diagnostic or role grounding. | `intelephense`, then `@samchon/graph-sitter`. | + +#### Ordinary-only strict status + +These languages are indexed through their ordinary server and static fallback today. They have no registered strict provider or strict timing claim. + +| Language | Ordinary server | Why no strict provider | Route | +| --- | --- | --- | --- | +| `scala` | `metals` | No registered strict provider; scip-java no longer supports Scala. | [tracked route](https://github.com/samchon/compiler-graph/issues/77) | +| `swift` | `sourcekit-lsp` | No packaged IndexStoreDB/SourceKit-LSP snapshot producer is registered. | [tracked route](https://github.com/samchon/compiler-graph/issues/78) | +| `zig` | `zls` | No analyzer or compiler Sema snapshot producer is registered. | [tracked route](https://github.com/samchon/compiler-graph/issues/79) | + + ### Repository topology The same `inspect_code_graph` tool has a `topology` request for workspaces, packages, source roots, targets, tasks, entrypoints, project dependencies, and file joins. These facts use a sibling provider plane: repository nodes never masquerade as code symbols, and a file join is returned only when the topology model can be fenced against one stable code generation. @@ -76,11 +184,7 @@ Every topology result carries provider/tool provenance, per-relation `complete`/ Before the generic lane runs, indexing asks a registry of strict providers which languages they own. A provider states what its facts are grounded in — a compiler, a whole-project analyzer, or a precomputed semantic index — and which edge families it can prove; a snapshot that publishes outside those is rejected rather than merged. Whatever no provider claims falls through to the language server, and then to the static indexer. Every decline is one sentence naming the provider and the authority the build gave up, so a fallback is never mistaken for the strict result it replaced. -The dump carries one `provenance` row per contributing provider: its authority, the fact families it proves, the producing tool and versions, a fingerprint of the inputs that decided the file set, and digests over the manifest and the published facts. Absent when no strict provider served the build. What a provider *did* to compute a generation is deliberately not recorded there — that belongs to one refresh rather than to the facts, and writing it down would make two dumps of the same unedited checkout differ. - -TypeScript's provider is the compiler-owned `ttscgraph` snapshot. The binary is resolved from the target project's `ttsc` installation; `TTSC_GRAPH_BINARY` can point to an exact absolute binary for development or release verification. If the binary is unavailable, its schema/provenance cannot be trusted, or the requested build is deliberately capped, indexing states the reason and falls back to `ttscserver`, then to the static indexer when no server is available. `ttscgraph` schema 6 is the complete portable contract: paths are relative to the producer's project (including `../` siblings), virtual libraries use `bundled:///`, and declarations may carry compiler-bounded signatures. Older producers are refused and indexing falls back honestly to `ttscserver`. - -Go's compiler-owned provider is shipped with this package and runs through Go 1.25 or newer. Its navigation corroboration is pinned to `scip-go` 0.2.7; install that exact producer with `go install github.com/scip-code/scip-go/cmd/scip-go@v0.2.7`. A project-local or `PATH` `samchon-graph-go` binary takes precedence over the bundled source runner, `SAMCHON_GRAPH_GO` can select an absolute development build, and `SAMCHON_GRAPH_SCIP_GO` can select an absolute `scip-go` binary. Without the required Go toolchain or pinned indexer, indexing reports the strict-provider decline and retains the generic `gopls` fallback. +The dump carries one `provenance` row per contributing provider: its authority, the fact families it proves, the producing tool and versions, a fingerprint of the inputs that decided the file set, and digests over the manifest and the published facts. It is absent when no strict provider served the build. What a provider *did* to compute a generation is deliberately not recorded there because that belongs to one refresh rather than to the facts. JavaScript is intentionally not indexed. In an arbitrary repository, `.js`/`.jsx`/`.mjs`/`.cjs` files are as often build output or vendored bundles as handwritten source, and the graph cannot tell which without project-specific provenance. @@ -134,27 +238,7 @@ checkout-grounding rule used by the reference harness. ### Indexing time -| Project | Language | First index | -|---|---|---| -| [slim](https://github.com/slimphp/Slim) | PHP | 5s | -| [excalidraw](https://github.com/excalidraw/excalidraw) | TypeScript | 5s | -| [gin](https://github.com/gin-gonic/gin) | Go | 15s | -| [leveldb](https://github.com/google/leveldb) | C++ | 16s | -| [darthttp](https://github.com/dart-lang/http) | Dart | 23s | -| [lualine](https://github.com/nvim-lualine/lualine.nvim) | Lua | 25s | -| [flask](https://github.com/pallets/flask) | Python | 55s | -| [gson](https://github.com/google/gson) | Java | 2m22s | -| [sinatra](https://github.com/sinatra/sinatra) | Ruby | 2m35s | -| [redis](https://github.com/redis/redis) | C | 2m50s | -| [tokio](https://github.com/tokio-rs/tokio) | Rust | 3m | -| [koin](https://github.com/InsertKoinIO/koin) | Kotlin | 19m25s | -| [serilog](https://github.com/serilog/serilog) | C# | not recorded | - -One-time cost per repository. The server re-scans only changed files after that (see [How it works](#how-it-works)); later calls are free. - -kotlin-language-server, jdtls, and csharp-ls are particularly slow: each resolves the whole project before answering anything. - -TypeScript and Go already close that gap through compiler-owned snapshots. The remaining languages use their listed language servers until their compiler-owned bulk providers land. +The exact current same-run cold strict/strict-disabled measurements are generated from the pinned result artifact in [Verified cold index cells](#verified-cold-index-cells). They make no warm, resident, or semantic-completeness claim; lifecycle modes are listed separately from measured cold time. ### Reproduction diff --git a/docs/provider-support.json b/docs/provider-support.json new file mode 100644 index 00000000..36d730de --- /dev/null +++ b/docs/provider-support.json @@ -0,0 +1,314 @@ +{ + "schemaVersion": 1, + "benchmark": { + "artifact": "tests/benchmark/results/graph.json", + "workflowRun": "https://github.com/samchon/compiler-graph/actions/runs/30448033020", + "kind": "cold end-to-end strict versus strict-disabled pairs" + }, + "providers": [ + { + "provider": "ttscgraph", + "languages": ["typescript"], + "status": "registered", + "authority": "compiler", + "facts": ["exports", "calls", "accesses", "instantiates", "type_ref", "extends", "implements", "overrides", "renders"], + "commands": ["ttscgraph", "ttscserver"], + "environmentOverrides": ["TTSC_GRAPH_BINARY"], + "install": "`npm i -D ttsc@^0.23.0 typescript`", + "resolution": "Absolute `TTSC_GRAPH_BINARY`, target-project `ttsc` package/binary, target-project `.bin`, then PATH/global compatibility fallback.", + "requirements": "A matching ttsc/TypeScript project and tsconfig/jsconfig/package inputs.", + "platforms": ["linux", "macos", "windows"], + "mode": "resident-no-op-reuse; full-rebuild-on-change", + "nativeAnalysis": "The target project's ttsc checker owns one resident compiler process.", + "exportMerge": "Each changed response serializes and validates one complete compiler dump before publication.", + "reuseResident": "An identical producer generation reuses the exact dump; changed work is not yet proportional to the invalidated closure.", + "limitations": "The current producer does not yet make changed-response work proportional to the compiler invalidated closure.", + "fallback": "`ttscserver`, then `@samchon/graph-sitter`; capped or incompatible requests decline explicitly.", + "experimentLanguages": ["typescript"], + "experimentTool": "ttscgraph", + "experimentCapabilities": ["universe", "sourceDigests", "diskDigests", "diagnostics"], + "benchmarks": [{"project": "excalidraw", "strictMs": 5340.295836, "fallbackMs": 2977.720236}], + "upstream": "https://github.com/samchon/ttsc", + "childIssues": ["https://github.com/samchon/compiler-graph/issues/63"] + }, + { + "provider": "samchon-graph-go", + "languages": ["go"], + "status": "registered", + "authority": "compiler", + "facts": ["contains", "exports", "imports", "calls", "accesses", "instantiates", "type_ref", "implements", "dispatches", "tests", "references"], + "commands": ["samchon-graph-go", "go", "scip-go"], + "environmentOverrides": ["SAMCHON_GRAPH_GO", "SAMCHON_GRAPH_GO_TOOLCHAIN", "SAMCHON_GRAPH_SCIP_GO"], + "install": "Go 1.25+; the package ships the Go exporter source. Install corroboration with `go install github.com/scip-code/scip-go/cmd/scip-go@v0.2.7`.", + "resolution": "Project/PATH `samchon-graph-go`, then the shipped source runner through Go; absolute environment overrides take precedence.", + "requirements": "Go workspace/module inputs, selected GOOS/GOARCH/cgo environment, embedded files and vendored inputs.", + "platforms": ["linux", "macos", "windows"], + "mode": "unchanged-snapshot-reuse; full-rebuild-on-change", + "nativeAnalysis": "The shipped exporter runs one compiler-owned go/packages batch against the selected build universe.", + "exportMerge": "A changed-input batch emits and validates one whole-workspace graph before snapshot publication.", + "reuseResident": "Unchanged inputs reuse the validated snapshot; no resident go/packages checker survives changed builds.", + "limitations": "Current changed-input export is a full rebuild and does not retain a resident `go/packages` checker session.", + "fallback": "`gopls`, then `@samchon/graph-sitter`; unavailable toolchain or corroborator declines explicitly.", + "experimentLanguages": ["go"], + "experimentTool": "samchon-graph-go", + "experimentCapabilities": ["universe", "sourceDigests", "fullRebuild"], + "benchmarks": [{"project": "gin", "strictMs": 38097.048393, "fallbackMs": 687.107355}], + "upstream": "https://github.com/scip-code/scip-go", + "childIssues": ["https://github.com/samchon/compiler-graph/issues/63"] + }, + { + "provider": "samchon-graph-lua", + "languages": ["lua"], + "status": "registered", + "authority": "analyzer", + "facts": ["references"], + "commands": ["lua-language-server"], + "environmentOverrides": ["SAMCHON_GRAPH_LUA", "SAMCHON_GRAPH_LUA_EXPORTER"], + "install": "Install `lua-language-server`; the package ships `sidecars/lua/export.lua`.", + "resolution": "Absolute `SAMCHON_GRAPH_LUA`, then project/PATH LuaLS; the shipped exporter may be replaced by `SAMCHON_GRAPH_LUA_EXPORTER`.", + "requirements": "LuaLS workspace configuration and the shipped readable exporter.", + "platforms": ["linux", "macos", "windows"], + "mode": "unchanged-snapshot-reuse; full-rebuild-on-change", + "nativeAnalysis": "LuaLS analyzes the workspace and the shipped exporter asks its semantic VM for declaration references.", + "exportMerge": "A changed-input run publishes one references-only whole-workspace graph.", + "reuseResident": "Unchanged inputs reuse the validated snapshot; the current exporter is not a resident incremental session.", + "limitations": "The current exporter calls `vm.getRefs` per declaration and proves references only; #83 replaces it with an occurrence-oriented resident traversal.", + "fallback": "Generic LuaLS, then `@samchon/graph-sitter`; capped requests or a missing exporter decline explicitly.", + "experimentLanguages": ["lua"], + "experimentTool": "lua-language-server", + "experimentCapabilities": ["universe", "diskDigests"], + "benchmarks": [{"project": "lualine", "strictMs": 18889.245252, "fallbackMs": 27848.006717}], + "upstream": "https://github.com/LuaLS/lua-language-server", + "childIssues": ["https://github.com/samchon/compiler-graph/issues/83"] + }, + { + "provider": "rust-analyzer-scip", + "languages": ["rust"], + "status": "registered", + "authority": "semantic-index", + "facts": ["contains", "references"], + "commands": ["rust-analyzer", "scip", "rustc", "cargo"], + "environmentOverrides": ["SAMCHON_GRAPH_RUST_ANALYZER", "SAMCHON_GRAPH_SCIP", "SAMCHON_GRAPH_RUSTC", "SAMCHON_GRAPH_CARGO"], + "install": "`rustup component add rust-analyzer`; install the `scip` decoder and provide matching rustc/Cargo.", + "resolution": "Project-local tools precede PATH; each absolute environment override replaces only its named tool.", + "requirements": "Cargo metadata/config, lock/toolchain inputs, target/features/cfg and build-script/proc-macro universe.", + "platforms": ["linux", "macos", "windows"], + "mode": "unchanged-snapshot-reuse; full-rebuild-on-change", + "nativeAnalysis": "Stock rust-analyzer produces one batch SCIP artifact for the selected Cargo universe.", + "exportMerge": "The decoder maps the complete artifact to a contains/references graph before atomic snapshot publication.", + "reuseResident": "Unchanged inputs reuse the validated snapshot; no rust-analyzer semantic session remains resident.", + "limitations": "Stock rust-analyzer SCIP has empty relationships/diagnostics and is navigation evidence, not the final HIR graph.", + "fallback": "Generic rust-analyzer, then `@samchon/graph-sitter`; any missing analyzer/decoder/rustc/Cargo component declines.", + "experimentLanguages": ["rust"], + "experimentTool": "rust-analyzer", + "experimentCapabilities": ["universe", "diskDigests"], + "benchmarks": [{"project": "tokio", "strictMs": 55238.18003, "fallbackMs": 229860.9964}], + "upstream": "https://github.com/rust-lang/rust-analyzer", + "childIssues": ["https://github.com/samchon/compiler-graph/issues/72"] + }, + { + "provider": "scip-clang", + "languages": ["c", "cpp"], + "status": "registered", + "authority": "semantic-index", + "facts": [], + "commands": ["scip-clang", "scip", "cc"], + "environmentOverrides": ["SAMCHON_GRAPH_SCIP_CLANG", "SAMCHON_GRAPH_SCIP"], + "install": "Install `scip-clang` 0.4.0 and the `scip` decoder; provide a generated `compile_commands.json`.", + "resolution": "Project-local producer/decoder precede PATH; `SAMCHON_GRAPH_SCIP_CLANG` and `SAMCHON_GRAPH_SCIP` select absolute tools. Compilers come from the compilation database.", + "requirements": "A valid compilation database and every named compiler/working directory/generated build input.", + "platforms": ["linux", "macos", "windows-when-installed"], + "mode": "unchanged-snapshot-reuse; full-rebuild-on-change", + "nativeAnalysis": "scip-clang runs one batch over the exact compilation database and its per-unit compiler commands.", + "exportMerge": "The complete decoded artifact publishes declarations but no currently defensible edge family; producer scheduling can move header selection.", + "reuseResident": "Unchanged inputs reuse the validated snapshot; every changed build reruns the batch producer.", + "limitations": "The current artifact proves declarations but no graph edge family because enclosing attribution and type-definition relationships are absent.", + "fallback": "`clangd`, then `@samchon/graph-sitter`; missing/invalid compilation metadata declines explicitly.", + "experimentLanguages": ["c", "cpp"], + "experimentTool": "scip-clang", + "experimentCapabilities": ["universe", "diskDigests"], + "benchmarks": [ + {"project": "redis", "strictMs": 22794.688115, "fallbackMs": 262905.79583}, + {"project": "leveldb", "strictMs": 8352.928418, "fallbackMs": 26451.951757} + ], + "upstream": "https://github.com/sourcegraph/scip-clang", + "childIssues": ["https://github.com/samchon/compiler-graph/issues/73"] + }, + { + "provider": "scip-java", + "languages": ["java", "kotlin"], + "status": "registered", + "authority": "semantic-index", + "facts": ["contains", "references"], + "commands": ["scip-java", "scip", "java"], + "environmentOverrides": ["SAMCHON_GRAPH_SCIP_JAVA", "SAMCHON_GRAPH_SCIP", "SAMCHON_GRAPH_JAVA_TOOLCHAIN"], + "install": "Install `scip-java` 0.13.1, the `scip` decoder and a compatible JDK; Kotlin experiments pin a compatible source build.", + "resolution": "Project-local producer/decoder/JDK precede PATH; absolute environment overrides select each tool.", + "requirements": "Maven or Gradle project metadata, dependency/classpath state and Java/Kotlin compiler inputs.", + "platforms": ["linux", "macos", "windows"], + "mode": "unchanged-snapshot-reuse; full-rebuild-on-change", + "nativeAnalysis": "scip-java drives the selected Maven or Gradle build and its Java/Kotlin producers as one batch.", + "exportMerge": "The complete decoded artifact is merged as a contains/references graph before atomic publication.", + "reuseResident": "Unchanged inputs reuse the validated snapshot; no javac, kotlinc or build session remains resident.", + "limitations": "The released Gradle path disables configuration cache and runs `clean scipCompileAll`; it is navigation fallback for #74/#76, not compiler-owned calls or accesses.", + "fallback": "`jdtls` or `kotlin-language-server`, then `@samchon/graph-sitter`.", + "experimentLanguages": ["java", "kotlin"], + "experimentTool": "scip-java", + "experimentCapabilities": ["universe", "diskDigests"], + "benchmarks": [ + {"project": "gson", "strictMs": 88653.49921, "fallbackMs": 231398.488953}, + {"project": "koin", "strictMs": 211263.800455, "fallbackMs": 967711.761431} + ], + "upstream": "https://github.com/scip-code/scip-java", + "childIssues": [ + "https://github.com/samchon/compiler-graph/issues/74", + "https://github.com/samchon/compiler-graph/issues/76" + ] + }, + { + "provider": "scip-dotnet", + "languages": ["csharp"], + "status": "registered", + "authority": "semantic-index", + "facts": [], + "commands": ["scip-dotnet", "scip", "dotnet"], + "environmentOverrides": ["SAMCHON_GRAPH_SCIP_DOTNET", "SAMCHON_GRAPH_SCIP", "SAMCHON_GRAPH_DOTNET_TOOLCHAIN"], + "install": "`dotnet tool install --global scip-dotnet`; install the `scip` decoder and matching .NET SDK.", + "resolution": "Project-local producer/decoder/toolchain precede PATH; absolute environment overrides select each tool.", + "requirements": "Solution/project/TFM/NuGet/MSBuild inputs and a resolvable SDK.", + "platforms": ["linux", "macos", "windows"], + "mode": "unchanged-snapshot-reuse; full-rebuild-on-change", + "nativeAnalysis": "scip-dotnet loads and analyzes the selected solution through one batch producer run.", + "exportMerge": "The complete decoded artifact publishes declarations but no currently defensible edge family.", + "reuseResident": "Unchanged inputs reuse the validated snapshot; no Roslyn workspace remains resident.", + "limitations": "The artifact proves declarations but no graph edge family and can log MSBuildWorkspace failures after publishing.", + "fallback": "`csharp-ls`, then `@samchon/graph-sitter`.", + "experimentLanguages": ["csharp"], + "experimentTool": "scip-dotnet", + "experimentCapabilities": ["universe", "diskDigests"], + "benchmarks": [{"project": "serilog", "strictMs": 20498.323945, "fallbackMs": 25085.071148}], + "upstream": "https://github.com/sourcegraph/scip-dotnet", + "childIssues": ["https://github.com/samchon/compiler-graph/issues/75"] + }, + { + "provider": "scip-python", + "languages": ["python"], + "status": "registered", + "authority": "semantic-index", + "facts": ["references"], + "commands": ["scip-python", "scip", "python3", "python", "py"], + "environmentOverrides": ["SAMCHON_GRAPH_SCIP_PYTHON", "SAMCHON_GRAPH_SCIP", "SAMCHON_GRAPH_PYTHON_TOOLCHAIN"], + "install": "`npm install -g @sourcegraph/scip-python@0.6.6`; install the `scip` decoder and select Python.", + "resolution": "Project-local producer/decoder/interpreter precede PATH; Python aliases are tried in order and absolute overrides select each tool.", + "requirements": "Python project/config/environment/import/stub inputs.", + "platforms": ["linux", "macos", "windows"], + "mode": "unchanged-snapshot-reuse; full-rebuild-on-change", + "nativeAnalysis": "scip-python runs its bundled Pyright-based analysis once for the selected project environment.", + "exportMerge": "The complete decoded artifact publishes a references-only project graph.", + "reuseResident": "Unchanged inputs reuse the validated snapshot; no Pyright analysis session remains resident.", + "limitations": "The bundled historical Pyright core proves references only and can recover from malformed pyproject configuration with defaults.", + "fallback": "`pyright-langserver`, then `@samchon/graph-sitter`.", + "experimentLanguages": ["python"], + "experimentTool": "scip-python", + "experimentCapabilities": ["universe", "diskDigests"], + "benchmarks": [{"project": "flask", "strictMs": 10628.897103, "fallbackMs": 748.453891}], + "upstream": "https://github.com/sourcegraph/scip-python", + "childIssues": ["https://github.com/samchon/compiler-graph/issues/80"] + }, + { + "provider": "scip-ruby", + "languages": ["ruby"], + "status": "registered", + "authority": "semantic-index", + "facts": [], + "commands": ["scip-ruby", "scip", "ruby"], + "environmentOverrides": ["SAMCHON_GRAPH_SCIP_RUBY", "SAMCHON_GRAPH_SCIP", "SAMCHON_GRAPH_RUBY_TOOLCHAIN"], + "install": "Install the pinned `scip-ruby` 0.4.7 release binary, the `scip` decoder and matching Ruby/Bundler.", + "resolution": "Project-local producer/decoder/Ruby precede PATH; absolute environment overrides select each tool.", + "requirements": "Gem/Bundler/Sorbet/RBI configuration inputs.", + "platforms": ["linux", "macos", "windows-when-installed"], + "mode": "unchanged-snapshot-reuse; full-rebuild-on-change", + "nativeAnalysis": "scip-ruby performs one full-project batch using the selected Ruby, Bundler and Sorbet inputs.", + "exportMerge": "The complete decoded artifact publishes declarations but no currently defensible edge family.", + "reuseResident": "Unchanged inputs reuse the validated snapshot; no Ruby or Sorbet index remains resident.", + "limitations": "The current artifact proves no graph edge family; it does not expose structural coverage, Sorbet sigils or typed unresolved sites.", + "fallback": "`ruby-lsp`, then `@samchon/graph-sitter`.", + "experimentLanguages": ["ruby"], + "experimentTool": "scip-ruby", + "experimentCapabilities": ["universe", "diskDigests"], + "benchmarks": [{"project": "sinatra", "strictTimedOutMs": 1800000, "fallbackTimedOutMs": 1800000}], + "upstream": "https://github.com/sourcegraph/scip-ruby", + "childIssues": ["https://github.com/samchon/compiler-graph/issues/81"] + }, + { + "provider": "scip-dart", + "languages": ["dart"], + "status": "registered", + "authority": "semantic-index", + "facts": [], + "commands": ["scip_dart", "scip", "dart"], + "environmentOverrides": ["SAMCHON_GRAPH_SCIP_DART", "SAMCHON_GRAPH_SCIP", "SAMCHON_GRAPH_DART_TOOLCHAIN"], + "install": "`dart pub global activate scip_dart 1.6.2`; install the `scip` decoder and Dart SDK.", + "resolution": "Project-local producer/decoder/Dart precede PATH; absolute environment overrides select each tool.", + "requirements": "pubspec/lock, analysis options and resolved package configuration.", + "platforms": ["linux", "macos", "windows"], + "mode": "unchanged-snapshot-reuse; full-rebuild-on-change", + "nativeAnalysis": "scip_dart performs one full-project batch using the resolved Dart package universe.", + "exportMerge": "The complete decoded artifact publishes declarations but no currently defensible edge family.", + "reuseResident": "Unchanged inputs reuse the validated snapshot; this is not resident Analysis Server state.", + "limitations": "The current artifact proves no graph edge family and is not resident Analysis Server state.", + "fallback": "Dart Analysis Server, then `@samchon/graph-sitter`.", + "experimentLanguages": ["dart"], + "experimentTool": "scip-dart", + "experimentCapabilities": ["universe", "diskDigests"], + "benchmarks": [{"project": "darthttp", "strictTimedOutMs": 1800000, "fallbackTimedOutMs": 1800000}], + "upstream": "https://pub.dev/packages/scip_dart", + "childIssues": ["https://github.com/samchon/compiler-graph/issues/84"] + }, + { + "provider": "scip-php", + "languages": ["php"], + "status": "registered", + "authority": "semantic-index", + "facts": [], + "commands": ["scip-php", "scip", "php"], + "environmentOverrides": ["SAMCHON_GRAPH_SCIP_PHP", "SAMCHON_GRAPH_SCIP", "SAMCHON_GRAPH_PHP_TOOLCHAIN"], + "install": "Install the project-local scip-php dependency with Composer, expose `vendor/bin/scip-php`, and install the `scip` decoder.", + "resolution": "Project `vendor/bin` precedes PATH; absolute producer/decoder/PHP overrides select each tool.", + "requirements": "Composer manifest/lock/autoload and PHP/PHPStan configuration inputs.", + "platforms": ["linux", "macos", "windows"], + "mode": "unchanged-snapshot-reuse; full-rebuild-on-change", + "nativeAnalysis": "The project-local scip-php producer performs one full-project batch through Composer/PHP inputs.", + "exportMerge": "The complete decoded artifact publishes declarations but no currently defensible edge family.", + "reuseResident": "Unchanged inputs reuse the validated snapshot; no PHPStan or Composer analysis session remains resident.", + "limitations": "The current raw parser/Composer artifact proves no graph edge family and has no diagnostic or role grounding.", + "fallback": "`intelephense`, then `@samchon/graph-sitter`.", + "experimentLanguages": ["php"], + "experimentTool": "scip-php", + "experimentCapabilities": ["universe", "diskDigests"], + "benchmarks": [{"project": "slim", "strictMs": 3771.828111, "fallbackMs": 9611.108084}], + "upstream": "https://github.com/davidrjenni/scip-php", + "childIssues": ["https://github.com/samchon/compiler-graph/issues/82"] + } + ], + "ordinaryOnly": [ + { + "language": "scala", + "server": "metals", + "issue": "https://github.com/samchon/compiler-graph/issues/77", + "reason": "No registered strict provider; scip-java no longer supports Scala." + }, + { + "language": "swift", + "server": "sourcekit-lsp", + "issue": "https://github.com/samchon/compiler-graph/issues/78", + "reason": "No packaged IndexStoreDB/SourceKit-LSP snapshot producer is registered." + }, + { + "language": "zig", + "server": "zls", + "issue": "https://github.com/samchon/compiler-graph/issues/79", + "reason": "No analyzer or compiler Sema snapshot producer is registered." + } + ] +} diff --git a/package.json b/package.json index d363dfb3..68e4163e 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,8 @@ "test": "pnpm --filter @samchon/graph-test start", "coverage": "pnpm --filter @samchon/graph build && pnpm --filter @samchon/graph-test build && pnpm exec c8 --all --src packages/graph/src --src packages/graph-sitter/src --exclude \"tests/**\" --exclude \"packages/graph/src/view.ts\" --exclude \"packages/graph/src/viewer/**\" --exclude-after-remap --reporter=text --reporter=lcov --check-coverage --lines 100 --functions 100 --branches 100 node tests/test-graph/lib/index.mjs", "parity": "pnpm --filter @samchon/graph-test build && node tests/test-graph/lib/parity.mjs", + "provider-support": "node packages/graph/build/provider-support.mjs --check", + "provider-support:write": "node packages/graph/build/provider-support.mjs --write", "experiment": "pnpm --filter @samchon/graph-experiment start", "benchmark": "pnpm --filter @samchon/graph-benchmark start", "release": "bumpp --r" diff --git a/packages/graph/build/provider-support.mjs b/packages/graph/build/provider-support.mjs new file mode 100644 index 00000000..55ae57eb --- /dev/null +++ b/packages/graph/build/provider-support.mjs @@ -0,0 +1,559 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const root = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../..", +); +const startMarker = ""; +const endMarker = ""; +const args = new Set(process.argv.slice(2)); +const manifestArgument = process.argv + .slice(2) + .find((argument) => argument.startsWith("--manifest=")); +const manifestFile = path.resolve( + root, + manifestArgument?.slice("--manifest=".length) ?? + "docs/provider-support.json", +); +const write = args.has("--write"); +const validateOnly = args.has("--validate-only"); + +if (write && validateOnly) { + throw new Error( + "provider support: --write and --validate-only are mutually exclusive", + ); +} + +const manifest = readJson(manifestFile); +const { GRAPH_PROVIDERS } = await import( + pathToFileURL( + path.join(root, "packages/graph/lib/provider/GRAPH_PROVIDERS.js"), + ).href +); +const { LANGUAGE_EXPERIMENTS } = await import( + pathToFileURL(path.join(root, "tests/experiment/src/catalog.mjs")).href +); +const benchmarkFile = path.resolve(root, manifest.benchmark?.artifact ?? ""); +const benchmark = readJson(benchmarkFile); + +validateManifest( + manifest, + GRAPH_PROVIDERS, + LANGUAGE_EXPERIMENTS, + benchmark, +); + +if (!validateOnly) { + const readmeFile = path.join(root, "README.md"); + const readme = fs.readFileSync(readmeFile, "utf8"); + const generated = [ + startMarker, + renderSupport(manifest), + endMarker, + ].join("\n"); + const next = replaceGeneratedBlock(readme, generated); + if (write) { + fs.writeFileSync(readmeFile, next); + } else if (next !== readme) { + throw new Error( + "provider support: README block is stale; run `pnpm provider-support:write` after building @samchon/graph", + ); + } +} + +function validateManifest( + support, + providers, + experiments, + benchmarkResult, +) { + invariant( + support.schemaVersion === 1, + "manifest schemaVersion must be 1", + ); + invariant( + Array.isArray(support.providers), + "manifest providers must be an array", + ); + invariant( + Array.isArray(support.ordinaryOnly), + "manifest ordinaryOnly must be an array", + ); + invariant( + support.benchmark?.kind === + "cold end-to-end strict versus strict-disabled pairs", + "benchmark kind must name the cold paired measurement", + ); + assertUrl(support.benchmark?.workflowRun, "benchmark workflowRun"); + invariant( + benchmarkResult.index?.schemaVersion === 2 && + Array.isArray(benchmarkResult.index?.cells), + "benchmark artifact must contain index schemaVersion 2 cells", + ); + + const providerNames = unique( + support.providers.map((provider) => provider.provider), + "manifest provider", + ); + const runtimeNames = providers.map((provider) => provider.name); + const missingProviders = runtimeNames.filter( + (provider) => !providerNames.includes(provider), + ); + const absentProviders = providerNames.filter( + (provider) => !runtimeNames.includes(provider), + ); + invariant( + missingProviders.length === 0, + `undocumented registered provider ${missingProviders.join(", ")}`, + ); + invariant( + absentProviders.length === 0, + `documented absent provider ${absentProviders.join(", ")}`, + ); + invariant( + equal(providerNames, runtimeNames), + "manifest providers must appear exactly once in GRAPH_PROVIDERS order", + ); + + const registeredLanguages = new Set(); + const benchmarkRows = new Map(); + for (const [index, documented] of support.providers.entries()) { + const provider = providers[index]; + invariant( + provider !== undefined && provider.name === documented.provider, + `documented absent provider ${documented.provider}`, + ); + for (const field of [ + "install", + "resolution", + "requirements", + "mode", + "nativeAnalysis", + "exportMerge", + "reuseResident", + "limitations", + "fallback", + ]) { + invariant( + typeof documented[field] === "string" && + documented[field].trim() !== "", + `${documented.provider} must define ${field}`, + ); + } + invariant( + documented.status === "registered", + `${documented.provider} status must be registered`, + ); + invariant( + Array.isArray(documented.platforms) && + documented.platforms.length > 0, + `${documented.provider} must name supported platforms`, + ); + assertUrl(documented.upstream, `${documented.provider} upstream`); + invariant( + Array.isArray(documented.childIssues) && + documented.childIssues.length > 0, + `${documented.provider} must name child issues`, + ); + unique(documented.childIssues, `${documented.provider} child issue`); + for (const issue of documented.childIssues) { + assertUrl(issue, `${documented.provider} child issue`); + } + invariant( + equal(documented.languages, provider.languages), + `${documented.provider} languages differ from GRAPH_PROVIDERS`, + ); + invariant( + documented.authority === provider.authority, + `${documented.provider} authority differs from GRAPH_PROVIDERS`, + ); + invariant( + equal(documented.facts, provider.facts), + `${documented.provider} facts differ from GRAPH_PROVIDERS`, + ); + invariant( + equal(documented.commands, provider.resolution?.commands), + `${documented.provider} commands differ from its resolver descriptor`, + ); + invariant( + equal( + documented.environmentOverrides, + provider.resolution?.environmentOverrides, + ), + `${documented.provider} environment overrides differ from its resolver descriptor`, + ); + invariant( + equal( + [...documented.experimentLanguages].sort(), + [...documented.languages].sort(), + ), + `${documented.provider} experiment languages must cover its registry languages`, + ); + invariant( + typeof documented.experimentTool === "string" && + documented.experimentTool !== "", + `${documented.provider} must name its experiment tool`, + ); + invariant( + Array.isArray(documented.experimentCapabilities) && + documented.experimentCapabilities.length > 0, + `${documented.provider} must name experiment capabilities`, + ); + + for (const language of documented.languages) { + invariant( + !registeredLanguages.has(language), + `registered language ${language} is documented more than once`, + ); + registeredLanguages.add(language); + const rows = experiments.filter( + (experiment) => experiment.language === language, + ); + invariant( + rows.length === 1, + `experiment catalog must contain one ${language} row`, + ); + const experiment = rows[0]; + invariant( + experiment.strictProvider === documented.provider, + `${language} experiment provider differs from the support manifest`, + ); + invariant( + experiment.strictAuthority === documented.authority, + `${language} experiment authority differs from the support manifest`, + ); + invariant( + experiment.strictTool === documented.experimentTool, + `${language} experiment tool differs from the support manifest`, + ); + invariant( + equal( + experiment.requiredCapabilities ?? [], + documented.experimentCapabilities, + ), + `${language} experiment capabilities differ from the support manifest`, + ); + for (const fact of experiment.semanticEdges ?? []) { + invariant( + documented.facts.includes(fact), + `${language} experiment requires undocumented ${fact} facts`, + ); + } + } + + invariant( + Array.isArray(documented.benchmarks) && + documented.benchmarks.length > 0, + `${documented.provider} must name benchmark evidence`, + ); + for (const row of documented.benchmarks) { + invariant( + typeof row.project === "string" && row.project !== "", + `${documented.provider} has a benchmark without a project`, + ); + invariant( + !benchmarkRows.has(row.project), + `benchmark project ${row.project} is documented more than once`, + ); + benchmarkRows.set(row.project, { + provider: documented.provider, + row, + }); + } + } + + const ordinaryLanguages = unique( + support.ordinaryOnly.map((row) => row.language), + "ordinary-only language", + ); + for (const row of support.ordinaryOnly) { + invariant( + typeof row.server === "string" && row.server !== "", + `${row.language} must name its ordinary server`, + ); + invariant( + typeof row.reason === "string" && row.reason !== "", + `${row.language} must explain its ordinary-only status`, + ); + assertUrl(row.issue, `${row.language} issue`); + invariant( + !registeredLanguages.has(row.language), + `${row.language} cannot be both registered and ordinary-only`, + ); + const rows = experiments.filter( + (experiment) => experiment.language === row.language, + ); + invariant( + rows.length === 1 && + rows[0].strictProvider === undefined && + rows[0].strictTool === undefined, + `${row.language} experiment must remain ordinary-only`, + ); + } + const experimentLanguages = unique( + experiments.map((experiment) => experiment.language), + "experiment language", + ); + invariant( + equal( + [...registeredLanguages, ...ordinaryLanguages].sort(), + [...experimentLanguages].sort(), + ), + "support manifest must classify every experiment language exactly once", + ); + + const cells = benchmarkResult.index.cells; + const cellProjects = [...new Set(cells.map((cell) => cell.project))]; + invariant( + equal([...benchmarkRows.keys()].sort(), [...cellProjects].sort()), + "manifest benchmark projects must match the exact artifact", + ); + for (const [project, documented] of benchmarkRows) { + const strict = cells.filter( + (cell) => cell.project === project && cell.strict === true, + ); + const fallback = cells.filter( + (cell) => cell.project === project && cell.strict === false, + ); + invariant( + strict.length === 1 && fallback.length === 1, + `${project} must have one strict and one strict-disabled cell`, + ); + invariant( + strict[0].measurementId === fallback[0].measurementId, + `${project} benchmark cells must come from one paired measurement`, + ); + invariant( + strict[0].servedBy.includes(documented.provider), + `${project} strict cell does not name ${documented.provider}`, + ); + if ( + Object.hasOwn(documented.row, "strictTimedOutMs") || + Object.hasOwn(documented.row, "fallbackTimedOutMs") + ) { + invariant( + documented.row.strictTimedOutMs === strict[0].timedOutMs && + strict[0].buildMs === null && + documented.row.fallbackTimedOutMs === fallback[0].timedOutMs && + fallback[0].buildMs === null, + `${project} timeout limits differ from the benchmark artifact`, + ); + } else { + invariant( + documented.row.strictMs === strict[0].buildMs && + documented.row.fallbackMs === fallback[0].buildMs, + `${project} timings differ from the benchmark artifact`, + ); + } + } +} + +function renderSupport(manifest) { + const capabilityRows = manifest.providers.map((provider) => [ + code(provider.provider), + provider.languages.map(code).join(", "), + code(provider.authority), + provider.facts.length === 0 + ? "**none**" + : provider.facts.map(code).join(", "), + [ + `[upstream](${provider.upstream})`, + ...provider.childIssues.map( + (issue) => + `[route #${issue.slice(issue.lastIndexOf("/") + 1)}](${issue})`, + ), + ].join(" / "), + ]); + const lifecycleRows = manifest.providers.map((provider) => [ + code(provider.provider), + code(provider.mode), + provider.requirements, + provider.nativeAnalysis, + provider.exportMerge, + provider.reuseResident, + ]); + const installRows = manifest.providers.map((provider) => [ + code(provider.provider), + provider.install, + provider.commands.map(code).join(", "), + provider.environmentOverrides.map(code).join(", "), + provider.resolution, + provider.requirements, + provider.platforms.map(code).join(", "), + ]); + const troubleshootingRows = manifest.providers.map((provider) => [ + provider.languages.map(code).join(", "), + code(provider.provider), + provider.limitations, + provider.fallback, + ]); + const benchmarkRows = manifest.providers.flatMap((provider) => + provider.benchmarks.map((benchmark) => [ + code(benchmark.project), + code(provider.provider), + Object.hasOwn(benchmark, "strictTimedOutMs") + ? `did not finish before ${seconds(benchmark.strictTimedOutMs)} s` + : milliseconds(benchmark.strictMs), + Object.hasOwn(benchmark, "fallbackTimedOutMs") + ? `did not finish before ${seconds(benchmark.fallbackTimedOutMs)} s` + : milliseconds(benchmark.fallbackMs), + ]), + ); + const ordinaryRows = manifest.ordinaryOnly.map((row) => [ + code(row.language), + code(row.server), + row.reason, + `[tracked route](${row.issue})`, + ]); + + return [ + "### Strict provider support", + "", + "_Generated from [`docs/provider-support.json`](https://github.com/samchon/compiler-graph/blob/master/docs/provider-support.json); do not edit this block by hand._", + "", + "Strict selection is per registered provider and may decline for missing tools, incompatible options, or incomplete build metadata. Authority grades differ. A provider's `facts` list means it can defend those edge families; it is not a universal-completeness claim. Strict dumps carry provider/tool provenance plus universe, input-manifest, and content digests. The MCP result reports operation coverage and uncertainty, but does not promise #63's future complete producer-owned per-generation coverage contract. Generic language-server and static fallbacks remain valid lower-authority results and are identified as such.", + "", + "#### Capability", + "", + table( + ["Provider", "Languages", "Authority", "Defensible facts", "Evidence"], + capabilityRows, + ), + "", + "#### Lifecycle", + "", + `These are current implementation modes, not future route claims. Preparation and native/export/resident phases are stated separately because the [experiment catalog](https://github.com/samchon/compiler-graph/blob/master/tests/experiment/src/catalog.mjs) and [cold measurement artifact](https://github.com/samchon/compiler-graph/blob/master/${manifest.benchmark.artifact}) prove different boundaries; the artifact reports whole end-to-end cells, not isolated phase timings.`, + "", + table( + ["Provider", "Mode", "Preparation", "Native analysis", "Export and merge", "Reuse or resident state"], + lifecycleRows, + ), + "", + "#### Installation and selection", + "", + "The troubleshooting table names the ordinary language-server/static fallback for each row. Resolution metadata is shared with the shipped registry and checked in CI.", + "", + table( + ["Provider", "Install", "Commands", "Overrides", "Resolution order", "Project preparation", "Platforms"], + installRows, + ), + "", + "#### Verified cold index cells", + "", + `These are exact same-run cold end-to-end strict/strict-disabled pairs from [\`${manifest.benchmark.artifact}\`](https://github.com/samchon/compiler-graph/blob/master/${manifest.benchmark.artifact}), produced by [the pinned workflow run](${manifest.benchmark.workflowRun}). They do not prove warm or semantic-incremental behavior. A zero-fact strict provider is not called semantically complete. Ruby and Dart report only that both whole cells exceeded the 1,800-second guard; that limit is not an isolated producer duration.`, + "", + table( + ["Project", "Strict provider", "Strict cell", "Strict-disabled cell"], + benchmarkRows, + ), + "", + "#### Troubleshooting", + "", + "A strict result's provenance name must equal the provider below. If it is absent, use the commands and overrides in the installation table, then follow the explicit decline reason; the fallback is still usable but does not inherit strict authority.", + "", + table( + ["Languages", "Expected provenance", "Common boundary", "Decline and fallback"], + troubleshootingRows, + ), + "", + "#### Ordinary-only strict status", + "", + "These languages are indexed through their ordinary server and static fallback today. They have no registered strict provider or strict timing claim.", + "", + table( + ["Language", "Ordinary server", "Why no strict provider", "Route"], + ordinaryRows, + ), + ].join("\n"); +} + +function replaceGeneratedBlock(readme, generated) { + const start = readme.indexOf(startMarker); + const end = readme.indexOf(endMarker); + invariant(start !== -1 && end !== -1, "README support markers are missing"); + invariant( + readme.indexOf(startMarker, start + startMarker.length) === -1 && + readme.indexOf(endMarker, end + endMarker.length) === -1, + "README support markers must be unique", + ); + invariant(start < end, "README support markers are reversed"); + return `${readme.slice(0, start)}${generated}${readme.slice( + end + endMarker.length, + )}`; +} + +function table(headers, rows) { + return [ + `| ${headers.map(cell).join(" | ")} |`, + `| ${headers.map(() => "---").join(" | ")} |`, + ...rows.map((row) => `| ${row.map(cell).join(" | ")} |`), + ].join("\n"); +} + +function cell(value) { + return String(value).replaceAll("|", "\\|").replace(/\r?\n/g, " "); +} + +function code(value) { + return `\`${value}\``; +} + +function milliseconds(value) { + return `${new Intl.NumberFormat("en-US", { + maximumFractionDigits: 3, + minimumFractionDigits: 3, + useGrouping: true, + }).format(value)} ms`; +} + +function seconds(value) { + return new Intl.NumberFormat("en-US").format(value / 1_000); +} + +function readJson(file) { + try { + return JSON.parse(fs.readFileSync(file, "utf8")); + } catch (error) { + throw new Error( + `provider support: cannot read ${path.relative(root, file)}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } +} + +function assertUrl(value, label) { + try { + const url = new URL(value); + invariant( + url.protocol === "https:", + `${label} must be an HTTPS URL`, + ); + } catch (error) { + if (error instanceof Error && error.message.startsWith("provider support:")) { + throw error; + } + throw new Error(`provider support: ${label} is not a valid URL`); + } +} + +function unique(values, label) { + const rows = new Set(values); + invariant(rows.size === values.length, `${label} rows must be unique`); + return [...rows]; +} + +function equal(left, right) { + return ( + Array.isArray(left) && + Array.isArray(right) && + left.length === right.length && + left.every((value, index) => value === right[index]) + ); +} + +function invariant(condition, message) { + if (!condition) throw new Error(`provider support: ${message}`); +} diff --git a/packages/graph/package.json b/packages/graph/package.json index 143dc110..174c4980 100644 --- a/packages/graph/package.json +++ b/packages/graph/package.json @@ -15,8 +15,8 @@ "./package.json": "./package.json" }, "scripts": { - "build": "pnpm --filter @samchon/graph-sitter build && rimraf lib && ttsc -p tsconfig.json && node build/copy-sidecars.mjs && node build/bundle-viewer.mjs && node -e \"require('node:fs').copyFileSync('../../README.md', 'README.md')\"", - "prepublishOnly": "node build/copy-sidecars.mjs && node -e \"require('node:fs').copyFileSync('../../README.md', 'README.md')\"" + "build": "pnpm --filter @samchon/graph-sitter build && rimraf lib && ttsc -p tsconfig.json && node build/provider-support.mjs --check && node build/copy-sidecars.mjs && node build/bundle-viewer.mjs && node -e \"require('node:fs').copyFileSync('../../README.md', 'README.md')\"", + "prepublishOnly": "node build/provider-support.mjs --check && node build/copy-sidecars.mjs && node -e \"require('node:fs').copyFileSync('../../README.md', 'README.md')\"" }, "keywords": [ "mcp", diff --git a/packages/graph/src/provider/IGraphProvider.ts b/packages/graph/src/provider/IGraphProvider.ts index 17e1026f..8784fa0b 100644 --- a/packages/graph/src/provider/IGraphProvider.ts +++ b/packages/graph/src/provider/IGraphProvider.ts @@ -68,6 +68,16 @@ export interface IGraphProvider { */ readonly facts: readonly GraphEdgeKind[]; + /** + * Machine-readable command-selection surface shared with public support + * documentation. + * + * These are resolver inputs, not prose copied out of a closure. Keeping them + * on the registry entry lets CI prove that every documented executable and + * environment override is the one runtime selection actually consults. + */ + readonly resolution?: IGraphProvider.IResolution; + /** * Why this provider cannot serve a build with these options, or `undefined` * when it can. @@ -142,6 +152,11 @@ export interface IGraphProvider { } export namespace IGraphProvider { + export interface IResolution { + readonly commands: readonly string[]; + readonly environmentOverrides: readonly string[]; + } + export interface IConfigurationDerivation { rows: readonly string[]; inconclusive: readonly number[]; diff --git a/packages/graph/src/provider/go/goGraphProvider.ts b/packages/graph/src/provider/go/goGraphProvider.ts index 353c325f..6e7df11a 100644 --- a/packages/graph/src/provider/go/goGraphProvider.ts +++ b/packages/graph/src/provider/go/goGraphProvider.ts @@ -10,6 +10,30 @@ import { resolveProviderCommand } from "../resolveProviderCommand"; import { toolchainVersion } from "../toolchainVersion"; import { sidecarProvider } from "../sidecar"; +const GO_GRAPH_TOOLS = Object.freeze({ + exporter: Object.freeze({ + command: "samchon-graph-go", + override: "SAMCHON_GRAPH_GO", + }), + toolchain: Object.freeze({ + command: "go", + override: "SAMCHON_GRAPH_GO_TOOLCHAIN", + }), + corroborator: Object.freeze({ + command: "scip-go", + override: "SAMCHON_GRAPH_SCIP_GO", + }), +}); + +const GO_GRAPH_RESOLUTION = Object.freeze({ + commands: Object.freeze( + Object.values(GO_GRAPH_TOOLS).map((tool) => tool.command), + ), + environmentOverrides: Object.freeze( + Object.values(GO_GRAPH_TOOLS).map((tool) => tool.override), + ), +}) satisfies IGraphProvider.IResolution; + function goIndexArgs(artifact: string): string[] { return [`--output=${artifact}`]; } @@ -47,6 +71,7 @@ export const goGraphProvider = Object.assign( "tests", "references", ] satisfies readonly GraphEdgeKind[], + resolution: GO_GRAPH_RESOLUTION, buildInputs: goBuildInputs, resolve: resolveGoGraphCommand, indexArgs: goIndexArgs, @@ -82,8 +107,7 @@ function resolveGoGraphCommand( env: NodeJS.ProcessEnv, ): IGraphProvider.ICommand | undefined { const installed = resolveProviderCommand(root, env, { - command: "samchon-graph-go", - override: "SAMCHON_GRAPH_GO", + ...GO_GRAPH_TOOLS.exporter, }); if (installed !== undefined) { return spawnableCommand.append( @@ -94,8 +118,7 @@ function resolveGoGraphCommand( const source = path.resolve(__dirname, "..", "..", "..", "sidecars", "go"); if (!fs.existsSync(path.join(source, "go.mod"))) return undefined; const go = resolveProviderCommand(root, env, { - command: "go", - override: "SAMCHON_GRAPH_GO_TOOLCHAIN", + ...GO_GRAPH_TOOLS.toolchain, }); return go === undefined ? undefined @@ -237,16 +260,15 @@ function goConfigurationDerivation( toolchainVersion.observe({ root, env, - command: "go", - override: "SAMCHON_GRAPH_GO_TOOLCHAIN", + ...GO_GRAPH_TOOLS.toolchain, args: ["env", "-json", ...GO_PROBED_ENVIRONMENT_KEYS], label: "go-env", }), toolObservation( root, env, - "scip-go", - "SAMCHON_GRAPH_SCIP_GO", + GO_GRAPH_TOOLS.corroborator.command, + GO_GRAPH_TOOLS.corroborator.override, ["--version"], ), ]); @@ -334,8 +356,8 @@ const GO_ENVIRONMENT_KEYS: readonly string[] = [ "GOTOOLCHAIN", "GOWORK", "PATH", - "SAMCHON_GRAPH_SCIP_GO", - "SAMCHON_GRAPH_GO_TOOLCHAIN", + GO_GRAPH_TOOLS.corroborator.override, + GO_GRAPH_TOOLS.toolchain.override, "PKG_CONFIG", ]; diff --git a/packages/graph/src/provider/lua/luaGraphProvider.ts b/packages/graph/src/provider/lua/luaGraphProvider.ts index 12d9f011..70c5f207 100644 --- a/packages/graph/src/provider/lua/luaGraphProvider.ts +++ b/packages/graph/src/provider/lua/luaGraphProvider.ts @@ -11,6 +11,20 @@ import { LuaGraphSession } from "./LuaGraphSession"; const BUILD_FILES = [".luarc.json", ".luarc.jsonc"] as const; const BUILD_EXTENSIONS = [".rockspec"] as const; +const LUA_GRAPH_TOOLS = Object.freeze({ + server: Object.freeze({ + command: "lua-language-server", + override: "SAMCHON_GRAPH_LUA", + }), + exporterOverride: "SAMCHON_GRAPH_LUA_EXPORTER", +}); +const LUA_GRAPH_RESOLUTION = Object.freeze({ + commands: Object.freeze([LUA_GRAPH_TOOLS.server.command]), + environmentOverrides: Object.freeze([ + LUA_GRAPH_TOOLS.server.override, + LUA_GRAPH_TOOLS.exporterOverride, + ]), +}) satisfies IGraphProvider.IResolution; /** * Lua, indexed by driving lua-language-server's own analysis engine. @@ -30,6 +44,7 @@ export const luaGraphProvider: IGraphProvider = { languages: ["lua"], authority: "analyzer", facts: [...LuaGraphSession.FACTS], + resolution: LUA_GRAPH_RESOLUTION, buildInputs: (root) => providerInputFiles(root, [], BUILD_FILES, BUILD_EXTENSIONS), @@ -65,8 +80,7 @@ export const luaGraphProvider: IGraphProvider = { resolve: (root, env) => { if (inspectExporter(env).status !== "available") return undefined; return resolveProviderCommand(root, env, { - command: "lua-language-server", - override: "SAMCHON_GRAPH_LUA", + ...LUA_GRAPH_TOOLS.server, }); }, @@ -140,8 +154,7 @@ function luaConfiguration( toolchainVersion.observe({ root, env, - command: "lua-language-server", - override: "SAMCHON_GRAPH_LUA", + ...LUA_GRAPH_TOOLS.server, args: ["--version"], ...(resolved === undefined ? {} : { resolved }), }), @@ -187,7 +200,7 @@ function luaExporterConfiguration( * tool look away. */ function inspectExporter(env: NodeJS.ProcessEnv): IExporterInspection { - const named = env.SAMCHON_GRAPH_LUA_EXPORTER; + const named = env[LUA_GRAPH_TOOLS.exporterOverride]; const script = named !== undefined && named !== "" ? path.resolve(named) diff --git a/packages/graph/src/provider/rust/rustScipProvider.ts b/packages/graph/src/provider/rust/rustScipProvider.ts index 5fc62790..425f606c 100644 --- a/packages/graph/src/provider/rust/rustScipProvider.ts +++ b/packages/graph/src/provider/rust/rustScipProvider.ts @@ -12,6 +12,33 @@ import { resolveProviderCommand } from "../resolveProviderCommand"; import { toolchainVersion } from "../toolchainVersion"; import { scipProvider } from "../scip"; +const RUST_GRAPH_TOOLS = Object.freeze({ + analyzer: Object.freeze({ + command: "rust-analyzer", + override: "SAMCHON_GRAPH_RUST_ANALYZER", + }), + decoder: Object.freeze({ + command: "scip", + override: "SAMCHON_GRAPH_SCIP", + }), + compiler: Object.freeze({ + command: "rustc", + override: "SAMCHON_GRAPH_RUSTC", + }), + cargo: Object.freeze({ + command: "cargo", + override: "SAMCHON_GRAPH_CARGO", + }), +}); +const RUST_GRAPH_RESOLUTION = Object.freeze({ + commands: Object.freeze( + Object.values(RUST_GRAPH_TOOLS).map((tool) => tool.command), + ), + environmentOverrides: Object.freeze( + Object.values(RUST_GRAPH_TOOLS).map((tool) => tool.override), + ), +}) satisfies IGraphProvider.IResolution; + /** * rust-analyzer's stock SCIP export is a navigation artifact, not HIR facts. * @@ -45,6 +72,7 @@ export const rustScipProvider = Object.assign( languageOf, }), { + resolution: RUST_GRAPH_RESOLUTION, indexArgs: rustScipIndexArgs, inputs: rustInputs, decodeCommand: rustScipDecoder, @@ -60,12 +88,27 @@ function resolveRustScipCommand( const analyzer = resolveTool( root, env, - "rust-analyzer", - "SAMCHON_GRAPH_RUST_ANALYZER", + RUST_GRAPH_TOOLS.analyzer.command, + RUST_GRAPH_TOOLS.analyzer.override, + ); + const decoder = resolveTool( + root, + env, + RUST_GRAPH_TOOLS.decoder.command, + RUST_GRAPH_TOOLS.decoder.override, + ); + const rustc = resolveTool( + root, + env, + RUST_GRAPH_TOOLS.compiler.command, + RUST_GRAPH_TOOLS.compiler.override, + ); + const cargo = resolveTool( + root, + env, + RUST_GRAPH_TOOLS.cargo.command, + RUST_GRAPH_TOOLS.cargo.override, ); - const decoder = resolveTool(root, env, "scip", "SAMCHON_GRAPH_SCIP"); - const rustc = resolveTool(root, env, "rustc", "SAMCHON_GRAPH_RUSTC"); - const cargo = resolveTool(root, env, "cargo", "SAMCHON_GRAPH_CARGO"); if ( analyzer === undefined || decoder === undefined || @@ -84,7 +127,12 @@ function rustScipDecoder( root: string, env: NodeJS.ProcessEnv = process.env, ): IGraphProvider.ICommand { - const decoder = resolveTool(root, env, "scip", "SAMCHON_GRAPH_SCIP"); + const decoder = resolveTool( + root, + env, + RUST_GRAPH_TOOLS.decoder.command, + RUST_GRAPH_TOOLS.decoder.override, + ); if (decoder === undefined) { throw new Error( "rust-analyzer-scip: the SCIP decoder disappeared after provider selection", @@ -180,19 +228,31 @@ function rustScipConfigurationDerivation( toolObservation( root, env, - "rust-analyzer", - "SAMCHON_GRAPH_RUST_ANALYZER", + RUST_GRAPH_TOOLS.analyzer.command, + RUST_GRAPH_TOOLS.analyzer.override, ["--version"], ), toolObservation( root, env, - "scip", - "SAMCHON_GRAPH_SCIP", + RUST_GRAPH_TOOLS.decoder.command, + RUST_GRAPH_TOOLS.decoder.override, ["--version"], ), - toolObservation(root, env, "rustc", "SAMCHON_GRAPH_RUSTC", ["-vV"]), - toolObservation(root, env, "cargo", "SAMCHON_GRAPH_CARGO", ["-V"]), + toolObservation( + root, + env, + RUST_GRAPH_TOOLS.compiler.command, + RUST_GRAPH_TOOLS.compiler.override, + ["-vV"], + ), + toolObservation( + root, + env, + RUST_GRAPH_TOOLS.cargo.command, + RUST_GRAPH_TOOLS.cargo.override, + ["-V"], + ), ]); } @@ -250,7 +310,10 @@ function rustCompilerVersion( _languages: readonly GraphLanguage[] | undefined, configuration: readonly string[], ): string { - const wanted = new Set(["rustc", "cargo"]); + const wanted = new Set([ + RUST_GRAPH_TOOLS.compiler.command, + RUST_GRAPH_TOOLS.cargo.command, + ]); return configuration .filter((row) => wanted.has(row.slice(0, Math.max(0, row.indexOf("="))))) .join("; "); @@ -264,11 +327,17 @@ function rustCompilerVersionFor( toolVersion( root, env, - "rustc", - "SAMCHON_GRAPH_RUSTC", + RUST_GRAPH_TOOLS.compiler.command, + RUST_GRAPH_TOOLS.compiler.override, ["-vV"], ), - toolVersion(root, env, "cargo", "SAMCHON_GRAPH_CARGO", ["-V"]), + toolVersion( + root, + env, + RUST_GRAPH_TOOLS.cargo.command, + RUST_GRAPH_TOOLS.cargo.override, + ["-V"], + ), ].join("; "); } @@ -338,9 +407,9 @@ const RUST_ENVIRONMENT_KEYS: readonly string[] = [ "RUSTFLAGS", "RUSTUP_HOME", "RUSTUP_TOOLCHAIN", - "SAMCHON_GRAPH_CARGO", - "SAMCHON_GRAPH_RUST_ANALYZER", - "SAMCHON_GRAPH_RUSTC", - "SAMCHON_GRAPH_SCIP", + RUST_GRAPH_TOOLS.cargo.override, + RUST_GRAPH_TOOLS.analyzer.override, + RUST_GRAPH_TOOLS.compiler.override, + RUST_GRAPH_TOOLS.decoder.override, ]; const RUST_ENVIRONMENT_KEY_SET = new Set(RUST_ENVIRONMENT_KEYS); diff --git a/packages/graph/src/provider/scip/standardScipProviders.ts b/packages/graph/src/provider/scip/standardScipProviders.ts index f0d45926..c061f5a0 100644 --- a/packages/graph/src/provider/scip/standardScipProviders.ts +++ b/packages/graph/src/provider/scip/standardScipProviders.ts @@ -14,6 +14,11 @@ import { resolveProviderCommand } from "../resolveProviderCommand"; import { toolchainVersion } from "../toolchainVersion"; import { scipProvider } from "./scipProvider"; +const SCIP_DECODER = Object.freeze({ + command: "scip", + override: "SAMCHON_GRAPH_SCIP", +}); + const clangScipProvider = createScipProvider({ name: "scip-clang", // scip-clang 0.4.0 writes occurrence range/symbol/roles only. Its @@ -492,115 +497,139 @@ function createScipProvider( ): IGraphProvider { const validateConfiguration = props.validateConfiguration; const producerConfiguration = props.producerConfiguration; - return scipProvider({ - name: props.name, - languages: props.languages, - authority: "semantic-index", - omitFacts: props.omitFacts, - ...(props.preferFileLanguage === undefined - ? {} - : { preferFileLanguage: props.preferFileLanguage }), - buildInputs: (root) => - withDerived( - providerInputFiles(root, [], props.buildFiles, props.buildExtensions), - props.derivedInputs?.(root), - ), - resolve: (root, env) => { - const indexer = resolveProviderCommand(root, env, { - command: props.command, - override: props.override, - }); - const decoder = resolveScipDecoder(root, env); - // The toolchain is required, not merely reported. A snapshot states which - // language version resolved its facts, and a provider that cannot answer - // that would publish `unavailable` into the field a consumer degrades - // against — which is worse than declining, because a fallback at least - // says so. `rust-analyzer-scip` refuses without `rustc` and `cargo` for - // the same reason. - // - // What has to resolve is the toolchain the project actually uses, not one - // chosen name for it. Requiring `clang` declined every GCC or MSVC project - // whose compilation database scip-clang would have consumed, and requiring - // `python3` declined a Windows interpreter installed as `python`. - const toolchain = resolveToolchain(root, env, props.toolchain); - const resolvedArgs = props.resolveArgs?.(root); - if ( - indexer === undefined || - decoder === undefined || - toolchain.some((tool) => tool.resolved === undefined) || - (props.resolveArgs !== undefined && resolvedArgs === undefined) - ) { - return undefined; - } - const args = resolvedArgs ?? []; - return spawnableCommand.append( - { ...indexer, args: [...indexer.args] }, - args, - ); - }, - decode: (root) => { - const decoder = resolveScipDecoder(root, process.env); - if (decoder === undefined) { - throw new Error( - `${props.name}: the SCIP decoder disappeared after provider selection`, - ); - } - return spawnableCommand.append( - { ...decoder, args: [...decoder.args] }, - ["print", "--json"], - ); - }, - indexArgs: props.indexArgs, - ...(props.artifactFrom === undefined - ? {} - : { artifactFrom: props.artifactFrom }), - inputs: (root, languages) => - withDerived( - providerInputFiles( - root, - languages, - props.buildFiles, - props.buildExtensions, + const resolution = Object.freeze({ + commands: Object.freeze([ + props.command, + SCIP_DECODER.command, + ...(props.toolchain.aliases ?? [props.toolchain.label]), + ]), + environmentOverrides: Object.freeze([ + props.override, + SCIP_DECODER.override, + ...(props.toolchain.override === undefined + ? [] + : [props.toolchain.override]), + ]), + }) satisfies IGraphProvider.IResolution; + return Object.assign( + scipProvider({ + name: props.name, + languages: props.languages, + authority: "semantic-index", + omitFacts: props.omitFacts, + ...(props.preferFileLanguage === undefined + ? {} + : { preferFileLanguage: props.preferFileLanguage }), + buildInputs: (root) => + withDerived( + providerInputFiles(root, [], props.buildFiles, props.buildExtensions), + props.derivedInputs?.(root), ), - props.derivedInputs?.(root), - ), - ...(validateConfiguration === undefined - ? {} - : { - validateConfiguration: ( + resolve: (root, env) => { + const indexer = resolveProviderCommand(root, env, { + command: props.command, + override: props.override, + }); + const decoder = resolveScipDecoder(root, env); + // The toolchain is required, not merely reported. A snapshot states which + // language version resolved its facts, and a provider that cannot answer + // that would publish `unavailable` into the field a consumer degrades + // against — which is worse than declining, because a fallback at least + // says so. `rust-analyzer-scip` refuses without `rustc` and `cargo` for + // the same reason. + // + // What has to resolve is the toolchain the project actually uses, not one + // chosen name for it. Requiring `clang` declined every GCC or MSVC project + // whose compilation database scip-clang would have consumed, and requiring + // `python3` declined a Windows interpreter installed as `python`. + const toolchain = resolveToolchain(root, env, props.toolchain); + const resolvedArgs = props.resolveArgs?.(root); + if ( + indexer === undefined || + decoder === undefined || + toolchain.some((tool) => tool.resolved === undefined) || + (props.resolveArgs !== undefined && resolvedArgs === undefined) + ) { + return undefined; + } + const args = resolvedArgs ?? []; + return spawnableCommand.append( + { ...indexer, args: [...indexer.args] }, + args, + ); + }, + decode: (root) => { + const decoder = resolveScipDecoder(root, process.env); + if (decoder === undefined) { + throw new Error( + `${props.name}: the SCIP decoder disappeared after provider selection`, + ); + } + return spawnableCommand.append( + { ...decoder, args: [...decoder.args] }, + ["print", "--json"], + ); + }, + indexArgs: props.indexArgs, + ...(props.artifactFrom === undefined + ? {} + : { artifactFrom: props.artifactFrom }), + inputs: (root, languages) => + withDerived( + providerInputFiles( root, - _languages, - configuration, - ) => validateConfiguration(root, configuration), - }), - configuration: (root, _languages, env = process.env) => { - const producerRow = - producerConfiguration === undefined - ? toolVersion(root, env, props.command, props.override) - : producerConfiguration( + languages, + props.buildFiles, + props.buildExtensions, + ), + props.derivedInputs?.(root), + ), + ...(validateConfiguration === undefined + ? {} + : { + validateConfiguration: ( root, - env, - resolveProviderCommand.attempt(root, env, { - command: props.command, - override: props.override, - }), - ); - return toolchainVersion.derive([ - producerRow, - toolVersion(root, env, "scip", "SAMCHON_GRAPH_SCIP"), - ...toolchainVersions(root, env, props.toolchain), - ]); + _languages, + configuration, + ) => validateConfiguration(root, configuration), + }), + configuration: (root, _languages, env = process.env) => { + const producerRow = + producerConfiguration === undefined + ? toolVersion(root, env, props.command, props.override) + : producerConfiguration( + root, + env, + resolveProviderCommand.attempt(root, env, { + command: props.command, + override: props.override, + }), + ); + return toolchainVersion.derive([ + producerRow, + toolVersion( + root, + env, + SCIP_DECODER.command, + SCIP_DECODER.override, + ), + ...toolchainVersions(root, env, props.toolchain), + ]); + }, + // Selected from the configuration rather than re-derived, so the + // published compiler is the one this universe was computed from. Labelled + // rather than positional: the indexer and the decoder are named exactly, + // and whatever remains is the toolchain. + compilerVersion: (_root, selectedLanguages, configuration) => + props.compilerVersion?.(selectedLanguages, configuration) ?? + standardCompilerVersion(props.command, configuration), + sourceText: true, + languageOf, + }), + { + resolution, }, - // Selected from the configuration rather than re-derived, so the - // published compiler is the one this universe was computed from. Labelled - // rather than positional: the indexer and the decoder are named exactly, - // and whatever remains is the toolchain. - compilerVersion: (_root, selectedLanguages, configuration) => - props.compilerVersion?.(selectedLanguages, configuration) ?? - standardCompilerVersion(props.command, configuration), - sourceText: true, - languageOf, - }); + ); } /** @@ -1703,8 +1732,8 @@ function resolveScipDecoder( env: NodeJS.ProcessEnv, ): IGraphProvider.ICommand | undefined { return resolveProviderCommand(root, env, { - command: "scip", - override: "SAMCHON_GRAPH_SCIP", + command: SCIP_DECODER.command, + override: SCIP_DECODER.override, }); } diff --git a/packages/graph/src/provider/sidecar/sidecarProvider.ts b/packages/graph/src/provider/sidecar/sidecarProvider.ts index 91297629..727a60d8 100644 --- a/packages/graph/src/provider/sidecar/sidecarProvider.ts +++ b/packages/graph/src/provider/sidecar/sidecarProvider.ts @@ -18,6 +18,9 @@ export function sidecarProvider( languages: props.languages, authority: props.authority, facts: props.facts, + ...(props.resolution === undefined + ? {} + : { resolution: props.resolution }), ...(props.buildInputs === undefined ? {} : { buildInputs: props.buildInputs }), @@ -88,6 +91,7 @@ export namespace sidecarProvider { languages: readonly GraphLanguage[]; authority: GraphProviderAuthority; facts: readonly GraphEdgeKind[]; + resolution?: IGraphProvider.IResolution; buildInputs?: IGraphProvider["buildInputs"]; resolve: IGraphProvider["resolve"]; prepare?: IGraphProvider["prepare"]; diff --git a/packages/graph/src/provider/ttscgraph/resolveTtscGraphCommand.ts b/packages/graph/src/provider/ttscgraph/resolveTtscGraphCommand.ts index bfbf20fa..c4a89dee 100644 --- a/packages/graph/src/provider/ttscgraph/resolveTtscGraphCommand.ts +++ b/packages/graph/src/provider/ttscgraph/resolveTtscGraphCommand.ts @@ -5,6 +5,13 @@ import path from "node:path"; import { isSpawnableFile } from "../../utils/isSpawnableFile"; import { spawnableCommand } from "../../utils/spawnableCommand"; +import { ttscGraphResolution } from "./ttscGraphResolution"; + +const [TTSC_GRAPH_COMMAND, TTSC_SERVER_COMMAND] = + ttscGraphResolution.commands; +const [TTSC_GRAPH_OVERRIDE] = + ttscGraphResolution.environmentOverrides; + interface ITtscGraphCommand { command: string; args: string[]; @@ -15,7 +22,7 @@ export function resolveTtscGraphCommand( root: string, env: NodeJS.ProcessEnv = process.env, ): ITtscGraphCommand | undefined { - const override = env.TTSC_GRAPH_BINARY; + const override = env[TTSC_GRAPH_OVERRIDE]; if ( override !== undefined && path.isAbsolute(override) && @@ -36,7 +43,12 @@ export function resolveTtscGraphCommand( // A package-manager shim can still reveal the project installation when its // package metadata is not directly resolvable (for example, an unusual // linked layout). Search only the target project's .bin at this stage. - const projectServer = resolveExecutable("ttscserver", root, env, false); + const projectServer = resolveExecutable( + TTSC_SERVER_COMMAND, + root, + env, + false, + ); if (projectServer !== undefined) { const beside = graphBesideServer(projectServer); if (beside !== undefined) return beside; @@ -44,10 +56,15 @@ export function resolveTtscGraphCommand( // Only after project-owned candidates fail may PATH/global installations be // used as a compatibility fallback. - const onPath = resolveExecutable("ttscgraph", root, env, true); + const onPath = resolveExecutable(TTSC_GRAPH_COMMAND, root, env, true); if (onPath !== undefined) return spawnable(onPath); - const globalServer = resolveExecutable("ttscserver", root, env, true); + const globalServer = resolveExecutable( + TTSC_SERVER_COMMAND, + root, + env, + true, + ); if (globalServer !== undefined && globalServer !== projectServer) { return graphBesideServer(globalServer); } diff --git a/packages/graph/src/provider/ttscgraph/ttscGraphProvider.ts b/packages/graph/src/provider/ttscgraph/ttscGraphProvider.ts index b26d4f9f..f9621ce5 100644 --- a/packages/graph/src/provider/ttscgraph/ttscGraphProvider.ts +++ b/packages/graph/src/provider/ttscgraph/ttscGraphProvider.ts @@ -2,6 +2,7 @@ import { IGraphProvider } from "../IGraphProvider"; import { assertGraphSnapshotContract } from "../assertGraphSnapshotContract"; import { adaptTtscGraphDump } from "./adaptTtscGraphDump"; import { resolveTtscGraphCommand } from "./resolveTtscGraphCommand"; +import { ttscGraphResolution } from "./ttscGraphResolution"; import { TtscGraphClient } from "./TtscGraphClient"; import { ttscGraphStrictRefusal } from "./ttscGraphStrictRefusal"; @@ -24,6 +25,7 @@ export const ttscGraphProvider: IGraphProvider = { authority: "compiler", facts: adaptTtscGraphDump.EDGE_KINDS, + resolution: ttscGraphResolution, // A `tsconfig` change can add or drop whole files from the program, and a // `package.json` change can move the resolution roots those files import diff --git a/packages/graph/src/provider/ttscgraph/ttscGraphResolution.ts b/packages/graph/src/provider/ttscgraph/ttscGraphResolution.ts new file mode 100644 index 00000000..51c447d6 --- /dev/null +++ b/packages/graph/src/provider/ttscgraph/ttscGraphResolution.ts @@ -0,0 +1,6 @@ +import { IGraphProvider } from "../IGraphProvider"; + +export const ttscGraphResolution = Object.freeze({ + commands: Object.freeze(["ttscgraph", "ttscserver"] as const), + environmentOverrides: Object.freeze(["TTSC_GRAPH_BINARY"] as const), +}) satisfies IGraphProvider.IResolution; diff --git a/tests/test-graph/src/features/test_provider_support_manifest_matches_registry_and_evidence.ts b/tests/test-graph/src/features/test_provider_support_manifest_matches_registry_and_evidence.ts new file mode 100644 index 00000000..b08436b3 --- /dev/null +++ b/tests/test-graph/src/features/test_provider_support_manifest_matches_registry_and_evidence.ts @@ -0,0 +1,87 @@ +import { TestValidator } from "@nestia/e2e"; +import fs from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; + +import { GraphPaths } from "../internal/GraphPaths"; + +export const test_provider_support_manifest_matches_registry_and_evidence = + () => { + const root = GraphPaths.createTempDirectory( + "samchon-graph-provider-support-", + ); + const canonical = path.join( + GraphPaths.repositoryRoot, + "docs", + "provider-support.json", + ); + try { + TestValidator.equals( + "the canonical support manifest matches registry, experiments and benchmark evidence", + validate(canonical, root), + { status: 0, stderr: "" }, + ); + + const parsed = JSON.parse(fs.readFileSync(canonical, "utf8")) as { + providers: Array>; + }; + const missing = structuredClone(parsed); + missing.providers.shift(); + const missingFile = path.join(root, "missing-provider.json"); + fs.writeFileSync(missingFile, JSON.stringify(missing)); + TestValidator.predicate( + "an undocumented registered provider fails closed", + validate(missingFile, root).stderr.includes( + "undocumented registered provider ttscgraph", + ), + ); + + const absent = structuredClone(parsed); + absent.providers.push({ + ...absent.providers[0], + provider: "absent-provider", + languages: ["absent-language"], + }); + const absentFile = path.join(root, "absent-provider.json"); + fs.writeFileSync(absentFile, JSON.stringify(absent)); + TestValidator.predicate( + "a documented absent provider fails closed", + validate(absentFile, root).stderr.includes( + "documented absent provider absent-provider", + ), + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }; + +function validate( + manifest: string, + coverageRoot: string, +): { + status: number | null; + stderr: string; +} { + const script = path.join( + GraphPaths.graphPackageRoot, + "build", + "provider-support.mjs", + ); + const result = spawnSync( + process.execPath, + [script, "--validate-only", `--manifest=${manifest}`], + { + cwd: GraphPaths.repositoryRoot, + encoding: "utf8", + env: { + ...process.env, + NODE_V8_COVERAGE: path.join(coverageRoot, "child-coverage"), + }, + windowsHide: true, + }, + ); + return { + status: result.status, + stderr: result.stderr, + }; +} diff --git a/tests/test-graph/src/features/test_readme_names_a_published_ttsc_install_range.ts b/tests/test-graph/src/features/test_readme_names_a_published_ttsc_install_range.ts index 0f0bf526..b6b223ca 100644 --- a/tests/test-graph/src/features/test_readme_names_a_published_ttsc_install_range.ts +++ b/tests/test-graph/src/features/test_readme_names_a_published_ttsc_install_range.ts @@ -6,7 +6,7 @@ import { GraphPaths } from "../internal/GraphPaths"; /** The public install command must name a version npm actually serves. */ export const test_readme_names_a_published_ttsc_install_range = () => { - const install = "npm i -D ttsc@^0.20.1 typescript"; + const install = "npm i -D ttsc@^0.23.0 typescript"; const goInstall = "go install github.com/scip-code/scip-go/cmd/scip-go@v0.2.7"; for (const readme of [ @@ -19,8 +19,8 @@ export const test_readme_names_a_published_ttsc_install_range = () => { text.includes(install), ); TestValidator.predicate( - `${path.relative(GraphPaths.repositoryRoot, readme)} does not predict an unpublished ttsc line`, - text.includes("ttsc@^0.20.2") === false, + `${path.relative(GraphPaths.repositoryRoot, readme)} does not retain the obsolete ttsc line`, + text.includes("ttsc@^0.20.1") === false, ); TestValidator.predicate( `${path.relative(GraphPaths.repositoryRoot, readme)} pins the Go navigation producer used by the bundled provider`, From 01e633b5f19c7d96355e6aeec8d4050f86d9fc38 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 31 Jul 2026 20:14:22 +0900 Subject: [PATCH 16/52] fix: close provider support truth gaps Close #143: [Docs] The README documents two strict providers while the registry ships eleven --- README.md | 52 +++++++-------- docs/provider-support.json | 62 ++++++++++++++++-- packages/graph/build/provider-support.mjs | 64 ++++++++++++++++++- packages/graph/src/provider/IGraphProvider.ts | 6 ++ .../provider/scip/standardScipProviders.ts | 16 ++++- ..._manifest_matches_registry_and_evidence.ts | 43 ++++++++++++- 6 files changed, 206 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 5d85ce25..b13a61cc 100644 --- a/README.md +++ b/README.md @@ -104,19 +104,19 @@ These are current implementation modes, not future route claims. Preparation and The troubleshooting table names the ordinary language-server/static fallback for each row. Resolution metadata is shared with the shipped registry and checked in CI. -| Provider | Install | Commands | Overrides | Resolution order | Project preparation | Platforms | -| --- | --- | --- | --- | --- | --- | --- | -| `ttscgraph` | `npm i -D ttsc@^0.23.0 typescript` | `ttscgraph`, `ttscserver` | `TTSC_GRAPH_BINARY` | Absolute `TTSC_GRAPH_BINARY`, target-project `ttsc` package/binary, target-project `.bin`, then PATH/global compatibility fallback. | A matching ttsc/TypeScript project and tsconfig/jsconfig/package inputs. | `linux`, `macos`, `windows` | -| `samchon-graph-go` | Go 1.25+; the package ships the Go exporter source. Install corroboration with `go install github.com/scip-code/scip-go/cmd/scip-go@v0.2.7`. | `samchon-graph-go`, `go`, `scip-go` | `SAMCHON_GRAPH_GO`, `SAMCHON_GRAPH_GO_TOOLCHAIN`, `SAMCHON_GRAPH_SCIP_GO` | Project/PATH `samchon-graph-go`, then the shipped source runner through Go; absolute environment overrides take precedence. | Go workspace/module inputs, selected GOOS/GOARCH/cgo environment, embedded files and vendored inputs. | `linux`, `macos`, `windows` | -| `samchon-graph-lua` | Install `lua-language-server`; the package ships `sidecars/lua/export.lua`. | `lua-language-server` | `SAMCHON_GRAPH_LUA`, `SAMCHON_GRAPH_LUA_EXPORTER` | Absolute `SAMCHON_GRAPH_LUA`, then project/PATH LuaLS; the shipped exporter may be replaced by `SAMCHON_GRAPH_LUA_EXPORTER`. | LuaLS workspace configuration and the shipped readable exporter. | `linux`, `macos`, `windows` | -| `rust-analyzer-scip` | `rustup component add rust-analyzer`; install the `scip` decoder and provide matching rustc/Cargo. | `rust-analyzer`, `scip`, `rustc`, `cargo` | `SAMCHON_GRAPH_RUST_ANALYZER`, `SAMCHON_GRAPH_SCIP`, `SAMCHON_GRAPH_RUSTC`, `SAMCHON_GRAPH_CARGO` | Project-local tools precede PATH; each absolute environment override replaces only its named tool. | Cargo metadata/config, lock/toolchain inputs, target/features/cfg and build-script/proc-macro universe. | `linux`, `macos`, `windows` | -| `scip-clang` | Install `scip-clang` 0.4.0 and the `scip` decoder; provide a generated `compile_commands.json`. | `scip-clang`, `scip`, `cc` | `SAMCHON_GRAPH_SCIP_CLANG`, `SAMCHON_GRAPH_SCIP` | Project-local producer/decoder precede PATH; `SAMCHON_GRAPH_SCIP_CLANG` and `SAMCHON_GRAPH_SCIP` select absolute tools. Compilers come from the compilation database. | A valid compilation database and every named compiler/working directory/generated build input. | `linux`, `macos`, `windows-when-installed` | -| `scip-java` | Install `scip-java` 0.13.1, the `scip` decoder and a compatible JDK; Kotlin experiments pin a compatible source build. | `scip-java`, `scip`, `java` | `SAMCHON_GRAPH_SCIP_JAVA`, `SAMCHON_GRAPH_SCIP`, `SAMCHON_GRAPH_JAVA_TOOLCHAIN` | Project-local producer/decoder/JDK precede PATH; absolute environment overrides select each tool. | Maven or Gradle project metadata, dependency/classpath state and Java/Kotlin compiler inputs. | `linux`, `macos`, `windows` | -| `scip-dotnet` | `dotnet tool install --global scip-dotnet`; install the `scip` decoder and matching .NET SDK. | `scip-dotnet`, `scip`, `dotnet` | `SAMCHON_GRAPH_SCIP_DOTNET`, `SAMCHON_GRAPH_SCIP`, `SAMCHON_GRAPH_DOTNET_TOOLCHAIN` | Project-local producer/decoder/toolchain precede PATH; absolute environment overrides select each tool. | Solution/project/TFM/NuGet/MSBuild inputs and a resolvable SDK. | `linux`, `macos`, `windows` | -| `scip-python` | `npm install -g @sourcegraph/scip-python@0.6.6`; install the `scip` decoder and select Python. | `scip-python`, `scip`, `python3`, `python`, `py` | `SAMCHON_GRAPH_SCIP_PYTHON`, `SAMCHON_GRAPH_SCIP`, `SAMCHON_GRAPH_PYTHON_TOOLCHAIN` | Project-local producer/decoder/interpreter precede PATH; Python aliases are tried in order and absolute overrides select each tool. | Python project/config/environment/import/stub inputs. | `linux`, `macos`, `windows` | -| `scip-ruby` | Install the pinned `scip-ruby` 0.4.7 release binary, the `scip` decoder and matching Ruby/Bundler. | `scip-ruby`, `scip`, `ruby` | `SAMCHON_GRAPH_SCIP_RUBY`, `SAMCHON_GRAPH_SCIP`, `SAMCHON_GRAPH_RUBY_TOOLCHAIN` | Project-local producer/decoder/Ruby precede PATH; absolute environment overrides select each tool. | Gem/Bundler/Sorbet/RBI configuration inputs. | `linux`, `macos`, `windows-when-installed` | -| `scip-dart` | `dart pub global activate scip_dart 1.6.2`; install the `scip` decoder and Dart SDK. | `scip_dart`, `scip`, `dart` | `SAMCHON_GRAPH_SCIP_DART`, `SAMCHON_GRAPH_SCIP`, `SAMCHON_GRAPH_DART_TOOLCHAIN` | Project-local producer/decoder/Dart precede PATH; absolute environment overrides select each tool. | pubspec/lock, analysis options and resolved package configuration. | `linux`, `macos`, `windows` | -| `scip-php` | Install the project-local scip-php dependency with Composer, expose `vendor/bin/scip-php`, and install the `scip` decoder. | `scip-php`, `scip`, `php` | `SAMCHON_GRAPH_SCIP_PHP`, `SAMCHON_GRAPH_SCIP`, `SAMCHON_GRAPH_PHP_TOOLCHAIN` | Project `vendor/bin` precedes PATH; absolute producer/decoder/PHP overrides select each tool. | Composer manifest/lock/autoload and PHP/PHPStan configuration inputs. | `linux`, `macos`, `windows` | +| Provider | Install | Install sources | Fixed commands | Project command sources | Overrides | Resolution order | Project preparation | Platforms | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| `ttscgraph` | `npm i -D ttsc@^0.23.0 typescript` | [ttsc 0.23.0 on npm](https://www.npmjs.com/package/ttsc/v/0.23.0) | `ttscgraph`, `ttscserver` | — | `TTSC_GRAPH_BINARY` | Absolute `TTSC_GRAPH_BINARY`, target-project `ttsc` package/binary, target-project `.bin`, then PATH/global compatibility fallback. | A matching ttsc/TypeScript project and tsconfig/jsconfig/package inputs. | `linux`, `macos`, `windows` | +| `samchon-graph-go` | Go 1.25+; the package ships the Go exporter source. Install corroboration with `go install github.com/scip-code/scip-go/cmd/scip-go@v0.2.7`. | [Go downloads](https://go.dev/dl/), [scip-go 0.2.7 source](https://github.com/scip-code/scip-go/tree/v0.2.7) | `samchon-graph-go`, `go`, `scip-go` | — | `SAMCHON_GRAPH_GO`, `SAMCHON_GRAPH_GO_TOOLCHAIN`, `SAMCHON_GRAPH_SCIP_GO` | Project/PATH `samchon-graph-go`, then the shipped source runner through Go; absolute environment overrides take precedence. | Go workspace/module inputs, selected GOOS/GOARCH/cgo environment, embedded files and vendored inputs. | `linux`, `macos`, `windows` | +| `samchon-graph-lua` | Install `lua-language-server`; the package ships `sidecars/lua/export.lua`. | [LuaLS releases](https://github.com/LuaLS/lua-language-server/releases) | `lua-language-server` | — | `SAMCHON_GRAPH_LUA`, `SAMCHON_GRAPH_LUA_EXPORTER` | Absolute `SAMCHON_GRAPH_LUA`, then project/PATH LuaLS; the shipped exporter may be replaced by `SAMCHON_GRAPH_LUA_EXPORTER`. | LuaLS workspace configuration and the shipped readable exporter. | `linux`, `macos`, `windows` | +| `rust-analyzer-scip` | `rustup component add rust-analyzer`; install the `scip` decoder and provide matching rustc/Cargo. | [rust-analyzer installation](https://rust-analyzer.github.io/book/rust_analyzer_binary.html), [SCIP releases](https://github.com/sourcegraph/scip/releases) | `rust-analyzer`, `scip`, `rustc`, `cargo` | — | `SAMCHON_GRAPH_RUST_ANALYZER`, `SAMCHON_GRAPH_SCIP`, `SAMCHON_GRAPH_RUSTC`, `SAMCHON_GRAPH_CARGO` | Project-local tools precede PATH; each absolute environment override replaces only its named tool. | Cargo metadata/config, lock/toolchain inputs, target/features/cfg and build-script/proc-macro universe. | `linux`, `macos`, `windows` | +| `scip-clang` | Install `scip-clang` 0.4.0 and the `scip` decoder; provide a generated `compile_commands.json`. | [scip-clang 0.4.0 release](https://github.com/sourcegraph/scip-clang/releases/tag/v0.4.0), [SCIP releases](https://github.com/sourcegraph/scip/releases) | `scip-clang`, `scip` | `compile_commands.json` | `SAMCHON_GRAPH_SCIP_CLANG`, `SAMCHON_GRAPH_SCIP` | Project-local producer/decoder precede PATH; `SAMCHON_GRAPH_SCIP_CLANG` and `SAMCHON_GRAPH_SCIP` select absolute tools. Compilers come from the compilation database. | A valid compilation database and every named compiler/working directory/generated build input. | `linux`, `macos`, `windows-when-installed` | +| `scip-java` | Install `scip-java` 0.13.1, the `scip` decoder and a compatible JDK; Kotlin experiments pin a compatible source build. | [scip-java 0.13.1 release](https://github.com/scip-code/scip-java/releases/tag/v0.13.1), [SCIP releases](https://github.com/sourcegraph/scip/releases) | `scip-java`, `scip`, `java` | — | `SAMCHON_GRAPH_SCIP_JAVA`, `SAMCHON_GRAPH_SCIP`, `SAMCHON_GRAPH_JAVA_TOOLCHAIN` | Project-local producer/decoder/JDK precede PATH; absolute environment overrides select each tool. | Maven or Gradle project metadata, dependency/classpath state and Java/Kotlin compiler inputs. | `linux`, `macos`, `windows` | +| `scip-dotnet` | `dotnet tool install --global scip-dotnet`; install the `scip` decoder and matching .NET SDK. | [scip-dotnet on NuGet](https://www.nuget.org/packages/scip-dotnet), [SCIP releases](https://github.com/sourcegraph/scip/releases) | `scip-dotnet`, `scip`, `dotnet` | — | `SAMCHON_GRAPH_SCIP_DOTNET`, `SAMCHON_GRAPH_SCIP`, `SAMCHON_GRAPH_DOTNET_TOOLCHAIN` | Project-local producer/decoder/toolchain precede PATH; absolute environment overrides select each tool. | Solution/project/TFM/NuGet/MSBuild inputs and a resolvable SDK. | `linux`, `macos`, `windows` | +| `scip-python` | `npm install -g @sourcegraph/scip-python@0.6.6`; install the `scip` decoder and select Python. | [scip-python 0.6.6 on npm](https://www.npmjs.com/package/@sourcegraph/scip-python/v/0.6.6), [SCIP releases](https://github.com/sourcegraph/scip/releases) | `scip-python`, `scip`, `python3`, `python`, `py` | — | `SAMCHON_GRAPH_SCIP_PYTHON`, `SAMCHON_GRAPH_SCIP`, `SAMCHON_GRAPH_PYTHON_TOOLCHAIN` | Project-local producer/decoder/interpreter precede PATH; Python aliases are tried in order and absolute overrides select each tool. | Python project/config/environment/import/stub inputs. | `linux`, `macos`, `windows` | +| `scip-ruby` | Install the pinned `scip-ruby` 0.4.7 release binary, the `scip` decoder and matching Ruby/Bundler. | [scip-ruby 0.4.7 release](https://github.com/sourcegraph/scip-ruby/releases/tag/scip-ruby-v0.4.7), [SCIP releases](https://github.com/sourcegraph/scip/releases) | `scip-ruby`, `scip`, `ruby` | — | `SAMCHON_GRAPH_SCIP_RUBY`, `SAMCHON_GRAPH_SCIP`, `SAMCHON_GRAPH_RUBY_TOOLCHAIN` | Project-local producer/decoder/Ruby precede PATH; absolute environment overrides select each tool. | Gem/Bundler/Sorbet/RBI configuration inputs. | `linux`, `macos`, `windows-when-installed` | +| `scip-dart` | `dart pub global activate scip_dart 1.6.2`; install the `scip` decoder and Dart SDK. | [scip_dart 1.6.2](https://pub.dev/packages/scip_dart/versions/1.6.2), [SCIP releases](https://github.com/sourcegraph/scip/releases) | `scip_dart`, `scip`, `dart` | — | `SAMCHON_GRAPH_SCIP_DART`, `SAMCHON_GRAPH_SCIP`, `SAMCHON_GRAPH_DART_TOOLCHAIN` | Project-local producer/decoder/Dart precede PATH; absolute environment overrides select each tool. | pubspec/lock, analysis options and resolved package configuration. | `linux`, `macos`, `windows` | +| `scip-php` | Install the project-local scip-php dependency with Composer, expose `vendor/bin/scip-php`, and install the `scip` decoder. | [scip-php source](https://github.com/davidrjenni/scip-php), [SCIP releases](https://github.com/sourcegraph/scip/releases) | `scip-php`, `scip`, `php` | — | `SAMCHON_GRAPH_SCIP_PHP`, `SAMCHON_GRAPH_SCIP`, `SAMCHON_GRAPH_PHP_TOOLCHAIN` | Project `vendor/bin` precedes PATH; absolute producer/decoder/PHP overrides select each tool. | Composer manifest/lock/autoload and PHP/PHPStan configuration inputs. | `linux`, `macos`, `windows` | #### Verified cold index cells @@ -142,19 +142,19 @@ These are exact same-run cold end-to-end strict/strict-disabled pairs from [`tes A strict result's provenance name must equal the provider below. If it is absent, use the commands and overrides in the installation table, then follow the explicit decline reason; the fallback is still usable but does not inherit strict authority. -| Languages | Expected provenance | Common boundary | Decline and fallback | -| --- | --- | --- | --- | -| `typescript` | `ttscgraph` | The current producer does not yet make changed-response work proportional to the compiler invalidated closure. | `ttscserver`, then `@samchon/graph-sitter`; capped or incompatible requests decline explicitly. | -| `go` | `samchon-graph-go` | Current changed-input export is a full rebuild and does not retain a resident `go/packages` checker session. | `gopls`, then `@samchon/graph-sitter`; unavailable toolchain or corroborator declines explicitly. | -| `lua` | `samchon-graph-lua` | The current exporter calls `vm.getRefs` per declaration and proves references only; #83 replaces it with an occurrence-oriented resident traversal. | Generic LuaLS, then `@samchon/graph-sitter`; capped requests or a missing exporter decline explicitly. | -| `rust` | `rust-analyzer-scip` | Stock rust-analyzer SCIP has empty relationships/diagnostics and is navigation evidence, not the final HIR graph. | Generic rust-analyzer, then `@samchon/graph-sitter`; any missing analyzer/decoder/rustc/Cargo component declines. | -| `c`, `cpp` | `scip-clang` | The current artifact proves declarations but no graph edge family because enclosing attribution and type-definition relationships are absent. | `clangd`, then `@samchon/graph-sitter`; missing/invalid compilation metadata declines explicitly. | -| `java`, `kotlin` | `scip-java` | The released Gradle path disables configuration cache and runs `clean scipCompileAll`; it is navigation fallback for #74/#76, not compiler-owned calls or accesses. | `jdtls` or `kotlin-language-server`, then `@samchon/graph-sitter`. | -| `csharp` | `scip-dotnet` | The artifact proves declarations but no graph edge family and can log MSBuildWorkspace failures after publishing. | `csharp-ls`, then `@samchon/graph-sitter`. | -| `python` | `scip-python` | The bundled historical Pyright core proves references only and can recover from malformed pyproject configuration with defaults. | `pyright-langserver`, then `@samchon/graph-sitter`. | -| `ruby` | `scip-ruby` | The current artifact proves no graph edge family; it does not expose structural coverage, Sorbet sigils or typed unresolved sites. | `ruby-lsp`, then `@samchon/graph-sitter`. | -| `dart` | `scip-dart` | The current artifact proves no graph edge family and is not resident Analysis Server state. | Dart Analysis Server, then `@samchon/graph-sitter`. | -| `php` | `scip-php` | The current raw parser/Composer artifact proves no graph edge family and has no diagnostic or role grounding. | `intelephense`, then `@samchon/graph-sitter`. | +| Languages | Expected provenance | Common boundary | Common decline | Fallback | +| --- | --- | --- | --- | --- | +| `typescript` | `ttscgraph` | The current producer does not yet make changed-response work proportional to the compiler invalidated closure. | A missing target-project ttsc binary, incompatible request cap, malformed response or unsupported schema declines the strict provider. | `ttscserver`, then `@samchon/graph-sitter`. | +| `go` | `samchon-graph-go` | Current changed-input export is a full rebuild and does not retain a resident `go/packages` checker session. | A missing Go 1.25+ toolchain, missing pinned scip-go corroborator or invalid workspace/module load declines the strict provider. | `gopls`, then `@samchon/graph-sitter`. | +| `lua` | `samchon-graph-lua` | The current exporter calls `vm.getRefs` per declaration and proves references only; #83 replaces it with an occurrence-oriented resident traversal. | A missing LuaLS binary/exporter, invalid workspace result or bounded request declines the strict provider. | Generic LuaLS, then `@samchon/graph-sitter`. | +| `rust` | `rust-analyzer-scip` | Stock rust-analyzer SCIP has empty relationships/diagnostics and is navigation evidence, not the final HIR graph. | A missing analyzer, decoder, rustc or Cargo component, or an invalid Cargo project load, declines the strict provider. | Generic rust-analyzer, then `@samchon/graph-sitter`. | +| `c`, `cpp` | `scip-clang` | The current artifact proves declarations but no graph edge family because enclosing attribution and type-definition relationships are absent. | A missing producer/decoder, missing or invalid compilation database, or an unresolved per-unit compiler declines the strict provider. | `clangd`, then `@samchon/graph-sitter`. | +| `java`, `kotlin` | `scip-java` | The released Gradle path disables configuration cache and runs `clean scipCompileAll`; it is navigation fallback for #74/#76, not compiler-owned calls or accesses. | A missing producer/decoder/JDK, unsupported Maven or Gradle project, or invalid dependency/build configuration declines the strict provider. | `jdtls` or `kotlin-language-server`, then `@samchon/graph-sitter`. | +| `csharp` | `scip-dotnet` | The artifact proves declarations but no graph edge family and can log MSBuildWorkspace failures after publishing. | A missing producer/decoder/.NET SDK, absent solution/project input or invalid MSBuild load declines the strict provider. | `csharp-ls`, then `@samchon/graph-sitter`. | +| `python` | `scip-python` | The bundled historical Pyright core proves references only and can recover from malformed pyproject configuration with defaults. | A missing producer/decoder/Python interpreter, absent project input or unusable Python environment declines the strict provider. | `pyright-langserver`, then `@samchon/graph-sitter`. | +| `ruby` | `scip-ruby` | The current artifact proves no graph edge family; it does not expose structural coverage, Sorbet sigils or typed unresolved sites. | A missing producer/decoder/Ruby runtime, unusable Bundler environment or invalid project configuration declines the strict provider. | `ruby-lsp`, then `@samchon/graph-sitter`. | +| `dart` | `scip-dart` | The current artifact proves no graph edge family and is not resident Analysis Server state. | A missing producer/decoder/Dart SDK, absent package configuration or failed pub resolution declines the strict provider. | Dart Analysis Server, then `@samchon/graph-sitter`. | +| `php` | `scip-php` | The current raw parser/Composer artifact proves no graph edge family and has no diagnostic or role grounding. | A missing project-local producer/decoder/PHP runtime, absent Composer autoload or invalid project configuration declines the strict provider. | `intelephense`, then `@samchon/graph-sitter`. | #### Ordinary-only strict status diff --git a/docs/provider-support.json b/docs/provider-support.json index 36d730de..4cb254cf 100644 --- a/docs/provider-support.json +++ b/docs/provider-support.json @@ -15,6 +15,7 @@ "commands": ["ttscgraph", "ttscserver"], "environmentOverrides": ["TTSC_GRAPH_BINARY"], "install": "`npm i -D ttsc@^0.23.0 typescript`", + "installSources": [{"label": "ttsc 0.23.0 on npm", "url": "https://www.npmjs.com/package/ttsc/v/0.23.0"}], "resolution": "Absolute `TTSC_GRAPH_BINARY`, target-project `ttsc` package/binary, target-project `.bin`, then PATH/global compatibility fallback.", "requirements": "A matching ttsc/TypeScript project and tsconfig/jsconfig/package inputs.", "platforms": ["linux", "macos", "windows"], @@ -23,7 +24,8 @@ "exportMerge": "Each changed response serializes and validates one complete compiler dump before publication.", "reuseResident": "An identical producer generation reuses the exact dump; changed work is not yet proportional to the invalidated closure.", "limitations": "The current producer does not yet make changed-response work proportional to the compiler invalidated closure.", - "fallback": "`ttscserver`, then `@samchon/graph-sitter`; capped or incompatible requests decline explicitly.", + "decline": "A missing target-project ttsc binary, incompatible request cap, malformed response or unsupported schema declines the strict provider.", + "fallback": "`ttscserver`, then `@samchon/graph-sitter`.", "experimentLanguages": ["typescript"], "experimentTool": "ttscgraph", "experimentCapabilities": ["universe", "sourceDigests", "diskDigests", "diagnostics"], @@ -40,6 +42,10 @@ "commands": ["samchon-graph-go", "go", "scip-go"], "environmentOverrides": ["SAMCHON_GRAPH_GO", "SAMCHON_GRAPH_GO_TOOLCHAIN", "SAMCHON_GRAPH_SCIP_GO"], "install": "Go 1.25+; the package ships the Go exporter source. Install corroboration with `go install github.com/scip-code/scip-go/cmd/scip-go@v0.2.7`.", + "installSources": [ + {"label": "Go downloads", "url": "https://go.dev/dl/"}, + {"label": "scip-go 0.2.7 source", "url": "https://github.com/scip-code/scip-go/tree/v0.2.7"} + ], "resolution": "Project/PATH `samchon-graph-go`, then the shipped source runner through Go; absolute environment overrides take precedence.", "requirements": "Go workspace/module inputs, selected GOOS/GOARCH/cgo environment, embedded files and vendored inputs.", "platforms": ["linux", "macos", "windows"], @@ -48,7 +54,8 @@ "exportMerge": "A changed-input batch emits and validates one whole-workspace graph before snapshot publication.", "reuseResident": "Unchanged inputs reuse the validated snapshot; no resident go/packages checker survives changed builds.", "limitations": "Current changed-input export is a full rebuild and does not retain a resident `go/packages` checker session.", - "fallback": "`gopls`, then `@samchon/graph-sitter`; unavailable toolchain or corroborator declines explicitly.", + "decline": "A missing Go 1.25+ toolchain, missing pinned scip-go corroborator or invalid workspace/module load declines the strict provider.", + "fallback": "`gopls`, then `@samchon/graph-sitter`.", "experimentLanguages": ["go"], "experimentTool": "samchon-graph-go", "experimentCapabilities": ["universe", "sourceDigests", "fullRebuild"], @@ -65,6 +72,7 @@ "commands": ["lua-language-server"], "environmentOverrides": ["SAMCHON_GRAPH_LUA", "SAMCHON_GRAPH_LUA_EXPORTER"], "install": "Install `lua-language-server`; the package ships `sidecars/lua/export.lua`.", + "installSources": [{"label": "LuaLS releases", "url": "https://github.com/LuaLS/lua-language-server/releases"}], "resolution": "Absolute `SAMCHON_GRAPH_LUA`, then project/PATH LuaLS; the shipped exporter may be replaced by `SAMCHON_GRAPH_LUA_EXPORTER`.", "requirements": "LuaLS workspace configuration and the shipped readable exporter.", "platforms": ["linux", "macos", "windows"], @@ -73,7 +81,8 @@ "exportMerge": "A changed-input run publishes one references-only whole-workspace graph.", "reuseResident": "Unchanged inputs reuse the validated snapshot; the current exporter is not a resident incremental session.", "limitations": "The current exporter calls `vm.getRefs` per declaration and proves references only; #83 replaces it with an occurrence-oriented resident traversal.", - "fallback": "Generic LuaLS, then `@samchon/graph-sitter`; capped requests or a missing exporter decline explicitly.", + "decline": "A missing LuaLS binary/exporter, invalid workspace result or bounded request declines the strict provider.", + "fallback": "Generic LuaLS, then `@samchon/graph-sitter`.", "experimentLanguages": ["lua"], "experimentTool": "lua-language-server", "experimentCapabilities": ["universe", "diskDigests"], @@ -90,6 +99,10 @@ "commands": ["rust-analyzer", "scip", "rustc", "cargo"], "environmentOverrides": ["SAMCHON_GRAPH_RUST_ANALYZER", "SAMCHON_GRAPH_SCIP", "SAMCHON_GRAPH_RUSTC", "SAMCHON_GRAPH_CARGO"], "install": "`rustup component add rust-analyzer`; install the `scip` decoder and provide matching rustc/Cargo.", + "installSources": [ + {"label": "rust-analyzer installation", "url": "https://rust-analyzer.github.io/book/rust_analyzer_binary.html"}, + {"label": "SCIP releases", "url": "https://github.com/sourcegraph/scip/releases"} + ], "resolution": "Project-local tools precede PATH; each absolute environment override replaces only its named tool.", "requirements": "Cargo metadata/config, lock/toolchain inputs, target/features/cfg and build-script/proc-macro universe.", "platforms": ["linux", "macos", "windows"], @@ -98,7 +111,8 @@ "exportMerge": "The decoder maps the complete artifact to a contains/references graph before atomic snapshot publication.", "reuseResident": "Unchanged inputs reuse the validated snapshot; no rust-analyzer semantic session remains resident.", "limitations": "Stock rust-analyzer SCIP has empty relationships/diagnostics and is navigation evidence, not the final HIR graph.", - "fallback": "Generic rust-analyzer, then `@samchon/graph-sitter`; any missing analyzer/decoder/rustc/Cargo component declines.", + "decline": "A missing analyzer, decoder, rustc or Cargo component, or an invalid Cargo project load, declines the strict provider.", + "fallback": "Generic rust-analyzer, then `@samchon/graph-sitter`.", "experimentLanguages": ["rust"], "experimentTool": "rust-analyzer", "experimentCapabilities": ["universe", "diskDigests"], @@ -112,9 +126,14 @@ "status": "registered", "authority": "semantic-index", "facts": [], - "commands": ["scip-clang", "scip", "cc"], + "commands": ["scip-clang", "scip"], + "projectCommandSources": ["compile_commands.json"], "environmentOverrides": ["SAMCHON_GRAPH_SCIP_CLANG", "SAMCHON_GRAPH_SCIP"], "install": "Install `scip-clang` 0.4.0 and the `scip` decoder; provide a generated `compile_commands.json`.", + "installSources": [ + {"label": "scip-clang 0.4.0 release", "url": "https://github.com/sourcegraph/scip-clang/releases/tag/v0.4.0"}, + {"label": "SCIP releases", "url": "https://github.com/sourcegraph/scip/releases"} + ], "resolution": "Project-local producer/decoder precede PATH; `SAMCHON_GRAPH_SCIP_CLANG` and `SAMCHON_GRAPH_SCIP` select absolute tools. Compilers come from the compilation database.", "requirements": "A valid compilation database and every named compiler/working directory/generated build input.", "platforms": ["linux", "macos", "windows-when-installed"], @@ -123,7 +142,8 @@ "exportMerge": "The complete decoded artifact publishes declarations but no currently defensible edge family; producer scheduling can move header selection.", "reuseResident": "Unchanged inputs reuse the validated snapshot; every changed build reruns the batch producer.", "limitations": "The current artifact proves declarations but no graph edge family because enclosing attribution and type-definition relationships are absent.", - "fallback": "`clangd`, then `@samchon/graph-sitter`; missing/invalid compilation metadata declines explicitly.", + "decline": "A missing producer/decoder, missing or invalid compilation database, or an unresolved per-unit compiler declines the strict provider.", + "fallback": "`clangd`, then `@samchon/graph-sitter`.", "experimentLanguages": ["c", "cpp"], "experimentTool": "scip-clang", "experimentCapabilities": ["universe", "diskDigests"], @@ -143,6 +163,10 @@ "commands": ["scip-java", "scip", "java"], "environmentOverrides": ["SAMCHON_GRAPH_SCIP_JAVA", "SAMCHON_GRAPH_SCIP", "SAMCHON_GRAPH_JAVA_TOOLCHAIN"], "install": "Install `scip-java` 0.13.1, the `scip` decoder and a compatible JDK; Kotlin experiments pin a compatible source build.", + "installSources": [ + {"label": "scip-java 0.13.1 release", "url": "https://github.com/scip-code/scip-java/releases/tag/v0.13.1"}, + {"label": "SCIP releases", "url": "https://github.com/sourcegraph/scip/releases"} + ], "resolution": "Project-local producer/decoder/JDK precede PATH; absolute environment overrides select each tool.", "requirements": "Maven or Gradle project metadata, dependency/classpath state and Java/Kotlin compiler inputs.", "platforms": ["linux", "macos", "windows"], @@ -151,6 +175,7 @@ "exportMerge": "The complete decoded artifact is merged as a contains/references graph before atomic publication.", "reuseResident": "Unchanged inputs reuse the validated snapshot; no javac, kotlinc or build session remains resident.", "limitations": "The released Gradle path disables configuration cache and runs `clean scipCompileAll`; it is navigation fallback for #74/#76, not compiler-owned calls or accesses.", + "decline": "A missing producer/decoder/JDK, unsupported Maven or Gradle project, or invalid dependency/build configuration declines the strict provider.", "fallback": "`jdtls` or `kotlin-language-server`, then `@samchon/graph-sitter`.", "experimentLanguages": ["java", "kotlin"], "experimentTool": "scip-java", @@ -174,6 +199,10 @@ "commands": ["scip-dotnet", "scip", "dotnet"], "environmentOverrides": ["SAMCHON_GRAPH_SCIP_DOTNET", "SAMCHON_GRAPH_SCIP", "SAMCHON_GRAPH_DOTNET_TOOLCHAIN"], "install": "`dotnet tool install --global scip-dotnet`; install the `scip` decoder and matching .NET SDK.", + "installSources": [ + {"label": "scip-dotnet on NuGet", "url": "https://www.nuget.org/packages/scip-dotnet"}, + {"label": "SCIP releases", "url": "https://github.com/sourcegraph/scip/releases"} + ], "resolution": "Project-local producer/decoder/toolchain precede PATH; absolute environment overrides select each tool.", "requirements": "Solution/project/TFM/NuGet/MSBuild inputs and a resolvable SDK.", "platforms": ["linux", "macos", "windows"], @@ -182,6 +211,7 @@ "exportMerge": "The complete decoded artifact publishes declarations but no currently defensible edge family.", "reuseResident": "Unchanged inputs reuse the validated snapshot; no Roslyn workspace remains resident.", "limitations": "The artifact proves declarations but no graph edge family and can log MSBuildWorkspace failures after publishing.", + "decline": "A missing producer/decoder/.NET SDK, absent solution/project input or invalid MSBuild load declines the strict provider.", "fallback": "`csharp-ls`, then `@samchon/graph-sitter`.", "experimentLanguages": ["csharp"], "experimentTool": "scip-dotnet", @@ -199,6 +229,10 @@ "commands": ["scip-python", "scip", "python3", "python", "py"], "environmentOverrides": ["SAMCHON_GRAPH_SCIP_PYTHON", "SAMCHON_GRAPH_SCIP", "SAMCHON_GRAPH_PYTHON_TOOLCHAIN"], "install": "`npm install -g @sourcegraph/scip-python@0.6.6`; install the `scip` decoder and select Python.", + "installSources": [ + {"label": "scip-python 0.6.6 on npm", "url": "https://www.npmjs.com/package/@sourcegraph/scip-python/v/0.6.6"}, + {"label": "SCIP releases", "url": "https://github.com/sourcegraph/scip/releases"} + ], "resolution": "Project-local producer/decoder/interpreter precede PATH; Python aliases are tried in order and absolute overrides select each tool.", "requirements": "Python project/config/environment/import/stub inputs.", "platforms": ["linux", "macos", "windows"], @@ -207,6 +241,7 @@ "exportMerge": "The complete decoded artifact publishes a references-only project graph.", "reuseResident": "Unchanged inputs reuse the validated snapshot; no Pyright analysis session remains resident.", "limitations": "The bundled historical Pyright core proves references only and can recover from malformed pyproject configuration with defaults.", + "decline": "A missing producer/decoder/Python interpreter, absent project input or unusable Python environment declines the strict provider.", "fallback": "`pyright-langserver`, then `@samchon/graph-sitter`.", "experimentLanguages": ["python"], "experimentTool": "scip-python", @@ -224,6 +259,10 @@ "commands": ["scip-ruby", "scip", "ruby"], "environmentOverrides": ["SAMCHON_GRAPH_SCIP_RUBY", "SAMCHON_GRAPH_SCIP", "SAMCHON_GRAPH_RUBY_TOOLCHAIN"], "install": "Install the pinned `scip-ruby` 0.4.7 release binary, the `scip` decoder and matching Ruby/Bundler.", + "installSources": [ + {"label": "scip-ruby 0.4.7 release", "url": "https://github.com/sourcegraph/scip-ruby/releases/tag/scip-ruby-v0.4.7"}, + {"label": "SCIP releases", "url": "https://github.com/sourcegraph/scip/releases"} + ], "resolution": "Project-local producer/decoder/Ruby precede PATH; absolute environment overrides select each tool.", "requirements": "Gem/Bundler/Sorbet/RBI configuration inputs.", "platforms": ["linux", "macos", "windows-when-installed"], @@ -232,6 +271,7 @@ "exportMerge": "The complete decoded artifact publishes declarations but no currently defensible edge family.", "reuseResident": "Unchanged inputs reuse the validated snapshot; no Ruby or Sorbet index remains resident.", "limitations": "The current artifact proves no graph edge family; it does not expose structural coverage, Sorbet sigils or typed unresolved sites.", + "decline": "A missing producer/decoder/Ruby runtime, unusable Bundler environment or invalid project configuration declines the strict provider.", "fallback": "`ruby-lsp`, then `@samchon/graph-sitter`.", "experimentLanguages": ["ruby"], "experimentTool": "scip-ruby", @@ -249,6 +289,10 @@ "commands": ["scip_dart", "scip", "dart"], "environmentOverrides": ["SAMCHON_GRAPH_SCIP_DART", "SAMCHON_GRAPH_SCIP", "SAMCHON_GRAPH_DART_TOOLCHAIN"], "install": "`dart pub global activate scip_dart 1.6.2`; install the `scip` decoder and Dart SDK.", + "installSources": [ + {"label": "scip_dart 1.6.2", "url": "https://pub.dev/packages/scip_dart/versions/1.6.2"}, + {"label": "SCIP releases", "url": "https://github.com/sourcegraph/scip/releases"} + ], "resolution": "Project-local producer/decoder/Dart precede PATH; absolute environment overrides select each tool.", "requirements": "pubspec/lock, analysis options and resolved package configuration.", "platforms": ["linux", "macos", "windows"], @@ -257,6 +301,7 @@ "exportMerge": "The complete decoded artifact publishes declarations but no currently defensible edge family.", "reuseResident": "Unchanged inputs reuse the validated snapshot; this is not resident Analysis Server state.", "limitations": "The current artifact proves no graph edge family and is not resident Analysis Server state.", + "decline": "A missing producer/decoder/Dart SDK, absent package configuration or failed pub resolution declines the strict provider.", "fallback": "Dart Analysis Server, then `@samchon/graph-sitter`.", "experimentLanguages": ["dart"], "experimentTool": "scip-dart", @@ -274,6 +319,10 @@ "commands": ["scip-php", "scip", "php"], "environmentOverrides": ["SAMCHON_GRAPH_SCIP_PHP", "SAMCHON_GRAPH_SCIP", "SAMCHON_GRAPH_PHP_TOOLCHAIN"], "install": "Install the project-local scip-php dependency with Composer, expose `vendor/bin/scip-php`, and install the `scip` decoder.", + "installSources": [ + {"label": "scip-php source", "url": "https://github.com/davidrjenni/scip-php"}, + {"label": "SCIP releases", "url": "https://github.com/sourcegraph/scip/releases"} + ], "resolution": "Project `vendor/bin` precedes PATH; absolute producer/decoder/PHP overrides select each tool.", "requirements": "Composer manifest/lock/autoload and PHP/PHPStan configuration inputs.", "platforms": ["linux", "macos", "windows"], @@ -282,6 +331,7 @@ "exportMerge": "The complete decoded artifact publishes declarations but no currently defensible edge family.", "reuseResident": "Unchanged inputs reuse the validated snapshot; no PHPStan or Composer analysis session remains resident.", "limitations": "The current raw parser/Composer artifact proves no graph edge family and has no diagnostic or role grounding.", + "decline": "A missing project-local producer/decoder/PHP runtime, absent Composer autoload or invalid project configuration declines the strict provider.", "fallback": "`intelephense`, then `@samchon/graph-sitter`.", "experimentLanguages": ["php"], "experimentTool": "scip-php", diff --git a/packages/graph/build/provider-support.mjs b/packages/graph/build/provider-support.mjs index 55ae57eb..73b605c1 100644 --- a/packages/graph/build/provider-support.mjs +++ b/packages/graph/build/provider-support.mjs @@ -19,6 +19,12 @@ const manifestFile = path.resolve( ); const write = args.has("--write"); const validateOnly = args.has("--validate-only"); +const supportedPlatforms = new Set([ + "linux", + "macos", + "windows", + "windows-when-installed", +]); if (write && validateOnly) { throw new Error( @@ -134,6 +140,7 @@ function validateManifest( "exportMerge", "reuseResident", "limitations", + "decline", "fallback", ]) { invariant( @@ -151,6 +158,36 @@ function validateManifest( documented.platforms.length > 0, `${documented.provider} must name supported platforms`, ); + unique(documented.platforms, `${documented.provider} platform`); + for (const platform of documented.platforms) { + invariant( + supportedPlatforms.has(platform), + `${documented.provider} names unknown platform ${platform}`, + ); + } + invariant( + Array.isArray(documented.installSources) && + documented.installSources.length > 0, + `${documented.provider} must name install sources`, + ); + unique( + documented.installSources.map((source) => source.label), + `${documented.provider} install-source label`, + ); + unique( + documented.installSources.map((source) => source.url), + `${documented.provider} install-source URL`, + ); + for (const source of documented.installSources) { + invariant( + typeof source.label === "string" && source.label.trim() !== "", + `${documented.provider} install source must have a label`, + ); + assertUrl( + source.url, + `${documented.provider} install source ${source.label}`, + ); + } assertUrl(documented.upstream, `${documented.provider} upstream`); invariant( Array.isArray(documented.childIssues) && @@ -161,6 +198,17 @@ function validateManifest( for (const issue of documented.childIssues) { assertUrl(issue, `${documented.provider} child issue`); } + unique(documented.languages, `${documented.provider} language`); + unique(documented.facts, `${documented.provider} fact`); + unique(documented.commands, `${documented.provider} command`); + unique( + documented.projectCommandSources ?? [], + `${documented.provider} project command source`, + ); + unique( + documented.environmentOverrides, + `${documented.provider} environment override`, + ); invariant( equal(documented.languages, provider.languages), `${documented.provider} languages differ from GRAPH_PROVIDERS`, @@ -177,6 +225,13 @@ function validateManifest( equal(documented.commands, provider.resolution?.commands), `${documented.provider} commands differ from its resolver descriptor`, ); + invariant( + equal( + documented.projectCommandSources ?? [], + provider.resolution?.projectCommandSources ?? [], + ), + `${documented.provider} project command sources differ from its resolver descriptor`, + ); invariant( equal( documented.environmentOverrides, @@ -377,7 +432,11 @@ function renderSupport(manifest) { const installRows = manifest.providers.map((provider) => [ code(provider.provider), provider.install, + provider.installSources + .map((source) => `[${source.label}](${source.url})`) + .join(", "), provider.commands.map(code).join(", "), + provider.projectCommandSources?.map(code).join(", ") ?? "—", provider.environmentOverrides.map(code).join(", "), provider.resolution, provider.requirements, @@ -387,6 +446,7 @@ function renderSupport(manifest) { provider.languages.map(code).join(", "), code(provider.provider), provider.limitations, + provider.decline, provider.fallback, ]); const benchmarkRows = manifest.providers.flatMap((provider) => @@ -436,7 +496,7 @@ function renderSupport(manifest) { "The troubleshooting table names the ordinary language-server/static fallback for each row. Resolution metadata is shared with the shipped registry and checked in CI.", "", table( - ["Provider", "Install", "Commands", "Overrides", "Resolution order", "Project preparation", "Platforms"], + ["Provider", "Install", "Install sources", "Fixed commands", "Project command sources", "Overrides", "Resolution order", "Project preparation", "Platforms"], installRows, ), "", @@ -454,7 +514,7 @@ function renderSupport(manifest) { "A strict result's provenance name must equal the provider below. If it is absent, use the commands and overrides in the installation table, then follow the explicit decline reason; the fallback is still usable but does not inherit strict authority.", "", table( - ["Languages", "Expected provenance", "Common boundary", "Decline and fallback"], + ["Languages", "Expected provenance", "Common boundary", "Common decline", "Fallback"], troubleshootingRows, ), "", diff --git a/packages/graph/src/provider/IGraphProvider.ts b/packages/graph/src/provider/IGraphProvider.ts index 8784fa0b..e2a1b93a 100644 --- a/packages/graph/src/provider/IGraphProvider.ts +++ b/packages/graph/src/provider/IGraphProvider.ts @@ -155,6 +155,12 @@ export namespace IGraphProvider { export interface IResolution { readonly commands: readonly string[]; readonly environmentOverrides: readonly string[]; + /** + * Project-owned files whose contents name additional executables. Those + * commands are dynamic resolver inputs and must not be represented by a + * made-up fixed executable in {@link commands}. + */ + readonly projectCommandSources?: readonly string[]; } export interface IConfigurationDerivation { diff --git a/packages/graph/src/provider/scip/standardScipProviders.ts b/packages/graph/src/provider/scip/standardScipProviders.ts index c061f5a0..6be04883 100644 --- a/packages/graph/src/provider/scip/standardScipProviders.ts +++ b/packages/graph/src/provider/scip/standardScipProviders.ts @@ -31,7 +31,11 @@ const clangScipProvider = createScipProvider({ // even though its compilation database was exactly what the indexer consumes. // What the index means is decided by the driver each translation unit was // actually compiled with, and the database records that per entry. - toolchain: { label: "cc", fromProject: compilationDatabaseCompilers }, + toolchain: { + label: "cc", + fromProject: compilationDatabaseCompilers, + sources: ["compile_commands.json"], + }, languages: ["c", "cpp"], command: "scip-clang", override: "SAMCHON_GRAPH_SCIP_CLANG", @@ -473,6 +477,7 @@ type IToolchain = env: NodeJS.ProcessEnv, ) => readonly string[]; override?: never; + sources: readonly string[]; /** What the rows call this toolchain when the project names none. */ label: string; @@ -501,7 +506,7 @@ function createScipProvider( commands: Object.freeze([ props.command, SCIP_DECODER.command, - ...(props.toolchain.aliases ?? [props.toolchain.label]), + ...(props.toolchain.aliases ?? []), ]), environmentOverrides: Object.freeze([ props.override, @@ -510,6 +515,13 @@ function createScipProvider( ? [] : [props.toolchain.override]), ]), + ...(props.toolchain.fromProject === undefined + ? {} + : { + projectCommandSources: Object.freeze([ + ...props.toolchain.sources, + ]), + }), }) satisfies IGraphProvider.IResolution; return Object.assign( scipProvider({ diff --git a/tests/test-graph/src/features/test_provider_support_manifest_matches_registry_and_evidence.ts b/tests/test-graph/src/features/test_provider_support_manifest_matches_registry_and_evidence.ts index b08436b3..60de603c 100644 --- a/tests/test-graph/src/features/test_provider_support_manifest_matches_registry_and_evidence.ts +++ b/tests/test-graph/src/features/test_provider_support_manifest_matches_registry_and_evidence.ts @@ -23,7 +23,12 @@ export const test_provider_support_manifest_matches_registry_and_evidence = ); const parsed = JSON.parse(fs.readFileSync(canonical, "utf8")) as { - providers: Array>; + providers: Array< + Record & { + installSources?: unknown; + platforms?: unknown; + } + >; }; const missing = structuredClone(parsed); missing.providers.shift(); @@ -50,6 +55,42 @@ export const test_provider_support_manifest_matches_registry_and_evidence = "documented absent provider absent-provider", ), ); + + const misspelledPlatform = structuredClone(parsed); + misspelledPlatform.providers[0]!.platforms = ["linxu"]; + const misspelledPlatformFile = path.join( + root, + "misspelled-platform.json", + ); + fs.writeFileSync( + misspelledPlatformFile, + JSON.stringify(misspelledPlatform), + ); + TestValidator.predicate( + "an unknown platform fails closed", + validate(misspelledPlatformFile, root).stderr.includes( + "ttscgraph names unknown platform linxu", + ), + ); + + const unsafeInstallSource = structuredClone(parsed); + unsafeInstallSource.providers[0]!.installSources = [ + { label: "unsafe source", url: "http://example.com/package" }, + ]; + const unsafeInstallSourceFile = path.join( + root, + "unsafe-install-source.json", + ); + fs.writeFileSync( + unsafeInstallSourceFile, + JSON.stringify(unsafeInstallSource), + ); + TestValidator.predicate( + "a non-HTTPS install source fails closed", + validate(unsafeInstallSourceFile, root).stderr.includes( + "ttscgraph install source unsafe source must be an HTTPS URL", + ), + ); } finally { fs.rmSync(root, { recursive: true, force: true }); } From 5f7f2ca34d3695fe795812b14a18c6e95a3875dd Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 31 Jul 2026 20:40:25 +0900 Subject: [PATCH 17/52] fix: preserve exact provider command sources Close #143: [Docs] The README documents two strict providers while the registry ships eleven --- README.md | 2 +- docs/provider-support.json | 2 +- .../provider/scip/standardScipProviders.ts | 11 +- ..._manifest_matches_registry_and_evidence.ts | 112 ++++++++++++++++++ 4 files changed, 119 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index b13a61cc..b734b544 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ The troubleshooting table names the ordinary language-server/static fallback for | `samchon-graph-go` | Go 1.25+; the package ships the Go exporter source. Install corroboration with `go install github.com/scip-code/scip-go/cmd/scip-go@v0.2.7`. | [Go downloads](https://go.dev/dl/), [scip-go 0.2.7 source](https://github.com/scip-code/scip-go/tree/v0.2.7) | `samchon-graph-go`, `go`, `scip-go` | — | `SAMCHON_GRAPH_GO`, `SAMCHON_GRAPH_GO_TOOLCHAIN`, `SAMCHON_GRAPH_SCIP_GO` | Project/PATH `samchon-graph-go`, then the shipped source runner through Go; absolute environment overrides take precedence. | Go workspace/module inputs, selected GOOS/GOARCH/cgo environment, embedded files and vendored inputs. | `linux`, `macos`, `windows` | | `samchon-graph-lua` | Install `lua-language-server`; the package ships `sidecars/lua/export.lua`. | [LuaLS releases](https://github.com/LuaLS/lua-language-server/releases) | `lua-language-server` | — | `SAMCHON_GRAPH_LUA`, `SAMCHON_GRAPH_LUA_EXPORTER` | Absolute `SAMCHON_GRAPH_LUA`, then project/PATH LuaLS; the shipped exporter may be replaced by `SAMCHON_GRAPH_LUA_EXPORTER`. | LuaLS workspace configuration and the shipped readable exporter. | `linux`, `macos`, `windows` | | `rust-analyzer-scip` | `rustup component add rust-analyzer`; install the `scip` decoder and provide matching rustc/Cargo. | [rust-analyzer installation](https://rust-analyzer.github.io/book/rust_analyzer_binary.html), [SCIP releases](https://github.com/sourcegraph/scip/releases) | `rust-analyzer`, `scip`, `rustc`, `cargo` | — | `SAMCHON_GRAPH_RUST_ANALYZER`, `SAMCHON_GRAPH_SCIP`, `SAMCHON_GRAPH_RUSTC`, `SAMCHON_GRAPH_CARGO` | Project-local tools precede PATH; each absolute environment override replaces only its named tool. | Cargo metadata/config, lock/toolchain inputs, target/features/cfg and build-script/proc-macro universe. | `linux`, `macos`, `windows` | -| `scip-clang` | Install `scip-clang` 0.4.0 and the `scip` decoder; provide a generated `compile_commands.json`. | [scip-clang 0.4.0 release](https://github.com/sourcegraph/scip-clang/releases/tag/v0.4.0), [SCIP releases](https://github.com/sourcegraph/scip/releases) | `scip-clang`, `scip` | `compile_commands.json` | `SAMCHON_GRAPH_SCIP_CLANG`, `SAMCHON_GRAPH_SCIP` | Project-local producer/decoder precede PATH; `SAMCHON_GRAPH_SCIP_CLANG` and `SAMCHON_GRAPH_SCIP` select absolute tools. Compilers come from the compilation database. | A valid compilation database and every named compiler/working directory/generated build input. | `linux`, `macos`, `windows-when-installed` | +| `scip-clang` | Install `scip-clang` 0.4.0 and the `scip` decoder; provide a generated `compile_commands.json`. | [scip-clang 0.4.0 release](https://github.com/sourcegraph/scip-clang/releases/tag/v0.4.0), [SCIP releases](https://github.com/sourcegraph/scip/releases) | `scip-clang`, `scip` | `compile_commands.json`, `build/compile_commands.json` | `SAMCHON_GRAPH_SCIP_CLANG`, `SAMCHON_GRAPH_SCIP` | Project-local producer/decoder precede PATH; `SAMCHON_GRAPH_SCIP_CLANG` and `SAMCHON_GRAPH_SCIP` select absolute tools. Compilers come from the compilation database. | A valid compilation database and every named compiler/working directory/generated build input. | `linux`, `macos`, `windows-when-installed` | | `scip-java` | Install `scip-java` 0.13.1, the `scip` decoder and a compatible JDK; Kotlin experiments pin a compatible source build. | [scip-java 0.13.1 release](https://github.com/scip-code/scip-java/releases/tag/v0.13.1), [SCIP releases](https://github.com/sourcegraph/scip/releases) | `scip-java`, `scip`, `java` | — | `SAMCHON_GRAPH_SCIP_JAVA`, `SAMCHON_GRAPH_SCIP`, `SAMCHON_GRAPH_JAVA_TOOLCHAIN` | Project-local producer/decoder/JDK precede PATH; absolute environment overrides select each tool. | Maven or Gradle project metadata, dependency/classpath state and Java/Kotlin compiler inputs. | `linux`, `macos`, `windows` | | `scip-dotnet` | `dotnet tool install --global scip-dotnet`; install the `scip` decoder and matching .NET SDK. | [scip-dotnet on NuGet](https://www.nuget.org/packages/scip-dotnet), [SCIP releases](https://github.com/sourcegraph/scip/releases) | `scip-dotnet`, `scip`, `dotnet` | — | `SAMCHON_GRAPH_SCIP_DOTNET`, `SAMCHON_GRAPH_SCIP`, `SAMCHON_GRAPH_DOTNET_TOOLCHAIN` | Project-local producer/decoder/toolchain precede PATH; absolute environment overrides select each tool. | Solution/project/TFM/NuGet/MSBuild inputs and a resolvable SDK. | `linux`, `macos`, `windows` | | `scip-python` | `npm install -g @sourcegraph/scip-python@0.6.6`; install the `scip` decoder and select Python. | [scip-python 0.6.6 on npm](https://www.npmjs.com/package/@sourcegraph/scip-python/v/0.6.6), [SCIP releases](https://github.com/sourcegraph/scip/releases) | `scip-python`, `scip`, `python3`, `python`, `py` | — | `SAMCHON_GRAPH_SCIP_PYTHON`, `SAMCHON_GRAPH_SCIP`, `SAMCHON_GRAPH_PYTHON_TOOLCHAIN` | Project-local producer/decoder/interpreter precede PATH; Python aliases are tried in order and absolute overrides select each tool. | Python project/config/environment/import/stub inputs. | `linux`, `macos`, `windows` | diff --git a/docs/provider-support.json b/docs/provider-support.json index 4cb254cf..f042b5a9 100644 --- a/docs/provider-support.json +++ b/docs/provider-support.json @@ -127,7 +127,7 @@ "authority": "semantic-index", "facts": [], "commands": ["scip-clang", "scip"], - "projectCommandSources": ["compile_commands.json"], + "projectCommandSources": ["compile_commands.json", "build/compile_commands.json"], "environmentOverrides": ["SAMCHON_GRAPH_SCIP_CLANG", "SAMCHON_GRAPH_SCIP"], "install": "Install `scip-clang` 0.4.0 and the `scip` decoder; provide a generated `compile_commands.json`.", "installSources": [ diff --git a/packages/graph/src/provider/scip/standardScipProviders.ts b/packages/graph/src/provider/scip/standardScipProviders.ts index 6be04883..8b40baa1 100644 --- a/packages/graph/src/provider/scip/standardScipProviders.ts +++ b/packages/graph/src/provider/scip/standardScipProviders.ts @@ -18,6 +18,10 @@ const SCIP_DECODER = Object.freeze({ command: "scip", override: "SAMCHON_GRAPH_SCIP", }); +const COMPILATION_DATABASE_INPUTS: readonly string[] = Object.freeze([ + "compile_commands.json", + "build/compile_commands.json", +]); const clangScipProvider = createScipProvider({ name: "scip-clang", @@ -34,7 +38,7 @@ const clangScipProvider = createScipProvider({ toolchain: { label: "cc", fromProject: compilationDatabaseCompilers, - sources: ["compile_commands.json"], + sources: COMPILATION_DATABASE_INPUTS, }, languages: ["c", "cpp"], command: "scip-clang", @@ -1724,11 +1728,6 @@ const WINDOWS_EXECUTABLE_SUFFIX = /\.(?:com|exe|cmd|bat)$/i; /** A separator no path can contain. */ const SEPARATOR = String.fromCharCode(0); -const COMPILATION_DATABASE_INPUTS: readonly string[] = [ - "compile_commands.json", - "build/compile_commands.json", -]; - const compilationDatabases = new BoundedMap(64); diff --git a/tests/test-graph/src/features/test_provider_support_manifest_matches_registry_and_evidence.ts b/tests/test-graph/src/features/test_provider_support_manifest_matches_registry_and_evidence.ts index 60de603c..f3f90044 100644 --- a/tests/test-graph/src/features/test_provider_support_manifest_matches_registry_and_evidence.ts +++ b/tests/test-graph/src/features/test_provider_support_manifest_matches_registry_and_evidence.ts @@ -25,8 +25,10 @@ export const test_provider_support_manifest_matches_registry_and_evidence = const parsed = JSON.parse(fs.readFileSync(canonical, "utf8")) as { providers: Array< Record & { + commands?: unknown; installSources?: unknown; platforms?: unknown; + projectCommandSources?: unknown; } >; }; @@ -73,6 +75,116 @@ export const test_provider_support_manifest_matches_registry_and_evidence = ), ); + const duplicatePlatform = structuredClone(parsed); + duplicatePlatform.providers[0]!.platforms = ["linux", "linux"]; + const duplicatePlatformFile = path.join( + root, + "duplicate-platform.json", + ); + fs.writeFileSync( + duplicatePlatformFile, + JSON.stringify(duplicatePlatform), + ); + TestValidator.predicate( + "a duplicate platform fails closed", + validate(duplicatePlatformFile, root).stderr.includes( + "ttscgraph platform rows must be unique", + ), + ); + + const duplicateCommand = structuredClone(parsed); + duplicateCommand.providers[0]!.commands = ["ttscgraph", "ttscgraph"]; + const duplicateCommandFile = path.join(root, "duplicate-command.json"); + fs.writeFileSync( + duplicateCommandFile, + JSON.stringify(duplicateCommand), + ); + TestValidator.predicate( + "a duplicate fixed command fails closed", + validate(duplicateCommandFile, root).stderr.includes( + "ttscgraph command rows must be unique", + ), + ); + + const incompleteProjectCommands = structuredClone(parsed); + const clang = incompleteProjectCommands.providers.find( + (provider) => provider.provider === "scip-clang", + ); + if (clang === undefined) + throw new Error("the canonical manifest must contain scip-clang"); + clang.projectCommandSources = ["compile_commands.json"]; + const incompleteProjectCommandsFile = path.join( + root, + "incomplete-project-command-sources.json", + ); + fs.writeFileSync( + incompleteProjectCommandsFile, + JSON.stringify(incompleteProjectCommands), + ); + TestValidator.predicate( + "an omitted project-owned command source fails closed", + validate(incompleteProjectCommandsFile, root).stderr.includes( + "scip-clang project command sources differ from its resolver descriptor", + ), + ); + + const missingInstallSource = structuredClone(parsed); + missingInstallSource.providers[0]!.installSources = []; + const missingInstallSourceFile = path.join( + root, + "missing-install-source.json", + ); + fs.writeFileSync( + missingInstallSourceFile, + JSON.stringify(missingInstallSource), + ); + TestValidator.predicate( + "a missing install source fails closed", + validate(missingInstallSourceFile, root).stderr.includes( + "ttscgraph must name install sources", + ), + ); + + const duplicateInstallLabel = structuredClone(parsed); + duplicateInstallLabel.providers[0]!.installSources = [ + { label: "same", url: "https://example.com/one" }, + { label: "same", url: "https://example.com/two" }, + ]; + const duplicateInstallLabelFile = path.join( + root, + "duplicate-install-label.json", + ); + fs.writeFileSync( + duplicateInstallLabelFile, + JSON.stringify(duplicateInstallLabel), + ); + TestValidator.predicate( + "a duplicate install-source label fails closed", + validate(duplicateInstallLabelFile, root).stderr.includes( + "ttscgraph install-source label rows must be unique", + ), + ); + + const duplicateInstallUrl = structuredClone(parsed); + duplicateInstallUrl.providers[0]!.installSources = [ + { label: "one", url: "https://example.com/same" }, + { label: "two", url: "https://example.com/same" }, + ]; + const duplicateInstallUrlFile = path.join( + root, + "duplicate-install-url.json", + ); + fs.writeFileSync( + duplicateInstallUrlFile, + JSON.stringify(duplicateInstallUrl), + ); + TestValidator.predicate( + "a duplicate install-source URL fails closed", + validate(duplicateInstallUrlFile, root).stderr.includes( + "ttscgraph install-source URL rows must be unique", + ), + ); + const unsafeInstallSource = structuredClone(parsed); unsafeInstallSource.providers[0]!.installSources = [ { label: "unsafe source", url: "http://example.com/package" }, From 9a1f3c74e9886bb265b2dcfe15cf4135c2a313fe Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 31 Jul 2026 21:02:48 +0900 Subject: [PATCH 18/52] test: require provider validation failures Close #143: [Docs] The README documents two strict providers while the registry ships eleven --- ..._manifest_matches_registry_and_evidence.ts | 68 ++++++++++++++++--- 1 file changed, 58 insertions(+), 10 deletions(-) diff --git a/tests/test-graph/src/features/test_provider_support_manifest_matches_registry_and_evidence.ts b/tests/test-graph/src/features/test_provider_support_manifest_matches_registry_and_evidence.ts index f3f90044..490d0abe 100644 --- a/tests/test-graph/src/features/test_provider_support_manifest_matches_registry_and_evidence.ts +++ b/tests/test-graph/src/features/test_provider_support_manifest_matches_registry_and_evidence.ts @@ -38,7 +38,8 @@ export const test_provider_support_manifest_matches_registry_and_evidence = fs.writeFileSync(missingFile, JSON.stringify(missing)); TestValidator.predicate( "an undocumented registered provider fails closed", - validate(missingFile, root).stderr.includes( + failsValidation( + validate(missingFile, root), "undocumented registered provider ttscgraph", ), ); @@ -53,7 +54,8 @@ export const test_provider_support_manifest_matches_registry_and_evidence = fs.writeFileSync(absentFile, JSON.stringify(absent)); TestValidator.predicate( "a documented absent provider fails closed", - validate(absentFile, root).stderr.includes( + failsValidation( + validate(absentFile, root), "documented absent provider absent-provider", ), ); @@ -70,7 +72,8 @@ export const test_provider_support_manifest_matches_registry_and_evidence = ); TestValidator.predicate( "an unknown platform fails closed", - validate(misspelledPlatformFile, root).stderr.includes( + failsValidation( + validate(misspelledPlatformFile, root), "ttscgraph names unknown platform linxu", ), ); @@ -87,7 +90,8 @@ export const test_provider_support_manifest_matches_registry_and_evidence = ); TestValidator.predicate( "a duplicate platform fails closed", - validate(duplicatePlatformFile, root).stderr.includes( + failsValidation( + validate(duplicatePlatformFile, root), "ttscgraph platform rows must be unique", ), ); @@ -101,7 +105,8 @@ export const test_provider_support_manifest_matches_registry_and_evidence = ); TestValidator.predicate( "a duplicate fixed command fails closed", - validate(duplicateCommandFile, root).stderr.includes( + failsValidation( + validate(duplicateCommandFile, root), "ttscgraph command rows must be unique", ), ); @@ -123,11 +128,39 @@ export const test_provider_support_manifest_matches_registry_and_evidence = ); TestValidator.predicate( "an omitted project-owned command source fails closed", - validate(incompleteProjectCommandsFile, root).stderr.includes( + failsValidation( + validate(incompleteProjectCommandsFile, root), "scip-clang project command sources differ from its resolver descriptor", ), ); + const duplicateProjectCommands = structuredClone(parsed); + const duplicateClang = duplicateProjectCommands.providers.find( + (provider) => provider.provider === "scip-clang", + ); + if (duplicateClang === undefined) + throw new Error("the canonical manifest must contain scip-clang"); + duplicateClang.projectCommandSources = [ + "compile_commands.json", + "build/compile_commands.json", + "build/compile_commands.json", + ]; + const duplicateProjectCommandsFile = path.join( + root, + "duplicate-project-command-sources.json", + ); + fs.writeFileSync( + duplicateProjectCommandsFile, + JSON.stringify(duplicateProjectCommands), + ); + TestValidator.predicate( + "a duplicate project-owned command source fails closed", + failsValidation( + validate(duplicateProjectCommandsFile, root), + "scip-clang project command source rows must be unique", + ), + ); + const missingInstallSource = structuredClone(parsed); missingInstallSource.providers[0]!.installSources = []; const missingInstallSourceFile = path.join( @@ -140,7 +173,8 @@ export const test_provider_support_manifest_matches_registry_and_evidence = ); TestValidator.predicate( "a missing install source fails closed", - validate(missingInstallSourceFile, root).stderr.includes( + failsValidation( + validate(missingInstallSourceFile, root), "ttscgraph must name install sources", ), ); @@ -160,7 +194,8 @@ export const test_provider_support_manifest_matches_registry_and_evidence = ); TestValidator.predicate( "a duplicate install-source label fails closed", - validate(duplicateInstallLabelFile, root).stderr.includes( + failsValidation( + validate(duplicateInstallLabelFile, root), "ttscgraph install-source label rows must be unique", ), ); @@ -180,7 +215,8 @@ export const test_provider_support_manifest_matches_registry_and_evidence = ); TestValidator.predicate( "a duplicate install-source URL fails closed", - validate(duplicateInstallUrlFile, root).stderr.includes( + failsValidation( + validate(duplicateInstallUrlFile, root), "ttscgraph install-source URL rows must be unique", ), ); @@ -199,7 +235,8 @@ export const test_provider_support_manifest_matches_registry_and_evidence = ); TestValidator.predicate( "a non-HTTPS install source fails closed", - validate(unsafeInstallSourceFile, root).stderr.includes( + failsValidation( + validate(unsafeInstallSourceFile, root), "ttscgraph install source unsafe source must be an HTTPS URL", ), ); @@ -238,3 +275,14 @@ function validate( stderr: result.stderr, }; } + +function failsValidation( + result: ReturnType, + diagnostic: string, +): boolean { + return ( + result.status !== null && + result.status !== 0 && + result.stderr.includes(diagnostic) + ); +} From f8874fdc9d73828dc3e6c863266563c5000791b2 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Sat, 1 Aug 2026 06:24:26 +0900 Subject: [PATCH 19/52] fix: preserve provider support line endings --- packages/graph/build/provider-support.mjs | 17 ++++-- ..._manifest_matches_registry_and_evidence.ts | 55 +++++++++++++++++++ 2 files changed, 66 insertions(+), 6 deletions(-) diff --git a/packages/graph/build/provider-support.mjs b/packages/graph/build/provider-support.mjs index 73b605c1..c386d9e7 100644 --- a/packages/graph/build/provider-support.mjs +++ b/packages/graph/build/provider-support.mjs @@ -17,6 +17,13 @@ const manifestFile = path.resolve( manifestArgument?.slice("--manifest=".length) ?? "docs/provider-support.json", ); +const readmeArgument = process.argv + .slice(2) + .find((argument) => argument.startsWith("--readme=")); +const readmeFile = path.resolve( + root, + readmeArgument?.slice("--readme=".length) ?? "README.md", +); const write = args.has("--write"); const validateOnly = args.has("--validate-only"); const supportedPlatforms = new Set([ @@ -52,13 +59,11 @@ validateManifest( ); if (!validateOnly) { - const readmeFile = path.join(root, "README.md"); const readme = fs.readFileSync(readmeFile, "utf8"); - const generated = [ - startMarker, - renderSupport(manifest), - endMarker, - ].join("\n"); + const newline = readme.includes("\r\n") ? "\r\n" : "\n"; + const generated = [startMarker, renderSupport(manifest), endMarker] + .join("\n") + .replaceAll("\n", newline); const next = replaceGeneratedBlock(readme, generated); if (write) { fs.writeFileSync(readmeFile, next); diff --git a/tests/test-graph/src/features/test_provider_support_manifest_matches_registry_and_evidence.ts b/tests/test-graph/src/features/test_provider_support_manifest_matches_registry_and_evidence.ts index 490d0abe..3e4efa77 100644 --- a/tests/test-graph/src/features/test_provider_support_manifest_matches_registry_and_evidence.ts +++ b/tests/test-graph/src/features/test_provider_support_manifest_matches_registry_and_evidence.ts @@ -22,6 +22,29 @@ export const test_provider_support_manifest_matches_registry_and_evidence = { status: 0, stderr: "" }, ); + const sourceReadme = fs.readFileSync( + path.join(GraphPaths.repositoryRoot, "README.md"), + "utf8", + ); + const lfReadme = path.join(root, "README-lf.md"); + fs.writeFileSync(lfReadme, sourceReadme.replace(/\r\n/g, "\n")); + TestValidator.equals( + "the generated support block preserves an LF checkout", + checkReadme(canonical, lfReadme, root), + { status: 0, stderr: "" }, + ); + + const crlfReadme = path.join(root, "README-crlf.md"); + fs.writeFileSync( + crlfReadme, + sourceReadme.replace(/\r?\n/g, "\r\n"), + ); + TestValidator.equals( + "the generated support block preserves a CRLF checkout", + checkReadme(canonical, crlfReadme, root), + { status: 0, stderr: "" }, + ); + const parsed = JSON.parse(fs.readFileSync(canonical, "utf8")) as { providers: Array< Record & { @@ -276,6 +299,38 @@ function validate( }; } +function checkReadme( + manifest: string, + readme: string, + coverageRoot: string, +): { + status: number | null; + stderr: string; +} { + const script = path.join( + GraphPaths.graphPackageRoot, + "build", + "provider-support.mjs", + ); + const result = spawnSync( + process.execPath, + [script, "--check", `--manifest=${manifest}`, `--readme=${readme}`], + { + cwd: GraphPaths.repositoryRoot, + encoding: "utf8", + env: { + ...process.env, + NODE_V8_COVERAGE: path.join(coverageRoot, "child-coverage"), + }, + windowsHide: true, + }, + ); + return { + status: result.status, + stderr: result.stderr, + }; +} + function failsValidation( result: ReturnType, diagnostic: string, From c8f6b9fd4b76f6d349c0d835c8f48f1ca1deb5c4 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Sat, 1 Aug 2026 08:29:54 +0900 Subject: [PATCH 20/52] feat: consume incremental TypeScript graph shards --- README.md | 6 +- docs/provider-support.json | 19 +- .../provider/ttscgraph/ITtscGraphSnapshot.ts | 39 +- .../src/provider/ttscgraph/TtscGraphClient.ts | 112 ++- .../ttscgraph/TtscGraphSnapshotStore.ts | 766 ++++++++++++++++++ .../provider/ttscgraph/adaptTtscGraphDump.ts | 22 +- .../ttscgraph/parseTtscGraphSnapshot.ts | 29 +- .../provider/ttscgraph/ttscGraphPhaseTrace.ts | 61 ++ .../ttscgraph/ttscGraphStrictRefusal.ts | 2 +- ..._manifest_matches_registry_and_evidence.ts | 8 + ...me_names_a_published_ttsc_install_range.ts | 30 - ..._states_the_ttsc_shard_release_boundary.ts | 40 + ...euses_and_atomically_replaces_snapshots.ts | 35 +- ...ph_dump_adapter_rejects_malformed_facts.ts | 41 + ...tive_shard_transactions_fail_atomically.ts | 148 ++++ ...napshot_accepts_canonical_project_alias.ts | 52 ++ ...e_is_opt_in_and_filters_producer_stderr.ts | 188 +++++ ...tocol_adapter_deletes_dependency_shards.ts | 48 +- ...vider_reindexes_and_reassembles_streams.ts | 8 +- ...blished_legacy_dump_falls_back_honestly.ts | 66 ++ ...h_published_schema3_falls_back_honestly.ts | 55 -- ...velope_is_validated_before_it_is_routed.ts | 24 +- .../src/internal/fake-ttscgraph-server.cjs | 366 ++++++++- 23 files changed, 1973 insertions(+), 192 deletions(-) create mode 100644 packages/graph/src/provider/ttscgraph/TtscGraphSnapshotStore.ts create mode 100644 packages/graph/src/provider/ttscgraph/ttscGraphPhaseTrace.ts delete mode 100644 tests/test-graph/src/features/test_readme_names_a_published_ttsc_install_range.ts create mode 100644 tests/test-graph/src/features/test_readme_states_the_ttsc_shard_release_boundary.ts create mode 100644 tests/test-graph/src/features/test_ttscgraph_native_shard_transactions_fail_atomically.ts create mode 100644 tests/test-graph/src/features/test_ttscgraph_native_snapshot_accepts_canonical_project_alias.ts create mode 100644 tests/test-graph/src/features/test_ttscgraph_phase_trace_is_opt_in_and_filters_producer_stderr.ts create mode 100644 tests/test-graph/src/features/test_ttscgraph_published_legacy_dump_falls_back_honestly.ts delete mode 100644 tests/test-graph/src/features/test_ttscgraph_published_schema3_falls_back_honestly.ts diff --git a/README.md b/README.md index b734b544..ddd83fed 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ These are current implementation modes, not future route claims. Preparation and | Provider | Mode | Preparation | Native analysis | Export and merge | Reuse or resident state | | --- | --- | --- | --- | --- | --- | -| `ttscgraph` | `resident-no-op-reuse; full-rebuild-on-change` | A matching ttsc/TypeScript project and tsconfig/jsconfig/package inputs. | The target project's ttsc checker owns one resident compiler process. | Each changed response serializes and validates one complete compiler dump before publication. | An identical producer generation reuses the exact dump; changed work is not yet proportional to the invalidated closure. | +| `ttscgraph` | `resident-no-op-reuse; invalidated-closure shard deltas with a compatible producer` | A matching ttsc/TypeScript project and tsconfig/jsconfig/package inputs. | A compatible target-project ttsc checker owns one resident compiler process and its incremental semantic state. | Changed compiler-owned raw shards cross a versioned transaction; the client validates the complete manifest and adapts only upserts before atomic publication. | Unchanged requests reuse the exact snapshot; body edits reuse unaffected native and normalized shards, while build-universe changes reload safely. | | `samchon-graph-go` | `unchanged-snapshot-reuse; full-rebuild-on-change` | Go workspace/module inputs, selected GOOS/GOARCH/cgo environment, embedded files and vendored inputs. | The shipped exporter runs one compiler-owned go/packages batch against the selected build universe. | A changed-input batch emits and validates one whole-workspace graph before snapshot publication. | Unchanged inputs reuse the validated snapshot; no resident go/packages checker survives changed builds. | | `samchon-graph-lua` | `unchanged-snapshot-reuse; full-rebuild-on-change` | LuaLS workspace configuration and the shipped readable exporter. | LuaLS analyzes the workspace and the shipped exporter asks its semantic VM for declaration references. | A changed-input run publishes one references-only whole-workspace graph. | Unchanged inputs reuse the validated snapshot; the current exporter is not a resident incremental session. | | `rust-analyzer-scip` | `unchanged-snapshot-reuse; full-rebuild-on-change` | Cargo metadata/config, lock/toolchain inputs, target/features/cfg and build-script/proc-macro universe. | Stock rust-analyzer produces one batch SCIP artifact for the selected Cargo universe. | The decoder maps the complete artifact to a contains/references graph before atomic snapshot publication. | Unchanged inputs reuse the validated snapshot; no rust-analyzer semantic session remains resident. | @@ -106,7 +106,7 @@ The troubleshooting table names the ordinary language-server/static fallback for | Provider | Install | Install sources | Fixed commands | Project command sources | Overrides | Resolution order | Project preparation | Platforms | | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| `ttscgraph` | `npm i -D ttsc@^0.23.0 typescript` | [ttsc 0.23.0 on npm](https://www.npmjs.com/package/ttsc/v/0.23.0) | `ttscgraph`, `ttscserver` | — | `TTSC_GRAPH_BINARY` | Absolute `TTSC_GRAPH_BINARY`, target-project `ttsc` package/binary, target-project `.bin`, then PATH/global compatibility fallback. | A matching ttsc/TypeScript project and tsconfig/jsconfig/package inputs. | `linux`, `macos`, `windows` | +| `ttscgraph` | Install a ttsc release that supports graph snapshot protocol v1. `ttsc@0.23.0` provides the ordinary `ttscserver` fallback but predates this strict protocol. | [ttsc 0.23.0 legacy release](https://www.npmjs.com/package/ttsc/v/0.23.0), [native shard producer PR](https://github.com/samchon/ttsc/pull/1056) | `ttscgraph`, `ttscserver` | — | `TTSC_GRAPH_BINARY` | Absolute `TTSC_GRAPH_BINARY`, target-project `ttsc` package/binary, target-project `.bin`, then PATH/global compatibility fallback. | A matching ttsc/TypeScript project and tsconfig/jsconfig/package inputs. | `linux`, `macos`, `windows` | | `samchon-graph-go` | Go 1.25+; the package ships the Go exporter source. Install corroboration with `go install github.com/scip-code/scip-go/cmd/scip-go@v0.2.7`. | [Go downloads](https://go.dev/dl/), [scip-go 0.2.7 source](https://github.com/scip-code/scip-go/tree/v0.2.7) | `samchon-graph-go`, `go`, `scip-go` | — | `SAMCHON_GRAPH_GO`, `SAMCHON_GRAPH_GO_TOOLCHAIN`, `SAMCHON_GRAPH_SCIP_GO` | Project/PATH `samchon-graph-go`, then the shipped source runner through Go; absolute environment overrides take precedence. | Go workspace/module inputs, selected GOOS/GOARCH/cgo environment, embedded files and vendored inputs. | `linux`, `macos`, `windows` | | `samchon-graph-lua` | Install `lua-language-server`; the package ships `sidecars/lua/export.lua`. | [LuaLS releases](https://github.com/LuaLS/lua-language-server/releases) | `lua-language-server` | — | `SAMCHON_GRAPH_LUA`, `SAMCHON_GRAPH_LUA_EXPORTER` | Absolute `SAMCHON_GRAPH_LUA`, then project/PATH LuaLS; the shipped exporter may be replaced by `SAMCHON_GRAPH_LUA_EXPORTER`. | LuaLS workspace configuration and the shipped readable exporter. | `linux`, `macos`, `windows` | | `rust-analyzer-scip` | `rustup component add rust-analyzer`; install the `scip` decoder and provide matching rustc/Cargo. | [rust-analyzer installation](https://rust-analyzer.github.io/book/rust_analyzer_binary.html), [SCIP releases](https://github.com/sourcegraph/scip/releases) | `rust-analyzer`, `scip`, `rustc`, `cargo` | — | `SAMCHON_GRAPH_RUST_ANALYZER`, `SAMCHON_GRAPH_SCIP`, `SAMCHON_GRAPH_RUSTC`, `SAMCHON_GRAPH_CARGO` | Project-local tools precede PATH; each absolute environment override replaces only its named tool. | Cargo metadata/config, lock/toolchain inputs, target/features/cfg and build-script/proc-macro universe. | `linux`, `macos`, `windows` | @@ -144,7 +144,7 @@ A strict result's provenance name must equal the provider below. If it is absent | Languages | Expected provenance | Common boundary | Common decline | Fallback | | --- | --- | --- | --- | --- | -| `typescript` | `ttscgraph` | The current producer does not yet make changed-response work proportional to the compiler invalidated closure. | A missing target-project ttsc binary, incompatible request cap, malformed response or unsupported schema declines the strict provider. | `ttscserver`, then `@samchon/graph-sitter`. | +| `typescript` | `ttscgraph` | No compatible ttsc release is published yet. Version 0.23.0 returns a legacy complete dump and therefore falls back honestly until the native shard producer ships. | A missing target-project ttsc binary, legacy full-dump producer, incompatible request cap, malformed transaction or unsupported schema declines the strict provider. | `ttscserver`, then `@samchon/graph-sitter`. | | `go` | `samchon-graph-go` | Current changed-input export is a full rebuild and does not retain a resident `go/packages` checker session. | A missing Go 1.25+ toolchain, missing pinned scip-go corroborator or invalid workspace/module load declines the strict provider. | `gopls`, then `@samchon/graph-sitter`. | | `lua` | `samchon-graph-lua` | The current exporter calls `vm.getRefs` per declaration and proves references only; #83 replaces it with an occurrence-oriented resident traversal. | A missing LuaLS binary/exporter, invalid workspace result or bounded request declines the strict provider. | Generic LuaLS, then `@samchon/graph-sitter`. | | `rust` | `rust-analyzer-scip` | Stock rust-analyzer SCIP has empty relationships/diagnostics and is navigation evidence, not the final HIR graph. | A missing analyzer, decoder, rustc or Cargo component, or an invalid Cargo project load, declines the strict provider. | Generic rust-analyzer, then `@samchon/graph-sitter`. | diff --git a/docs/provider-support.json b/docs/provider-support.json index f042b5a9..65d7e82b 100644 --- a/docs/provider-support.json +++ b/docs/provider-support.json @@ -14,17 +14,20 @@ "facts": ["exports", "calls", "accesses", "instantiates", "type_ref", "extends", "implements", "overrides", "renders"], "commands": ["ttscgraph", "ttscserver"], "environmentOverrides": ["TTSC_GRAPH_BINARY"], - "install": "`npm i -D ttsc@^0.23.0 typescript`", - "installSources": [{"label": "ttsc 0.23.0 on npm", "url": "https://www.npmjs.com/package/ttsc/v/0.23.0"}], + "install": "Install a ttsc release that supports graph snapshot protocol v1. `ttsc@0.23.0` provides the ordinary `ttscserver` fallback but predates this strict protocol.", + "installSources": [ + {"label": "ttsc 0.23.0 legacy release", "url": "https://www.npmjs.com/package/ttsc/v/0.23.0"}, + {"label": "native shard producer PR", "url": "https://github.com/samchon/ttsc/pull/1056"} + ], "resolution": "Absolute `TTSC_GRAPH_BINARY`, target-project `ttsc` package/binary, target-project `.bin`, then PATH/global compatibility fallback.", "requirements": "A matching ttsc/TypeScript project and tsconfig/jsconfig/package inputs.", "platforms": ["linux", "macos", "windows"], - "mode": "resident-no-op-reuse; full-rebuild-on-change", - "nativeAnalysis": "The target project's ttsc checker owns one resident compiler process.", - "exportMerge": "Each changed response serializes and validates one complete compiler dump before publication.", - "reuseResident": "An identical producer generation reuses the exact dump; changed work is not yet proportional to the invalidated closure.", - "limitations": "The current producer does not yet make changed-response work proportional to the compiler invalidated closure.", - "decline": "A missing target-project ttsc binary, incompatible request cap, malformed response or unsupported schema declines the strict provider.", + "mode": "resident-no-op-reuse; invalidated-closure shard deltas with a compatible producer", + "nativeAnalysis": "A compatible target-project ttsc checker owns one resident compiler process and its incremental semantic state.", + "exportMerge": "Changed compiler-owned raw shards cross a versioned transaction; the client validates the complete manifest and adapts only upserts before atomic publication.", + "reuseResident": "Unchanged requests reuse the exact snapshot; body edits reuse unaffected native and normalized shards, while build-universe changes reload safely.", + "limitations": "No compatible ttsc release is published yet. Version 0.23.0 returns a legacy complete dump and therefore falls back honestly until the native shard producer ships.", + "decline": "A missing target-project ttsc binary, legacy full-dump producer, incompatible request cap, malformed transaction or unsupported schema declines the strict provider.", "fallback": "`ttscserver`, then `@samchon/graph-sitter`.", "experimentLanguages": ["typescript"], "experimentTool": "ttscgraph", diff --git a/packages/graph/src/provider/ttscgraph/ITtscGraphSnapshot.ts b/packages/graph/src/provider/ttscgraph/ITtscGraphSnapshot.ts index c0caca88..609f7b74 100644 --- a/packages/graph/src/provider/ttscgraph/ITtscGraphSnapshot.ts +++ b/packages/graph/src/provider/ttscgraph/ITtscGraphSnapshot.ts @@ -1,21 +1,19 @@ /** * One response frame of the `ttscgraph serve` protocol, as this client pins it. * - * Mirrored by hand from `serveResponse` in ttsc's - * `packages/ttsc/cmd/ttscgraph/serve.go`, first published at tag `v0.19.2` - * (`77192d97a`). There is no generator between the Go struct and this file, and - * there cannot be one this repository owns: the producer lives in another - * repository and ships as a prebuilt binary whose version the target project — - * not this package — chooses. That is why {@link ITtscGraphSnapshot.PROTOCOL_VERSION} - * exists, and why {@link parseTtscGraphSnapshot} validates every field on - * arrival instead of casting. + * Mirrored by hand from `serveResponse` and `serveGraphSnapshot` in ttsc's + * `packages/ttsc/cmd/ttscgraph`. There is no generator between the Go structs + * and this file, and there cannot be one this repository owns: the producer + * lives in another repository and ships as a prebuilt binary whose version the + * target project — not this package — chooses. That is why the envelope and + * native transaction have independent version pins, and why every field is + * validated on arrival instead of cast. * - * This is the envelope only. The `dump` it carries stays `unknown` here on - * purpose: {@link adaptTtscGraphDump} validates the body field by field into - * the product's own structures, so restating the body's wire shape would add a - * second contract to keep in sync with the same Go struct — and it would be the - * one the adapter never consults, which is the kind of duplicate that goes - * stale without anything failing. + * This is the envelope only. The native `snapshot` stays `unknown` here on + * purpose: {@link TtscGraphSnapshotStore} validates its transaction and shard + * fields before adapting changed shards into the product protocol. Restating + * that wire shape as a trusted TypeScript type would add a duplicate contract + * that can drift without protecting the runtime boundary. */ export type ITtscGraphSnapshot = | ITtscGraphSnapshot.IFailure @@ -57,6 +55,7 @@ export namespace ITtscGraphSnapshot { error: string; changed: false; dump?: undefined; + snapshot?: undefined; } /** A request the producer answered, whether or not the graph moved. */ @@ -75,8 +74,11 @@ export namespace ITtscGraphSnapshot { /** Whether the graph moved since the last snapshot. */ changed: boolean; - /** The snapshot body, present exactly when `changed` is true. */ - dump?: unknown; + /** The native shard transaction, present exactly when `changed` is true. */ + snapshot?: unknown; + + /** Legacy full dumps are refused by this incremental client. */ + dump?: undefined; } /** * The serve protocol version this client speaks. @@ -96,8 +98,11 @@ export namespace ITtscGraphSnapshot { */ export const PROTOCOL_VERSION = 1; + /** Native graph-shard transaction requested from compatible producers. */ + export const GRAPH_SNAPSHOT_VERSION = 1; + /** - * The version of the dump body this client adapts. + * The version of the compiler fact schema carried by native shards. * * Independent of {@link PROTOCOL_VERSION}: one versions the NDJSON envelope, * the other the graph document inside a changed frame. Keep this equal to diff --git a/packages/graph/src/provider/ttscgraph/TtscGraphClient.ts b/packages/graph/src/provider/ttscgraph/TtscGraphClient.ts index e8865567..e0494ce9 100644 --- a/packages/graph/src/provider/ttscgraph/TtscGraphClient.ts +++ b/packages/graph/src/provider/ttscgraph/TtscGraphClient.ts @@ -5,10 +5,10 @@ import { ownedProcess } from "../../utils/ownedProcess"; import { spawnableCommand } from "../../utils/spawnableCommand"; import { GraphSnapshotProtocol } from "../GraphSnapshotProtocol"; import { IBulkGraphSession } from "../IBulkGraphSession"; -import { adaptTtscGraphDump } from "./adaptTtscGraphDump"; -import { createTtscGraphProtocolTransaction } from "./createTtscGraphProtocolTransaction"; import { ITtscGraphSnapshot } from "./ITtscGraphSnapshot"; import { parseTtscGraphSnapshot } from "./parseTtscGraphSnapshot"; +import { TtscGraphSnapshotStore } from "./TtscGraphSnapshotStore"; +import { ttscGraphPhaseTrace } from "./ttscGraphPhaseTrace"; const DEFAULT_REQUEST_TIMEOUT_MS = 300_000; const DEFAULT_MAX_RESPONSE_BYTES = 256 * 1024 * 1024; @@ -19,6 +19,7 @@ interface NativeChild { stdoutChunks: string[]; stdoutBytes: number; stderr: string; + phaseTraceBuffer: string; exit: Promise; /** Resolves when every stream is finished, not merely when the child left. */ @@ -51,6 +52,8 @@ export class TtscGraphClient implements IBulkGraphSession { snapshot: IBulkGraphSession.ISnapshot, ) => void; private readonly protocol: GraphSnapshotProtocol.Store; + private readonly nativeProtocol: TtscGraphSnapshotStore; + private readonly phaseTrace: ttscGraphPhaseTrace.ITrace | undefined; private child: NativeChild | undefined; private readonly ownedChildren = new Set(); private readonly pending = new Map(); @@ -105,6 +108,8 @@ export class TtscGraphClient implements IBulkGraphSession { this.maxResponseBytes = maxResponseBytes; this.validate = options.validate ?? (() => undefined); this.protocol = new GraphSnapshotProtocol.Store(this.root); + this.nativeProtocol = new TtscGraphSnapshotStore(this.root); + this.phaseTrace = ttscGraphPhaseTrace(); } public get generation(): number { @@ -123,8 +128,16 @@ export class TtscGraphClient implements IBulkGraphSession { } return this.enqueue(async () => { this.assertOpen(); + const refreshStarted = performance.now(); try { + const requestStarted = performance.now(); const response = await this.request(options.signal); + this.trace( + response.id, + response.mode, + "producer-roundtrip", + requestStarted, + ); if (response.mode === "error") { throw new Error(`ttscgraph: ${response.error}`); } @@ -139,6 +152,7 @@ export class TtscGraphClient implements IBulkGraphSession { response.capabilities, this.snapshot.provenance.capabilities, ); + this.trace(response.id, mode, "mcp-ready", refreshStarted); return { changed: false, generation: this.version, @@ -147,34 +161,39 @@ export class TtscGraphClient implements IBulkGraphSession { }; } - const adapted = adaptTtscGraphDump(response.dump, this.root); - const provenance: IBulkGraphSession.IProvenance = { - ...adapted.provenance, - protocolVersion: response.protocolVersion, - }; - assertCapabilitiesMatch(response.capabilities, provenance.capabilities); + const nativeStarted = performance.now(); + const prepared = this.nativeProtocol.prepare(response.snapshot, { + sequence: this.version + 1, + previous: this.snapshot, + }); + this.trace(response.id, mode, "native-normalize", nativeStarted); + assertCapabilitiesMatch( + response.capabilities, + prepared.capabilities, + ); if ( mode === "incremental" && this.snapshot !== undefined && - this.snapshot.provenance.universe !== provenance.universe + this.snapshot.provenance.universe !== prepared.universe ) { throw new Error( - "ttscgraph: incremental snapshot reports a build universe that moved since the last generation, so its program cannot have been reused", + "ttscgraph: incremental snapshot reports a build universe that " + + "moved since the last generation, so its program cannot have " + + "been reused", ); } - const frames = createTtscGraphProtocolTransaction(adapted, { - root: this.root, - sequence: this.version + 1, - previous: this.snapshot, - }); - const next = this.protocol.apply(frames, { + const commonStarted = performance.now(); + const next = this.protocol.apply(prepared.frames, { signal: options.signal, - warnings: adapted.warnings, + warnings: prepared.warnings, validate: this.validate, }); + prepared.commit(); this.snapshot = next; this.childHasSnapshot = true; this.version += 1; + this.trace(response.id, mode, "common-commit", commonStarted); + this.trace(response.id, mode, "mcp-ready", refreshStarted); return { changed: true, generation: this.version, @@ -203,6 +222,20 @@ export class TtscGraphClient implements IBulkGraphSession { return this.closing; } + private trace( + request: number, + mode: string, + phase: ttscGraphPhaseTrace.IEvent["phase"], + started: number, + ): void { + this.phaseTrace?.event({ + request, + mode, + phase, + durationMs: performance.now() - started, + }); + } + private request(signal?: AbortSignal): Promise { if (signal?.aborted) throw cancelledError(signal); const child = this.ensureChild(); @@ -237,12 +270,14 @@ export class TtscGraphClient implements IBulkGraphSession { pending.abort!(); return; } - child.process.stdin.write(`${JSON.stringify({ id })}\n`, (error) => { - /* c8 ignore start -- Windows keeps the inherited named-pipe read - * handle until child exit. This callback-specific EPIPE path is - * POSIX-only and is exercised there. */ - if (error === null || error === undefined) return; - if (this.pending.get(id) !== pending) return; + child.process.stdin.write( + `${JSON.stringify({ id, graphSnapshotVersion: ITtscGraphSnapshot.GRAPH_SNAPSHOT_VERSION })}\n`, + (error) => { + /* c8 ignore start -- Windows keeps the inherited named-pipe read + * handle until child exit. This callback-specific EPIPE path is + * POSIX-only and is exercised there. */ + if (error === null || error === undefined) return; + if (this.pending.get(id) !== pending) return; // EPIPE says our end of the pipe closed, which is never the diagnosis: // the child exited before it could accept the request, and why it did // is whatever it printed on the way out. The timeout path beside this @@ -264,17 +299,18 @@ export class TtscGraphClient implements IBulkGraphSession { // broken pipe does not prove a dead child: a producer that closes its // stdin and keeps running never fires `close`, and waiting for it would // hold the request until the timeout for a fault that is already known. - void drained(child).then(() => { - if (this.pending.get(id) !== pending) return; - this.failChild( - child, - new Error( - `ttscgraph: could not request snapshot: ${error.message}${TtscGraphClient.exitSuffix(child.process)}${stderrSuffix(child)}`, - ), - ); - }); - /* c8 ignore stop */ - }); + void drained(child).then(() => { + if (this.pending.get(id) !== pending) return; + this.failChild( + child, + new Error( + `ttscgraph: could not request snapshot: ${error.message}${TtscGraphClient.exitSuffix(child.process)}${stderrSuffix(child)}`, + ), + ); + }); + /* c8 ignore stop */ + }, + ); }); } @@ -327,6 +363,7 @@ export class TtscGraphClient implements IBulkGraphSession { stdoutChunks: [], stdoutBytes: 0, stderr: "", + phaseTraceBuffer: "", exit: ownedProcess.exit(spawned), // `close` and not `exit`, which is the whole point. `ownedProcess.exit` // settles on whichever of error, exit or close arrives first, and exit @@ -338,12 +375,19 @@ export class TtscGraphClient implements IBulkGraphSession { }; this.child = child; this.childHasSnapshot = false; + this.nativeProtocol.reset(); this.ownedChildren.add(child); spawned.stdout.setEncoding("utf8"); spawned.stderr.setEncoding("utf8"); spawned.stdout.on("data", (chunk: string) => this.consume(child, chunk)); spawned.stderr.on("data", (chunk: string) => { child.stderr = (child.stderr + chunk).slice(-64 * 1024); + if (this.phaseTrace !== undefined) { + child.phaseTraceBuffer = this.phaseTrace.forwardProducer( + child.phaseTraceBuffer, + chunk, + ); + } }); /* c8 ignore start -- direct POSIX spawn failures are exercised on POSIX. * Windows starts a stable Job Object supervisor first and reports a nested diff --git a/packages/graph/src/provider/ttscgraph/TtscGraphSnapshotStore.ts b/packages/graph/src/provider/ttscgraph/TtscGraphSnapshotStore.ts new file mode 100644 index 00000000..6fafdb95 --- /dev/null +++ b/packages/graph/src/provider/ttscgraph/TtscGraphSnapshotStore.ts @@ -0,0 +1,766 @@ +import { Buffer } from "node:buffer"; +import { createHash } from "node:crypto"; +import path from "node:path"; + +import { ISamchonGraphCoverage } from "../../structures"; +import { GRAPH_EDGE_KINDS } from "../../typings"; +import { GraphSnapshotProtocol } from "../GraphSnapshotProtocol"; +import { IBulkGraphSession } from "../IBulkGraphSession"; +import { adaptTtscGraphDump } from "./adaptTtscGraphDump"; +import { ITtscGraphSnapshot } from "./ITtscGraphSnapshot"; + +interface INativeShard { + key: string; + source?: Record; + config?: Record; + nodes: unknown[]; + edges: unknown[]; + diagnostics: unknown[]; +} + +interface INativeTransaction { + protocolVersion: number; + schemaVersion: number; + project: string; + tsconfig: string; + producer: Record; + capabilities: string[]; + universe: Record; + sequence: number; + generation: string; + baseSequence?: number; + baseGeneration?: string; + upserts: { + digest: string; + shard: INativeShard; + rawShard: Record; + }[]; + deletes: string[]; + manifest: { key: string; digest: string }[]; +} + +interface ICommittedNativeShard { + digest: string; + shard: INativeShard; +} + +/** Validates native ttsc shards and maps only their deltas into common shards. */ +export class TtscGraphSnapshotStore { + public static readonly VERSION = 1; + + private sequence: number | undefined; + private generation: string | undefined; + private project: string | undefined; + private tsconfig: string | undefined; + private native = new Map(); + private normalized = new Map(); + + public constructor(private readonly root: string) {} + + /** A new native child owns a new sequence space and must start completely. */ + public reset(): void { + this.sequence = undefined; + this.generation = undefined; + this.project = undefined; + this.tsconfig = undefined; + this.native = new Map(); + this.normalized = new Map(); + } + + /** + * Prepare one atomic common-protocol transaction without publishing native + * state. The caller commits only after the common store and product validator + * have accepted the same generation. + */ + public prepare( + input: unknown, + options: { + sequence: number; + previous?: IBulkGraphSession.ISnapshot; + }, + ): TtscGraphSnapshotStore.IPrepared { + const transaction = transactionOf(input); + this.assertCoordinates(transaction); + const touched = new Set(); + const nextNative = + transaction.baseGeneration === undefined + ? new Map() + : new Map(this.native); + for (const key of transaction.deletes) { + assertShardKey(key); + if (touched.has(key)) duplicateTouch(key); + touched.add(key); + if (!nextNative.delete(key)) { + throw new Error( + `ttscgraph: native transaction deletes unknown shard ${key}`, + ); + } + } + for (const upsert of transaction.upserts) { + assertShardKey(upsert.shard.key); + if (touched.has(upsert.shard.key)) duplicateTouch(upsert.shard.key); + touched.add(upsert.shard.key); + const digest = nativeDigest(upsert.rawShard); + if (digest !== upsert.digest) { + throw new Error( + `ttscgraph: native shard ${upsert.shard.key} digest ` + + `${upsert.digest} does not match ${digest}`, + ); + } + nextNative.set(upsert.shard.key, { + digest, + shard: upsert.shard, + }); + } + assertNativeManifest(transaction, nextNative); + assertNativeGeneration(transaction); + const nodeById = assertNativeGenerationFacts(transaction, nextNative); + + const provenance = nativeProvenance(transaction, nextNative); + const metadata = adaptTtscGraphDump( + { + project: transaction.project, + tsconfig: transaction.tsconfig, + provenance, + diagnostics: [], + nodes: [], + edges: [], + }, + this.root, + ); + const nextNormalized = + transaction.baseGeneration === undefined + ? new Map() + : new Map(this.normalized); + for (const key of transaction.deletes) nextNormalized.delete(key); + for (const upsert of transaction.upserts) { + nextNormalized.set( + upsert.shard.key, + adaptNativeShard( + upsert.shard, + transaction, + provenance, + nodeById, + metadata, + this.root, + ), + ); + } + + const hello = helloOf(metadata, transaction.schemaVersion); + const coverage = coverageShard(metadata, hello); + if (nextNative.has(coverage.key)) { + throw new Error( + `ttscgraph: native shard uses reserved normalized key ${coverage.key}`, + ); + } + for (const key of nextNormalized.keys()) { + if (key.startsWith("0:coverage:") && key !== coverage.key) { + nextNormalized.delete(key); + } + } + nextNormalized.set(coverage.key, coverage); + + const ordered = [...nextNormalized].sort(([left], [right]) => + compareText(left, right), + ); + const manifest = ordered.map(([key, shard]) => ({ + key, + digest: GraphSnapshotProtocol.shardDigest(shard), + })); + const sources = ordered.flatMap(([, shard]) => shard.sources); + const canReuse = + transaction.baseGeneration !== undefined && + options.previous?.protocol !== undefined; + const begin: GraphSnapshotProtocol.IBegin = { + type: "begin", + sequence: options.sequence, + generation: transaction.generation, + ...(canReuse + ? { + baseSequence: options.previous!.protocol!.sequence, + baseGeneration: options.previous!.protocol!.generation, + } + : {}), + universe: metadata.provenance.universe, + manifest: GraphSnapshotProtocol.manifestDigest(sources), + targets: [metadata.target], + }; + const assembled = assembledSnapshot(hello, begin, ordered); + const factDigest = GraphSnapshotProtocol.factDigest(assembled); + const frames: GraphSnapshotProtocol.Frame[] = [hello, begin]; + const previousNormalized = canReuse ? this.normalized : new Map(); + for (const key of previousNormalized.keys()) { + if (!nextNormalized.has(key)) frames.push({ type: "deleteShard", key }); + } + for (const [key, shard] of ordered) { + const digest = GraphSnapshotProtocol.shardDigest(shard); + const previous = previousNormalized.get(key); + if ( + previous === undefined || + GraphSnapshotProtocol.shardDigest(previous) !== digest + ) { + frames.push({ type: "upsertShard", digest, shard }); + } + } + frames.push({ + type: "commit", + sequence: begin.sequence, + generation: begin.generation, + shards: manifest, + factDigest, + }); + return { + frames, + capabilities: hello.capabilities, + universe: begin.universe, + warnings: metadata.warnings, + commit: () => { + this.sequence = transaction.sequence; + this.generation = transaction.generation; + this.project = transaction.project; + this.tsconfig = transaction.tsconfig; + this.native = nextNative; + this.normalized = nextNormalized; + }, + }; + } + + private assertCoordinates(transaction: INativeTransaction): void { + if (transaction.protocolVersion !== TtscGraphSnapshotStore.VERSION) { + throw new Error( + "ttscgraph: native snapshot protocol " + + `v${String(transaction.protocolVersion)} is incompatible with ` + + `client v${String(TtscGraphSnapshotStore.VERSION)}`, + ); + } + if ( + !ITtscGraphSnapshot.SUPPORTED_DUMP_SCHEMA_VERSIONS.includes( + transaction.schemaVersion, + ) + ) { + throw new Error( + "ttscgraph: native snapshot uses unsupported dump schema " + + `v${String(transaction.schemaVersion)}`, + ); + } + assertDigest(transaction.generation, "native transaction generation"); + if (this.sequence === undefined || this.generation === undefined) { + if ( + transaction.sequence !== 1 || + transaction.baseSequence !== undefined || + transaction.baseGeneration !== undefined || + transaction.deletes.length !== 0 + ) { + throw new Error( + "ttscgraph: initial native transaction is not a complete generation", + ); + } + return; + } + if ( + transaction.sequence !== this.sequence + 1 || + transaction.baseSequence !== this.sequence || + transaction.baseGeneration !== this.generation + ) { + throw new Error( + "ttscgraph: native transaction has stale base " + + `${String(transaction.baseSequence)}/` + + String(transaction.baseGeneration), + ); + } + if ( + transaction.project !== this.project || + transaction.tsconfig !== this.tsconfig + ) { + throw new Error( + "ttscgraph: native transaction changed its resident project coordinates", + ); + } + } +} + +export namespace TtscGraphSnapshotStore { + export interface IPrepared { + frames: GraphSnapshotProtocol.Frame[]; + capabilities: string[]; + universe: string; + warnings: string[]; + commit: () => void; + } +} + +function adaptNativeShard( + shard: INativeShard, + transaction: INativeTransaction, + provenance: Record, + nodeById: ReadonlyMap, + metadata: ReturnType, + root: string, +): GraphSnapshotProtocol.IShard { + const localIds = new Set( + shard.nodes.map((node, index) => + stringOf(objectOf(node, `${shard.key}.nodes[${String(index)}]`).id, "node.id"), + ), + ); + const nodes = [...shard.nodes]; + const includedIds = new Set(localIds); + for (let index = 0; index < shard.edges.length; index++) { + const edge = objectOf( + shard.edges[index], + `${shard.key}.edges[${String(index)}]`, + ); + const target = stringOf(edge.to, `${shard.key}.edges[${String(index)}].to`); + if (!includedIds.has(target)) { + nodes.push(nodeById.get(target)!); + includedIds.add(target); + } + } + const adapted = adaptTtscGraphDump( + { + project: transaction.project, + tsconfig: transaction.tsconfig, + provenance, + diagnostics: shard.diagnostics, + nodes, + edges: shard.edges, + }, + root, + ); + const localModuleFiles = new Set(); + for (const node of shard.nodes) { + const raw = objectOf(node, `${shard.key}.node`); + if (raw.kind === "module") { + localModuleFiles.add(stringOf(raw.file, `${shard.key}.node.file`)); + } + } + const sourceFile = shard.source?.file ?? shard.config?.file; + const sources: GraphSnapshotProtocol.ISource[] = []; + if (sourceFile !== undefined) { + const file = stringOf(sourceFile, `${shard.key}.source.file`); + const canonical = file.startsWith("bundled:///") + ? file + : path.resolve(root, file); + // `nativeProvenance` is derived from this same validated shard set, and + // `adaptTtscGraphDump` adds its validated configuration universe. + const digest = metadata.sources.get(canonical)!; + sources.push({ file: canonical, ...digest }); + } + return { + key: shard.key, + target: metadata.target, + languages: ["typescript"], + nodes: adapted.nodes.filter( + (node) => localIds.has(node.id) || localModuleFiles.has(node.id), + ), + edges: adapted.edges, + diagnostics: adapted.diagnostics, + coverage: [], + unresolved: [], + sources, + }; +} + +function nativeProvenance( + transaction: INativeTransaction, + shards: ReadonlyMap, +): Record { + const sources: Record[] = []; + for (const { shard } of shards.values()) { + if (shard.source !== undefined) sources.push({ ...shard.source }); + } + sources.sort((left, right) => + compareUtf8(stringOf(left.file, "source.file"), stringOf(right.file, "source.file")), + ); + return { + schemaVersion: transaction.schemaVersion, + capabilities: [...transaction.capabilities], + producer: transaction.producer, + universe: transaction.universe, + sources, + }; +} + +function helloOf( + metadata: ReturnType, + schemaVersion: number, +): GraphSnapshotProtocol.IHello { + return { + type: "hello", + protocolVersion: GraphSnapshotProtocol.VERSION, + schemaVersion: GraphSnapshotProtocol.SCHEMA_VERSION, + producerSchemaVersion: schemaVersion, + provider: metadata.provenance.provider, + producer: metadata.provenance.tool, + producerVersion: metadata.provenance.toolVersion, + compilerVersion: metadata.provenance.compilerVersion, + languages: ["typescript"], + authority: metadata.provenance.authority, + supportedFacts: [...metadata.provenance.facts], + capabilities: [...metadata.provenance.capabilities], + }; +} + +function coverageShard( + metadata: ReturnType, + hello: GraphSnapshotProtocol.IHello, +): GraphSnapshotProtocol.IShard { + const supported = new Set(hello.supportedFacts); + const coverage: ISamchonGraphCoverage[] = GRAPH_EDGE_KINDS.map((family) => ({ + provider: hello.provider, + language: "typescript", + target: metadata.target, + family, + state: supported.has(family) ? "partial" : "unsupported", + })); + return { + key: `0:coverage:${JSON.stringify([ + GraphSnapshotProtocol.VERSION, + hello.provider, + hello.producerVersion, + hello.compilerVersion, + "typescript", + metadata.target, + metadata.provenance.universe, + ])}`, + target: metadata.target, + languages: ["typescript"], + nodes: [], + edges: [], + diagnostics: [], + coverage, + unresolved: hello.supportedFacts.map((family) => ({ + provider: hello.provider, + language: "typescript", + target: metadata.target, + universe: metadata.provenance.universe, + family, + evidence: { file: metadata.target, startLine: 1, startCol: 1 }, + reason: "provider-gap", + })), + sources: [], + }; +} + +function assembledSnapshot( + hello: GraphSnapshotProtocol.IHello, + begin: GraphSnapshotProtocol.IBegin, + shards: readonly [string, GraphSnapshotProtocol.IShard][], +): Parameters[0] { + const values = shards.map(([, shard]) => shard); + return { + languages: [...hello.languages], + nodes: values.flatMap((shard) => shard.nodes), + edges: values.flatMap((shard) => shard.edges), + diagnostics: values.flatMap((shard) => shard.diagnostics), + coverage: values.flatMap((shard) => shard.coverage), + unresolved: values.flatMap((shard) => shard.unresolved), + provenance: { + provider: hello.provider, + authority: hello.authority, + facts: [...hello.supportedFacts], + schemaVersion: hello.producerSchemaVersion, + tool: hello.producer, + toolVersion: hello.producerVersion, + compilerVersion: hello.compilerVersion, + protocolVersion: hello.protocolVersion, + universe: begin.universe, + capabilities: [...hello.capabilities], + }, + }; +} + +function assertNativeGenerationFacts( + transaction: INativeTransaction, + shards: ReadonlyMap, +): Map { + const nodeById = new Map(); + const nodeOwners = new Map(); + const sourceFiles = new Set(); + const configs = new Map(); + for (const [key, { shard }] of shards) { + if (shard.source !== undefined && shard.config !== undefined) { + throw new Error(`ttscgraph: native shard ${key} owns two input kinds`); + } + const sourceFile = + shard.source === undefined + ? undefined + : stringOf(shard.source.file, `${key}.source.file`); + const configFile = + shard.config === undefined + ? undefined + : stringOf(shard.config.file, `${key}.config.file`); + if (sourceFile !== undefined) { + if (sourceFiles.has(sourceFile)) { + throw new Error(`ttscgraph: native source ${sourceFile} has two shards`); + } + sourceFiles.add(sourceFile); + } + if (configFile !== undefined) { + const digest = stringOf(shard.config!.digest, `${key}.config.digest`); + if (configs.has(configFile)) { + throw new Error(`ttscgraph: native config ${configFile} has two shards`); + } + configs.set(configFile, digest); + if (shard.nodes.length !== 0 || shard.edges.length !== 0) { + throw new Error(`ttscgraph: native config shard ${key} owns facts`); + } + } + if (sourceFile === undefined && shard.edges.length !== 0) { + throw new Error(`ttscgraph: native non-source shard ${key} owns edges`); + } + for (let index = 0; index < shard.nodes.length; index++) { + const node = objectOf(shard.nodes[index], `${key}.nodes[${String(index)}]`); + const id = stringOf(node.id, `${key}.nodes[${String(index)}].id`); + const file = stringOf(node.file, `${key}.nodes[${String(index)}].file`); + const external = booleanOf( + node.external, + `${key}.nodes[${String(index)}].external`, + ); + if ( + (sourceFile !== undefined && (external || file !== sourceFile)) || + (sourceFile === undefined && !external) + ) { + throw new Error(`ttscgraph: native shard ${key} misowns node ${id}`); + } + if (nodeById.has(id)) { + throw new Error(`ttscgraph: native node ${id} has two owners`); + } + nodeById.set(id, shard.nodes[index]); + nodeOwners.set(id, key); + } + for (let index = 0; index < shard.diagnostics.length; index++) { + const diagnostic = objectOf( + shard.diagnostics[index], + `${key}.diagnostics[${String(index)}]`, + ); + const file = stringOf( + diagnostic.file, + `${key}.diagnostics[${String(index)}].file`, + ); + if ( + (sourceFile !== undefined && file !== sourceFile) || + (configFile !== undefined && file !== configFile) || + (sourceFile === undefined && configFile === undefined && file !== "") + ) { + throw new Error(`ttscgraph: native shard ${key} misowns diagnostic`); + } + } + } + for (const [key, { shard }] of shards) { + for (let index = 0; index < shard.edges.length; index++) { + const edge = objectOf(shard.edges[index], `${key}.edges[${String(index)}]`); + const from = stringOf(edge.from, `${key}.edges[${String(index)}].from`); + const to = stringOf(edge.to, `${key}.edges[${String(index)}].to`); + if (nodeOwners.get(from) !== key) { + throw new Error(`ttscgraph: native shard ${key} misowns edge ${from}`); + } + if (!nodeById.has(to)) { + throw new Error(`ttscgraph: native edge target is absent: ${to}`); + } + } + } + const universe = objectOf(transaction.universe, "native universe"); + const universeConfigs = arrayOf(universe.configs, "native universe.configs"); + if (universeConfigs.length !== configs.size) { + throw new Error("ttscgraph: native config shards do not cover the universe"); + } + for (let index = 0; index < universeConfigs.length; index++) { + const config = objectOf( + universeConfigs[index], + `native universe.configs[${String(index)}]`, + ); + const file = stringOf(config.file, "native config.file"); + const digest = stringOf(config.digest, "native config.digest"); + if (configs.get(file) !== digest || !configs.delete(file)) { + throw new Error(`ttscgraph: native config shard disagrees at ${file}`); + } + } + return nodeById; +} + +function assertNativeManifest( + transaction: INativeTransaction, + shards: ReadonlyMap, +): void { + if (transaction.manifest.length !== shards.size) { + throw new Error("ttscgraph: native manifest does not cover the generation"); + } + for (let index = 0; index < transaction.manifest.length; index++) { + const entry = transaction.manifest[index]!; + assertShardKey(entry.key); + assertDigest(entry.digest, "native manifest digest"); + if ( + index !== 0 && + compareUtf8(transaction.manifest[index - 1]!.key, entry.key) >= 0 + ) { + throw new Error("ttscgraph: native manifest is not strictly key-sorted"); + } + if (shards.get(entry.key)?.digest !== entry.digest) { + throw new Error(`ttscgraph: native manifest disagrees at ${entry.key}`); + } + } +} + +function assertNativeGeneration(transaction: INativeTransaction): void { + const generation = nativeDigest({ + tsconfig: transaction.tsconfig, + producer: transaction.producer, + capabilities: transaction.capabilities, + universe: transaction.universe, + manifest: transaction.manifest, + }); + if (generation !== transaction.generation) { + throw new Error( + `ttscgraph: native generation ${transaction.generation} does not match ${generation}`, + ); + } +} + +function transactionOf(value: unknown): INativeTransaction { + const raw = objectOf(value, "native snapshot"); + const transaction: INativeTransaction = { + protocolVersion: integerOf(raw.protocolVersion, "native protocolVersion"), + schemaVersion: integerOf(raw.schemaVersion, "native schemaVersion"), + project: stringOf(raw.project, "native project"), + tsconfig: stringOf(raw.tsconfig, "native tsconfig"), + producer: objectOf(raw.producer, "native producer"), + capabilities: arrayOf(raw.capabilities, "native capabilities").map( + (entry, index) => stringOf(entry, `native capabilities[${String(index)}]`), + ), + universe: objectOf(raw.universe, "native universe"), + sequence: integerOf(raw.sequence, "native sequence"), + generation: stringOf(raw.generation, "native generation"), + upserts: arrayOf(raw.upserts, "native upserts").map((entry, index) => { + const upsert = objectOf(entry, `native upserts[${String(index)}]`); + const rawShard = objectOf( + upsert.shard, + `native upserts[${String(index)}].shard`, + ); + return { + digest: stringOf(upsert.digest, "native upsert.digest"), + shard: shardOf(rawShard, `native upserts[${String(index)}].shard`), + rawShard, + }; + }), + deletes: arrayOf(raw.deletes, "native deletes").map((entry, index) => + stringOf(entry, `native deletes[${String(index)}]`), + ), + manifest: arrayOf(raw.manifest, "native manifest").map((entry, index) => { + const reference = objectOf(entry, `native manifest[${String(index)}]`); + return { + key: stringOf(reference.key, "native manifest.key"), + digest: stringOf(reference.digest, "native manifest.digest"), + }; + }), + }; + if (raw.baseSequence !== undefined) { + transaction.baseSequence = integerOf( + raw.baseSequence, + "native baseSequence", + ); + } + if (raw.baseGeneration !== undefined) { + transaction.baseGeneration = stringOf( + raw.baseGeneration, + "native baseGeneration", + ); + } + if ( + (transaction.baseSequence === undefined) !== + (transaction.baseGeneration === undefined) + ) { + throw new Error("ttscgraph: native base coordinates are incomplete"); + } + return transaction; +} + +function shardOf(value: unknown, label: string): INativeShard { + const raw = objectOf(value, label); + const shard: INativeShard = { + key: stringOf(raw.key, `${label}.key`), + nodes: arrayOf(raw.nodes, `${label}.nodes`), + edges: arrayOf(raw.edges, `${label}.edges`), + diagnostics: arrayOf(raw.diagnostics, `${label}.diagnostics`), + }; + if (raw.source !== undefined) { + shard.source = objectOf(raw.source, `${label}.source`); + } + if (raw.config !== undefined) { + shard.config = objectOf(raw.config, `${label}.config`); + } + return shard; +} + +function nativeDigest(value: unknown): string { + return createHash("sha256").update(goJSON(value)).digest("hex"); +} + +function goJSON(value: unknown): string { + return JSON.stringify(value) + .replaceAll("<", "\\u003c") + .replaceAll(">", "\\u003e") + .replaceAll("&", "\\u0026") + .replaceAll("\u2028", "\\u2028") + .replaceAll("\u2029", "\\u2029"); +} + +function compareUtf8(left: string, right: string): number { + return Buffer.compare(Buffer.from(left, "utf8"), Buffer.from(right, "utf8")); +} + +function compareText(left: string, right: string): number { + // Map keys are unique, so shard ordering never compares equal identities. + return left < right ? -1 : 1; +} + +function duplicateTouch(key: string): never { + throw new Error(`ttscgraph: native transaction touches shard ${key} twice`); +} + +function assertShardKey(key: string): void { + if (key === "" || key.includes("\0")) { + throw new Error(`ttscgraph: native shard key is invalid: ${key}`); + } +} + +function assertDigest(value: string, label: string): void { + if (!/^[a-f0-9]{64}$/u.test(value)) { + throw new Error(`ttscgraph: ${label} must be a SHA-256 digest`); + } +} + +function objectOf(value: unknown, label: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`ttscgraph: ${label} must be an object`); + } + return value as Record; +} + +function arrayOf(value: unknown, label: string): unknown[] { + if (!Array.isArray(value)) { + throw new Error(`ttscgraph: ${label} must be an array`); + } + return value; +} + +function stringOf(value: unknown, label: string): string { + if (typeof value !== "string") { + throw new Error(`ttscgraph: ${label} must be a string`); + } + return value; +} + +function booleanOf(value: unknown, label: string): boolean { + if (typeof value !== "boolean") { + throw new Error(`ttscgraph: ${label} must be boolean`); + } + return value; +} + +function integerOf(value: unknown, label: string): number { + if (!Number.isSafeInteger(value) || (value as number) < 1) { + throw new Error(`ttscgraph: ${label} must be a positive safe integer`); + } + return value as number; +} diff --git a/packages/graph/src/provider/ttscgraph/adaptTtscGraphDump.ts b/packages/graph/src/provider/ttscgraph/adaptTtscGraphDump.ts index e314068f..b8e245a4 100644 --- a/packages/graph/src/provider/ttscgraph/adaptTtscGraphDump.ts +++ b/packages/graph/src/provider/ttscgraph/adaptTtscGraphDump.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import fs from "node:fs"; import path from "node:path"; import { compareOrdinal } from "@samchon/graph-sitter"; @@ -934,11 +935,28 @@ function validateNodeId(id: string, file: string, kind: GraphNodeKind): void { } function samePath(left: string, right: string): boolean { - const normalizedLeft = path.resolve(left); - const normalizedRight = path.resolve(right); + const normalizedLeft = physicalPath(left); + const normalizedRight = physicalPath(right); // Only one arm of this comparison runs on a given operating system. /* c8 ignore next 3 */ return process.platform === "win32" ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase() : normalizedLeft === normalizedRight; } + +/** Resolve aliases through the longest existing ancestor, including missing leaves. */ +function physicalPath(location: string): string { + let candidate = path.resolve(location); + const suffix: string[] = []; + for (;;) { + try { + return path.join(fs.realpathSync.native(candidate), ...suffix.reverse()); + } catch { + const parent = path.dirname(candidate); + /* c8 ignore next -- every supported platform has an existing filesystem root. */ + if (parent === candidate) return path.resolve(location); + suffix.push(path.basename(candidate)); + candidate = parent; + } + } +} diff --git a/packages/graph/src/provider/ttscgraph/parseTtscGraphSnapshot.ts b/packages/graph/src/provider/ttscgraph/parseTtscGraphSnapshot.ts index 118d5fb0..6a86d59b 100644 --- a/packages/graph/src/provider/ttscgraph/parseTtscGraphSnapshot.ts +++ b/packages/graph/src/provider/ttscgraph/parseTtscGraphSnapshot.ts @@ -92,8 +92,10 @@ export function parseTtscGraphSnapshot(value: unknown): ITtscGraphSnapshot { "ttscgraph: error response cannot also report a changed graph", ); } - if (raw.dump !== undefined) { - throw new Error("ttscgraph: error response unexpectedly included a dump"); + if (raw.dump !== undefined || raw.snapshot !== undefined) { + throw new Error( + "ttscgraph: error response unexpectedly included snapshot state", + ); } return { ...base, mode: "error", error: raw.error, changed: false }; } @@ -104,21 +106,28 @@ export function parseTtscGraphSnapshot(value: unknown): ITtscGraphSnapshot { ); } - // `changed` decides whether a dump rides along; the producer stakes its whole - // atomicity claim on that pairing, so a frame that breaks it is rejected here - // rather than surfacing later as an absent dump nobody expected. - if (raw.changed && raw.dump === undefined) { - throw new Error(`ttscgraph: changed ${mode} response omitted its full dump`); + // `changed` decides whether a shard transaction rides along; the producer + // stakes its whole atomicity claim on that pairing, so a frame that breaks it + // is rejected here rather than surfacing later as absent state. + if (raw.dump !== undefined) { + throw new Error( + "ttscgraph: binary returned a legacy full dump instead of graph snapshot protocol v1; install a matching ttsc", + ); + } + if (raw.changed && raw.snapshot === undefined) { + throw new Error( + `ttscgraph: changed ${mode} response omitted its native shard transaction`, + ); } - if (!raw.changed && raw.dump !== undefined) { + if (!raw.changed && raw.snapshot !== undefined) { throw new Error( - `ttscgraph: unchanged ${mode} response unexpectedly included a dump`, + `ttscgraph: unchanged ${mode} response unexpectedly included a native shard transaction`, ); } return { ...base, mode: mode as ITtscGraphSnapshot.ComputationMode, changed: raw.changed, - ...(raw.dump === undefined ? {} : { dump: raw.dump }), + ...(raw.snapshot === undefined ? {} : { snapshot: raw.snapshot }), }; } diff --git a/packages/graph/src/provider/ttscgraph/ttscGraphPhaseTrace.ts b/packages/graph/src/provider/ttscgraph/ttscGraphPhaseTrace.ts new file mode 100644 index 00000000..2352bc27 --- /dev/null +++ b/packages/graph/src/provider/ttscgraph/ttscGraphPhaseTrace.ts @@ -0,0 +1,61 @@ +import fs from "node:fs"; + +const PHASE_TRACE_ENVIRONMENT = "SAMCHON_GRAPH_TTSC_PHASE_TRACE"; +const PREFIX = "@samchon/graph: ttscgraph-phase "; +const PRODUCER_LINE = + /^@samchon\/graph: ttscgraph-phase owner=producer request=[1-9]\d* mode=(?:initial|reload|unchanged|incremental|rebuild|error) phase=(?:native-load|semantic-refresh|shard-export|encode|producer-total) durationMs=\d+\.\d{3}$/u; + +/** Opt-in, payload-free timing trace for the native TypeScript graph route. */ +export function ttscGraphPhaseTrace( + env: NodeJS.ProcessEnv = process.env, + write: (line: string) => unknown = (line) => + typeof process.stderr.fd === "number" + ? fs.writeSync(process.stderr.fd, line) + : process.stderr.write(line), +): ttscGraphPhaseTrace.ITrace | undefined { + if (env[PHASE_TRACE_ENVIRONMENT] !== "1") return undefined; + const emit = (line: string): void => { + try { + write(line); + } catch { + // Observability must never alter provider transport or publication. + } + }; + return { + event: (event) => { + emit( + `${PREFIX}owner=consumer request=${String(event.request)}` + + ` mode=${event.mode} phase=${event.phase}` + + ` durationMs=${event.durationMs.toFixed(3)}\n`, + ); + }, + forwardProducer: (buffer, chunk) => { + const joined = buffer + chunk; + const lines = joined.split("\n"); + const remainder = lines.pop()!; + for (const raw of lines) { + const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw; + if (PRODUCER_LINE.test(line)) emit(`${line}\n`); + } + return remainder.length <= 4_096 ? remainder : remainder.slice(-4_096); + }, + }; +} + +export namespace ttscGraphPhaseTrace { + export interface IEvent { + request: number; + mode: string; + phase: + | "producer-roundtrip" + | "native-normalize" + | "common-commit" + | "mcp-ready"; + durationMs: number; + } + + export interface ITrace { + event: (event: IEvent) => void; + forwardProducer: (buffer: string, chunk: string) => string; + } +} diff --git a/packages/graph/src/provider/ttscgraph/ttscGraphStrictRefusal.ts b/packages/graph/src/provider/ttscgraph/ttscGraphStrictRefusal.ts index 73fcd728..e5f8a9ff 100644 --- a/packages/graph/src/provider/ttscgraph/ttscGraphStrictRefusal.ts +++ b/packages/graph/src/provider/ttscgraph/ttscGraphStrictRefusal.ts @@ -43,7 +43,7 @@ export function ttscGraphStrictRefusal( // nothing — the reader needs the whole reason, not the first clause of it. return ( `typescript: ttscgraph bulk indexing is disabled by ${refused.join(", ")}; ` + - `the compiler-owned provider publishes whole-program snapshots and has no bounded mode, ` + + `the compiler-owned provider publishes whole-program generations and has no bounded mode, ` + `so this language falls through to the generic ttscserver LSP lane (and static fallback if that lane cannot answer). ` + `These facts are not compiler-owned. Drop ${ refused.length === 1 ? "that option" : "those options" diff --git a/tests/test-graph/src/features/test_provider_support_manifest_matches_registry_and_evidence.ts b/tests/test-graph/src/features/test_provider_support_manifest_matches_registry_and_evidence.ts index 3e4efa77..7d9dc4bd 100644 --- a/tests/test-graph/src/features/test_provider_support_manifest_matches_registry_and_evidence.ts +++ b/tests/test-graph/src/features/test_provider_support_manifest_matches_registry_and_evidence.ts @@ -5,6 +5,14 @@ import { spawnSync } from "node:child_process"; import { GraphPaths } from "../internal/GraphPaths"; +/** + * Provider claims are generated public data, so registry, evidence, platform, + * command-resolution, and README projections must remain one closed contract. + * + * 1. Validate the canonical manifest and both README line-ending forms. + * 2. Mutate each governed dimension independently through temporary manifests. + * 3. Require every drift to fail with the provider-specific reason. + */ export const test_provider_support_manifest_matches_registry_and_evidence = () => { const root = GraphPaths.createTempDirectory( diff --git a/tests/test-graph/src/features/test_readme_names_a_published_ttsc_install_range.ts b/tests/test-graph/src/features/test_readme_names_a_published_ttsc_install_range.ts deleted file mode 100644 index b6b223ca..00000000 --- a/tests/test-graph/src/features/test_readme_names_a_published_ttsc_install_range.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { TestValidator } from "@nestia/e2e"; -import fs from "node:fs"; -import path from "node:path"; - -import { GraphPaths } from "../internal/GraphPaths"; - -/** The public install command must name a version npm actually serves. */ -export const test_readme_names_a_published_ttsc_install_range = () => { - const install = "npm i -D ttsc@^0.23.0 typescript"; - const goInstall = - "go install github.com/scip-code/scip-go/cmd/scip-go@v0.2.7"; - for (const readme of [ - path.join(GraphPaths.repositoryRoot, "README.md"), - path.join(GraphPaths.graphPackageRoot, "README.md"), - ]) { - const text = fs.readFileSync(readme, "utf8"); - TestValidator.predicate( - `${path.relative(GraphPaths.repositoryRoot, readme)} names the published ttsc line`, - text.includes(install), - ); - TestValidator.predicate( - `${path.relative(GraphPaths.repositoryRoot, readme)} does not retain the obsolete ttsc line`, - text.includes("ttsc@^0.20.1") === false, - ); - TestValidator.predicate( - `${path.relative(GraphPaths.repositoryRoot, readme)} pins the Go navigation producer used by the bundled provider`, - text.includes(goInstall), - ); - } -}; diff --git a/tests/test-graph/src/features/test_readme_states_the_ttsc_shard_release_boundary.ts b/tests/test-graph/src/features/test_readme_states_the_ttsc_shard_release_boundary.ts new file mode 100644 index 00000000..47435414 --- /dev/null +++ b/tests/test-graph/src/features/test_readme_states_the_ttsc_shard_release_boundary.ts @@ -0,0 +1,40 @@ +import { TestValidator } from "@nestia/e2e"; +import fs from "node:fs"; +import path from "node:path"; + +import { GraphPaths } from "../internal/GraphPaths"; + +/** + * The published ttsc release predates native shard negotiation, so installation + * prose must not advertise that binary as satisfying the strict route. + * + * 1. Read both source and packaged README projections. + * 2. Require the legacy fallback boundary and pending producer link. + * 3. Retain the independently pinned Go corroboration command. + */ +export const test_readme_states_the_ttsc_shard_release_boundary = () => { + const fallback = + "`ttsc@0.23.0` provides the ordinary `ttscserver` fallback but predates this strict protocol"; + const producer = "native shard producer PR"; + const goInstall = + "go install github.com/scip-code/scip-go/cmd/scip-go@v0.2.7"; + for (const readme of [ + path.join(GraphPaths.repositoryRoot, "README.md"), + path.join(GraphPaths.graphPackageRoot, "README.md"), + ]) { + const text = fs.readFileSync(readme, "utf8"); + const label = path.relative(GraphPaths.repositoryRoot, readme); + TestValidator.predicate( + `${label} states the published ttsc fallback boundary`, + text.includes(fallback), + ); + TestValidator.predicate( + `${label} links the pending native producer`, + text.includes(producer), + ); + TestValidator.predicate( + `${label} pins the Go navigation producer used by the bundled provider`, + text.includes(goInstall), + ); + } +}; diff --git a/tests/test-graph/src/features/test_ttscgraph_bulk_provider_reuses_and_atomically_replaces_snapshots.ts b/tests/test-graph/src/features/test_ttscgraph_bulk_provider_reuses_and_atomically_replaces_snapshots.ts index 08e92bce..6043337f 100644 --- a/tests/test-graph/src/features/test_ttscgraph_bulk_provider_reuses_and_atomically_replaces_snapshots.ts +++ b/tests/test-graph/src/features/test_ttscgraph_bulk_provider_reuses_and_atomically_replaces_snapshots.ts @@ -12,6 +12,14 @@ import { ISamchonGraphDump } from "../../../../packages/graph/src/structures"; import { GRAPH_EDGE_KINDS } from "../../../../packages/graph/src/typings/GRAPH_EDGE_KINDS"; import { GraphPaths } from "../internal/GraphPaths"; +/** + * The reference TypeScript route must retain compiler evidence while applying + * native shard deltas as one common-protocol generation. + * + * 1. Publish a complete compiler-backed generation and reuse it unchanged. + * 2. Replace only the changed source-owned shards on an incremental response. + * 3. Verify normalized facts, provenance, coverage, reuse, and failure atomicity. + */ export const test_ttscgraph_bulk_provider_reuses_and_atomically_replaces_snapshots = async () => { const root = GraphPaths.createTempDirectory( @@ -50,8 +58,16 @@ export const test_ttscgraph_bulk_provider_reuses_and_atomically_replaces_snapsho }); const initial = await client.refresh(); - TestValidator.equals("the first full dump starts generation one", initial.generation, 1); - TestValidator.equals("the compiler language is added losslessly", initial.snapshot.nodes[0]?.language, "typescript"); + TestValidator.equals( + "the first native transaction starts generation one", + initial.generation, + 1, + ); + TestValidator.equals( + "the compiler language is added losslessly", + initial.snapshot.nodes.find((node) => node.name === "first")?.language, + "typescript", + ); TestValidator.equals("the module export surface folds onto its file", initial.snapshot.edges[0]?.from, "src/index.ts"); TestValidator.equals("edge evidence keeps the module source file", initial.snapshot.edges[0]?.evidence?.file, "src/index.ts"); TestValidator.predicate( @@ -82,9 +98,12 @@ export const test_ttscgraph_bulk_provider_reuses_and_atomically_replaces_snapsho ); TestValidator.predicate( "compiler flags and decorator literals survive adaptation", - initial.snapshot.nodes[0]?.ignored === true && - initial.snapshot.nodes[0]?.closure === true && - initial.snapshot.nodes[0]?.decorators?.[0]?.arguments[0]?.literal === 1, + initial.snapshot.nodes.find((node) => node.name === "first")?.ignored === + true && + initial.snapshot.nodes.find((node) => node.name === "first") + ?.closure === true && + initial.snapshot.nodes.find((node) => node.name === "first") + ?.decorators?.[0]?.arguments[0]?.literal === 1, ); TestValidator.equals( "the snapshot names its files by the digest the compiler read, not by their text", @@ -179,11 +198,11 @@ export const test_ttscgraph_bulk_provider_reuses_and_atomically_replaces_snapsho const changed = await client.refresh(); TestValidator.predicate( - "a validated full dump atomically replaces the snapshot", + "a validated shard transaction atomically replaces the snapshot", changed.changed && changed.generation === 2 && changed.snapshot !== initial.snapshot && - changed.snapshot.nodes[0]?.name === "second", + changed.snapshot.nodes.some((node) => node.name === "second"), ); TestValidator.equals( "a reused program is reported as incremental because the compiler said so", @@ -209,7 +228,7 @@ export const test_ttscgraph_bulk_provider_reuses_and_atomically_replaces_snapsho initialShards.has(key) && initialShards.get(key) !== digest, ).length, ], - [1, initial.snapshot.protocol?.generation, 4, 2], + [1, initial.snapshot.protocol?.generation, 6, 2], ); await rejects(client.refresh(), "serve errors are surfaced"); TestValidator.predicate( diff --git a/tests/test-graph/src/features/test_ttscgraph_dump_adapter_rejects_malformed_facts.ts b/tests/test-graph/src/features/test_ttscgraph_dump_adapter_rejects_malformed_facts.ts index 0be0c6b4..2031c4a6 100644 --- a/tests/test-graph/src/features/test_ttscgraph_dump_adapter_rejects_malformed_facts.ts +++ b/tests/test-graph/src/features/test_ttscgraph_dump_adapter_rejects_malformed_facts.ts @@ -540,6 +540,35 @@ export const test_ttscgraph_dump_adapter_rejects_malformed_facts = async () => { ), "a diagnostic without the diagnostics capability", ); + rejectsWithMessage( + () => + adaptTtscGraphDump( + mutate((d) => { + d.provenance.sources.push({ ...d.provenance.sources[0] }); + }), + project, + ), + "a duplicate source manifest entry", + "duplicate source manifest entry", + ); + rejectsWithMessage( + () => + adaptTtscGraphDump( + mutate((d) => { + d.diagnostics.push({ + file: "src/unloaded.ts", + line: 1, + column: 1, + code: 2322, + category: "error", + message: "unloaded finding", + }); + }), + project, + ), + "a diagnostic outside the source manifest", + "source manifest never loaded", + ); // Identity format and uniqueness. rejects(() => adaptTtscGraphDump(mutate((d) => ((d.nodes[1] as { id: string }).id = "no-hash-here")), project), "a node id that does not encode its file and kind"); @@ -569,6 +598,18 @@ export const test_ttscgraph_dump_adapter_rejects_malformed_facts = async () => { // Edge endpoints and uniqueness. rejects(() => adaptTtscGraphDump(mutate((d) => ((d.edges[0] as { from: string }).from = "src/a.ts#ghost:function")), project), "an edge from an unknown endpoint"); + rejectsWithMessage( + () => + adaptTtscGraphDump( + mutate((d) => { + (d.edges[0] as { to: string }).to = + "src/a.ts#src/a.ts:module"; + }), + project, + ), + "an edge to a folded module endpoint", + "unknown or folded to endpoint", + ); rejects(() => adaptTtscGraphDump(mutate((d) => d.edges.push({ ...(d.edges[0] as object) })), project), "a duplicate edge after module folding"); // Evidence spans and decorator literals. diff --git a/tests/test-graph/src/features/test_ttscgraph_native_shard_transactions_fail_atomically.ts b/tests/test-graph/src/features/test_ttscgraph_native_shard_transactions_fail_atomically.ts new file mode 100644 index 00000000..4edaddbe --- /dev/null +++ b/tests/test-graph/src/features/test_ttscgraph_native_shard_transactions_fail_atomically.ts @@ -0,0 +1,148 @@ +import { TestValidator } from "@nestia/e2e"; +import fs from "node:fs"; +import path from "node:path"; + +import { TtscGraphClient } from "../../../../packages/graph/src/provider/ttscgraph/TtscGraphClient"; +import { GraphPaths } from "../internal/GraphPaths"; + +/** + * The native shard lane is a separately versioned atomic transaction, so a + * bad shard digest, manifest or base must never become common graph state. + * + * 1. Request independently corrupted initial transactions through the real client. + * 2. Commit one good generation, then corrupt its incremental successor. + * 3. Require no initial publication and exact prior-object retention respectively. + */ +export const test_ttscgraph_native_shard_transactions_fail_atomically = + async () => { + const initialFailures: Record = { + "--native-invalid-digest": "native shard", + "--native-invalid-manifest": "manifest does not cover", + "--native-invalid-base": "initial native transaction", + "--native-invalid-protocol": "native snapshot protocol v2", + "--native-invalid-schema": "unsupported dump schema", + "--native-invalid-sequence-zero": "native sequence must be", + "--native-invalid-sequence-fraction": "native sequence must be", + "--native-invalid-generation-format": + "native transaction generation must be", + "--native-invalid-generation": "native generation", + "--native-invalid-initial-sequence": "initial native transaction", + "--native-invalid-base-sequence-only": + "native base coordinates are incomplete", + "--native-invalid-base-generation-only": + "native base coordinates are incomplete", + "--native-invalid-base-sequence-type": + "native baseSequence must be", + "--native-invalid-key-empty": "native shard key is invalid", + "--native-invalid-key-nul": "native shard key is invalid", + "--native-invalid-reserved-coverage": + "uses reserved normalized key", + "--native-invalid-two-input-kinds": "owns two input kinds", + "--native-invalid-duplicate-source": "has two shards", + "--native-invalid-duplicate-config": "has two shards", + "--native-invalid-config-facts": "config shard", + "--native-invalid-nonsource-edges": "non-source shard", + "--native-invalid-source-external-node": "misowns node", + "--native-invalid-source-foreign-node": "misowns node", + "--native-invalid-external-local-node": "misowns node", + "--native-invalid-duplicate-node": "has two owners", + "--native-invalid-source-diagnostic": "misowns diagnostic", + "--native-invalid-config-diagnostic": "misowns diagnostic", + "--native-invalid-metadata-diagnostic": "misowns diagnostic", + "--native-invalid-edge-owner": "misowns edge", + "--native-invalid-config-coverage": "do not cover the universe", + "--native-invalid-config-digest": "config shard disagrees", + "--native-invalid-manifest-sort": "not strictly key-sorted", + "--native-invalid-manifest-entry": "manifest disagrees", + "--native-invalid-manifest-digest-format": + "native manifest digest must be", + "--native-invalid-snapshot-string": "native snapshot must be an object", + "--native-invalid-snapshot-null": "native snapshot must be an object", + "--native-invalid-producer-array": "native producer must be an object", + "--native-invalid-capabilities-array": + "native capabilities must be an array", + "--native-invalid-project-string": "native project must be a string", + "--native-invalid-nodes-array": "nodes must be an array", + "--native-invalid-node-boolean": "external must be boolean", + }; + for (const [mode, expected] of Object.entries(initialFailures)) { + const client = create(fixture(), mode); + try { + const error = await rejectionOf(client.refresh()); + TestValidator.predicate( + `${mode} rejects before initial publication: ${errorText(error)}`, + error instanceof Error && + error.message.includes(expected) && + client.current === undefined && + client.generation === 0, + ); + } finally { + await client.close(); + } + } + + const incrementalFailures: Record = { + "--native-invalid-digest-third": "native shard", + "--native-invalid-base-third": "stale base", + "--native-invalid-project-third": "project coordinates", + "--native-invalid-tsconfig-third": "project coordinates", + "--native-invalid-delete-unknown-third": "deletes unknown shard", + "--native-invalid-delete-duplicate-third": "touches shard", + "--native-invalid-upsert-duplicate-third": "touches shard", + }; + for (const [mode, expected] of Object.entries(incrementalFailures)) { + const client = create(fixture(), mode); + try { + const initial = await client.refresh(); + const unchanged = await client.refresh(); + TestValidator.predicate( + `${mode} reuses the good base before its corrupt delta`, + unchanged.snapshot === initial.snapshot && !unchanged.changed, + ); + const error = await rejectionOf(client.refresh()); + TestValidator.predicate( + `${mode} retains the exact committed snapshot and generation: ${errorText(error)}`, + error instanceof Error && + error.message.includes(expected) && + client.current === initial.snapshot && + client.generation === 1, + ); + } finally { + await client.close(); + } + } + }; + +function create(root: string, mode: string): TtscGraphClient { + return new TtscGraphClient({ + root, + command: process.execPath, + args: [GraphPaths.fakeTtscGraphServer, mode], + }); +} + +function fixture(): string { + const root = GraphPaths.createTempDirectory("samchon-graph-native-shards-"); + fs.mkdirSync(path.join(root, "src", "core"), { recursive: true }); + fs.writeFileSync(path.join(root, "tsconfig.json"), "{}\n"); + fs.writeFileSync(path.join(root, "src", "index.ts"), "export {};\n"); + fs.writeFileSync( + path.join(root, "src", "core", "order.ts"), + "export function first() {}\n", + ); + fs.writeFileSync(path.join(root, "src", "empty.ts"), "export {};\n"); + return root; +} + +async function rejectionOf(task: Promise): Promise { + try { + await task; + return undefined; + } catch (error) { + return error; + } +} + +function errorText(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/tests/test-graph/src/features/test_ttscgraph_native_snapshot_accepts_canonical_project_alias.ts b/tests/test-graph/src/features/test_ttscgraph_native_snapshot_accepts_canonical_project_alias.ts new file mode 100644 index 00000000..41c0e4bb --- /dev/null +++ b/tests/test-graph/src/features/test_ttscgraph_native_snapshot_accepts_canonical_project_alias.ts @@ -0,0 +1,52 @@ +import { TestValidator } from "@nestia/e2e"; +import fs from "node:fs"; +import path from "node:path"; + +import { TtscGraphClient } from "../../../../packages/graph/src/provider/ttscgraph/TtscGraphClient"; +import { GraphPaths } from "../internal/GraphPaths"; + +/** + * The producer publishes its canonical physical project base while callers may + * select the same checkout through a symlink or Windows junction. + * + * 1. Create one physical project and address it through a filesystem alias. + * 2. Make the fake producer publish the physical base in its native transaction. + * 3. Require acceptance while preserving caller-root source identities. + */ +export const test_ttscgraph_native_snapshot_accepts_canonical_project_alias = + async () => { + const parent = GraphPaths.createTempDirectory( + "samchon-graph-native-project-alias-", + ); + const physical = path.join(parent, "physical"); + const alias = path.join(parent, "alias"); + fs.mkdirSync(path.join(physical, "src", "core"), { recursive: true }); + fs.writeFileSync(path.join(physical, "tsconfig.json"), "{}\n"); + fs.writeFileSync(path.join(physical, "src", "index.ts"), "export {};\n"); + fs.writeFileSync( + path.join(physical, "src", "core", "order.ts"), + "export function first() {}\n", + ); + fs.writeFileSync(path.join(physical, "src", "empty.ts"), "export {};\n"); + fs.symlinkSync( + physical, + alias, + process.platform === "win32" ? "junction" : "dir", + ); + + const client = new TtscGraphClient({ + root: alias, + command: process.execPath, + args: [GraphPaths.fakeTtscGraphServer, "--canonical-project"], + }); + try { + const snapshot = (await client.refresh()).snapshot; + TestValidator.predicate( + "the physical producer base and caller alias identify one project", + snapshot.nodes.some((node) => node.name === "first") && + snapshot.sources.has(path.join(alias, "src", "core", "order.ts")), + ); + } finally { + await client.close(); + } + }; diff --git a/tests/test-graph/src/features/test_ttscgraph_phase_trace_is_opt_in_and_filters_producer_stderr.ts b/tests/test-graph/src/features/test_ttscgraph_phase_trace_is_opt_in_and_filters_producer_stderr.ts new file mode 100644 index 00000000..ee977751 --- /dev/null +++ b/tests/test-graph/src/features/test_ttscgraph_phase_trace_is_opt_in_and_filters_producer_stderr.ts @@ -0,0 +1,188 @@ +import { TestValidator } from "@nestia/e2e"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { Worker } from "node:worker_threads"; + +import { ttscGraphPhaseTrace } from "../../../../packages/graph/src/provider/ttscgraph/ttscGraphPhaseTrace"; +import { GraphPaths } from "../internal/GraphPaths"; + +/** + * Phase evidence is a benchmark diagnostic, not a second transport surface. + * + * 1. Construct the trace with disabled and enabled isolated environments. + * 2. Emit a consumer phase and fragmented producer stderr containing noise. + * 3. Require stable timings while rejecting arbitrary diagnostics and paths. + */ +export const test_ttscgraph_phase_trace_is_opt_in_and_filters_producer_stderr = + async () => { + TestValidator.equals( + "the phase trace is disabled by default", + ttscGraphPhaseTrace({}, () => undefined), + undefined, + ); + const lines: string[] = []; + const trace = ttscGraphPhaseTrace( + { SAMCHON_GRAPH_TTSC_PHASE_TRACE: "1" }, + (line) => lines.push(line), + )!; + trace.event({ + request: 7, + mode: "incremental", + phase: "native-normalize", + durationMs: 12.3456, + }); + let buffered = trace.forwardProducer( + "", + "private diagnostic C:\\project\\secret.ts\n" + + "@samchon/graph: ttscgraph-phase C:\\project\\spoof.ts\n" + + "@samchon/graph: ttscgraph-", + ); + buffered = trace.forwardProducer( + buffered, + "phase owner=producer request=7 mode=incremental phase=shard-export durationMs=8.250\n", + ); + TestValidator.equals( + "a complete producer line leaves no buffer", + buffered, + "", + ); + TestValidator.equals( + "only payload-free phase rows reach the trace", + lines, + [ + "@samchon/graph: ttscgraph-phase owner=consumer request=7 " + + "mode=incremental phase=native-normalize durationMs=12.346\n", + "@samchon/graph: ttscgraph-phase owner=producer request=7 " + + "mode=incremental phase=shard-export durationMs=8.250\n", + ], + ); + TestValidator.equals( + "a carriage-return producer line is normalized before filtering", + trace.forwardProducer( + "", + "@samchon/graph: ttscgraph-phase owner=producer request=8 " + + "mode=unchanged phase=producer-total durationMs=1.000\r\n", + ), + "", + ); + TestValidator.equals( + "an unterminated producer diagnostic is bounded", + trace.forwardProducer("", "x".repeat(5_000)).length, + 4_096, + ); + const resilient = ttscGraphPhaseTrace( + { SAMCHON_GRAPH_TTSC_PHASE_TRACE: "1" }, + () => { + throw new Error("synthetic trace sink failure"); + }, + )!; + resilient.event({ + request: 9, + mode: "error", + phase: "mcp-ready", + durationMs: 1, + }); + TestValidator.predicate( + "a failed trace sink cannot change provider control flow", + true, + ); + + const root = GraphPaths.createTempDirectory("samchon-graph-phase-trace-"); + fs.mkdirSync(path.join(root, "src", "core"), { recursive: true }); + fs.writeFileSync(path.join(root, "tsconfig.json"), "{}\n"); + fs.writeFileSync(path.join(root, "src", "index.ts"), "export {};\n"); + fs.writeFileSync( + path.join(root, "src", "core", "order.ts"), + "export function first() {}\n", + ); + fs.writeFileSync(path.join(root, "src", "empty.ts"), "export {};\n"); + const clientModule = pathToFileURL( + path.join( + GraphPaths.graphPackageRoot, + "lib", + "provider", + "ttscgraph", + "TtscGraphClient.js", + ), + ).href; + const child = spawnSync( + process.execPath, + [ + "--input-type=module", + "--eval", + [ + `const { TtscGraphClient } = await import(${JSON.stringify(clientModule)});`, + "const client = new TtscGraphClient({", + ` root: ${JSON.stringify(root)},`, + " command: process.execPath,", + ` args: [${JSON.stringify(GraphPaths.fakeTtscGraphServer)}, "--phase-trace"],`, + "});", + "try { await client.refresh(); } finally { await client.close(); }", + ].join("\n"), + ], + { + cwd: root, + encoding: "utf8", + env: { + ...process.env, + SAMCHON_GRAPH_TTSC_PHASE_TRACE: "1", + }, + windowsHide: true, + }, + ); + TestValidator.predicate( + "the client forwards only exact producer rows beside its consumer phases", + child.status === 0 && + child.signal === null && + child.stderr.includes("owner=producer request=1 mode=initial") && + child.stderr.includes( + "owner=consumer request=1 mode=initial phase=mcp-ready", + ) && + !child.stderr.includes("spoof.ts"), + ); + + const traceModule = pathToFileURL( + path.join( + GraphPaths.graphPackageRoot, + "lib", + "provider", + "ttscgraph", + "ttscGraphPhaseTrace.js", + ), + ).href; + const worker = new Worker( + [ + "(async () => {", + ` const { ttscGraphPhaseTrace } = await import(${JSON.stringify(traceModule)});`, + " const trace = ttscGraphPhaseTrace({ SAMCHON_GRAPH_TTSC_PHASE_TRACE: '1' });", + " trace.event({ request: 11, mode: 'unchanged', phase: 'mcp-ready', durationMs: 2 });", + "})().catch((error) => { throw error; });", + ].join("\n"), + { eval: true, stderr: true }, + ); + worker.stderr.setEncoding("utf8"); + let workerStderr = ""; + worker.stderr.on("data", (chunk: string) => { + workerStderr += chunk; + }); + const exitPromise = new Promise((resolve, reject) => { + worker.once("error", reject); + worker.once("exit", resolve); + }); + const stderrEnd = new Promise((resolve, reject) => { + worker.stderr.once("error", reject); + worker.stderr.once("end", resolve); + }); + const [exit] = await Promise.all([exitPromise, stderrEnd]); + TestValidator.equals( + "a redirected Worker uses the stream writer when stderr has no fd", + [exit, workerStderr], + [ + 0, + "@samchon/graph: ttscgraph-phase owner=consumer request=11 " + + "mode=unchanged phase=mcp-ready durationMs=2.000\n", + ], + ); + }; diff --git a/tests/test-graph/src/features/test_ttscgraph_protocol_adapter_deletes_dependency_shards.ts b/tests/test-graph/src/features/test_ttscgraph_protocol_adapter_deletes_dependency_shards.ts index e4778318..227cd978 100644 --- a/tests/test-graph/src/features/test_ttscgraph_protocol_adapter_deletes_dependency_shards.ts +++ b/tests/test-graph/src/features/test_ttscgraph_protocol_adapter_deletes_dependency_shards.ts @@ -69,8 +69,10 @@ function dump( dependency: boolean, globalDiagnostic: boolean, ): unknown { + const bundled = "bundled:///libs/lib.es2015.collection.d.ts"; const files = [ "src/main.ts", + bundled, ...(dependency ? ["vendor/dependency.d.ts"] : []), ]; return { @@ -101,8 +103,21 @@ function dump( diskDigest: sha256(`${file}:disk`), })), }, - diagnostics: globalDiagnostic - ? [ + diagnostics: [ + ...(dependency + ? [ + { + file: "src/main.ts", + line: 1, + column: 1, + code: 2322, + category: "error", + message: "synthetic source finding", + }, + ] + : []), + ...(globalDiagnostic + ? [ { file: "", line: 0, @@ -111,8 +126,9 @@ function dump( category: "warning", message: "synthetic global finding", }, - ] - : [], + ] + : []), + ], nodes: [ { id: "src/main.ts#src/main.ts:module", @@ -128,6 +144,13 @@ function dump( file: "src/main.ts", external: false, }, + { + id: `${bundled}#Map:interface`, + kind: "interface", + name: "Map", + file: bundled, + external: true, + }, ...(dependency ? [ { @@ -140,6 +163,21 @@ function dump( ] : []), ], - edges: [], + edges: [ + { + from: "src/main.ts#run:function", + to: `${bundled}#Map:interface`, + kind: "type_ref", + }, + ...(dependency + ? [ + { + from: "src/main.ts#run:function", + to: "vendor/dependency.d.ts#Dependency:interface", + kind: "type_ref", + }, + ] + : []), + ], }; } diff --git a/tests/test-graph/src/features/test_ttscgraph_provider_reindexes_and_reassembles_streams.ts b/tests/test-graph/src/features/test_ttscgraph_provider_reindexes_and_reassembles_streams.ts index 5fae4842..e2389506 100644 --- a/tests/test-graph/src/features/test_ttscgraph_provider_reindexes_and_reassembles_streams.ts +++ b/tests/test-graph/src/features/test_ttscgraph_provider_reindexes_and_reassembles_streams.ts @@ -32,14 +32,18 @@ export const test_ttscgraph_provider_reindexes_and_reassembles_streams = const blank = await refreshOnce(root, "--blank-line"); TestValidator.predicate( "a blank NDJSON line is ignored and the real frame still applies", - blank.changed && blank.generation === 1 && blank.snapshot.nodes[0]?.name === "first", + blank.changed && + blank.generation === 1 && + blank.snapshot.nodes.some((node) => node.name === "first"), ); // A frame split across two stream chunks is reassembled before parsing. const split = await refreshOnce(root, "--split-frame"); TestValidator.predicate( "a frame split across stream chunks is reassembled", - split.changed && split.generation === 1 && split.snapshot.nodes[0]?.name === "first", + split.changed && + split.generation === 1 && + split.snapshot.nodes.some((node) => node.name === "first"), ); }; diff --git a/tests/test-graph/src/features/test_ttscgraph_published_legacy_dump_falls_back_honestly.ts b/tests/test-graph/src/features/test_ttscgraph_published_legacy_dump_falls_back_honestly.ts new file mode 100644 index 00000000..ae6ecca7 --- /dev/null +++ b/tests/test-graph/src/features/test_ttscgraph_published_legacy_dump_falls_back_honestly.ts @@ -0,0 +1,66 @@ +import { TestValidator } from "@nestia/e2e"; +import { buildGraphDump } from "@samchon/graph"; +import fs from "node:fs"; +import path from "node:path"; + +import { resolveTtscGraphCommand } from "../../../../packages/graph/src/provider/ttscgraph/resolveTtscGraphCommand"; +import { GraphPaths } from "../internal/GraphPaths"; + +/** + * A published producer that predates native shard negotiation cannot satisfy + * the strict incremental route merely because its complete dump is valid. + * + * 1. Resolve the workspace's independently published `ttscgraph` binary. + * 2. Ask the normal language route to index a strict TypeScript project. + * 3. Require an explicit provider refusal and an honest fallback result. + */ +export const test_ttscgraph_published_legacy_dump_falls_back_honestly = + async () => { + const resolved = resolveTtscGraphCommand(GraphPaths.graphPackageRoot); + TestValidator.predicate( + "the workspace resolves its published ttscgraph binary", + resolved !== undefined && resolved.args.length === 0, + ); + const root = GraphPaths.createTempDirectory("samchon-graph-schema3-real-"); + fs.mkdirSync(path.join(root, "src"), { recursive: true }); + fs.writeFileSync( + path.join(root, "tsconfig.json"), + JSON.stringify({ + compilerOptions: { strict: true }, + include: ["src/**/*.ts"], + }), + ); + fs.writeFileSync( + path.join(root, "src", "model.ts"), + 'export type Status = "ready" | "done";\n', + ); + + const previous = process.env.TTSC_GRAPH_BINARY; + process.env.TTSC_GRAPH_BINARY = resolved!.command; + try { + const dump = await buildGraphDump({ + cwd: root, + mode: "lsp", + languages: ["typescript"], + }); + TestValidator.predicate( + "the published legacy full-dump producer falls back instead of " + + "masquerading as a shard producer", + dump.warnings?.some( + (warning) => + warning.includes("provider failed") && + warning.includes("legacy full dump"), + ) === true && + (dump.provenance ?? []).every( + (row) => row.provider !== "ttscgraph", + ), + ); + TestValidator.predicate( + "the compatibility fallback still indexes the project source", + dump.nodes.some((node) => node.file === "src/model.ts"), + ); + } finally { + if (previous === undefined) delete process.env.TTSC_GRAPH_BINARY; + else process.env.TTSC_GRAPH_BINARY = previous; + } + }; diff --git a/tests/test-graph/src/features/test_ttscgraph_published_schema3_falls_back_honestly.ts b/tests/test-graph/src/features/test_ttscgraph_published_schema3_falls_back_honestly.ts deleted file mode 100644 index 2096be19..00000000 --- a/tests/test-graph/src/features/test_ttscgraph_published_schema3_falls_back_honestly.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { TestValidator } from "@nestia/e2e"; -import { buildGraphDump } from "@samchon/graph"; -import fs from "node:fs"; -import path from "node:path"; - -import { resolveTtscGraphCommand } from "../../../../packages/graph/src/provider/ttscgraph/resolveTtscGraphCommand"; -import { GraphPaths } from "../internal/GraphPaths"; - -export const test_ttscgraph_published_schema3_falls_back_honestly = async () => { - const resolved = resolveTtscGraphCommand(GraphPaths.graphPackageRoot); - TestValidator.predicate( - "the workspace resolves its published ttscgraph binary", - resolved !== undefined && resolved.args.length === 0, - ); - const root = GraphPaths.createTempDirectory("samchon-graph-schema3-real-"); - fs.mkdirSync(path.join(root, "src"), { recursive: true }); - fs.writeFileSync( - path.join(root, "tsconfig.json"), - JSON.stringify({ compilerOptions: { strict: true }, include: ["src/**/*.ts"] }), - ); - fs.writeFileSync( - path.join(root, "src", "model.ts"), - 'export type Status = "ready" | "done";\n', - ); - - const previous = process.env.TTSC_GRAPH_BINARY; - process.env.TTSC_GRAPH_BINARY = resolved!.command; - try { - const dump = await buildGraphDump({ - cwd: root, - mode: "lsp", - languages: ["typescript"], - }); - TestValidator.predicate( - "the pinned producer's schema 6 snapshot is accepted without fallback", - dump.warnings?.every( - (warning) => - !warning.includes("provider failed") && - !warning.includes("compatibility snapshot"), - ) === true && - dump.provenance?.some( - (row) => - row.provider === "ttscgraph" && - row.producer.schemaVersion === 6, - ) === true, - ); - TestValidator.predicate( - "the strict snapshot returns the project declaration", - dump.nodes.some((node) => node.name === "Status" && node.kind === "type"), - ); - } finally { - if (previous === undefined) delete process.env.TTSC_GRAPH_BINARY; - else process.env.TTSC_GRAPH_BINARY = previous; - } -}; diff --git a/tests/test-graph/src/features/test_ttscgraph_serve_envelope_is_validated_before_it_is_routed.ts b/tests/test-graph/src/features/test_ttscgraph_serve_envelope_is_validated_before_it_is_routed.ts index 027a7c3b..b5636cc7 100644 --- a/tests/test-graph/src/features/test_ttscgraph_serve_envelope_is_validated_before_it_is_routed.ts +++ b/tests/test-graph/src/features/test_ttscgraph_serve_envelope_is_validated_before_it_is_routed.ts @@ -32,8 +32,8 @@ export const test_ttscgraph_serve_envelope_is_validated_before_it_is_routed = }); TestValidator.equals("an unchanged frame keeps its reported mode", unchanged.mode, "unchanged"); TestValidator.equals( - "an unchanged frame carries no dump", - unchanged.dump, + "an unchanged frame carries no shard transaction", + unchanged.snapshot, undefined, ); TestValidator.equals( @@ -46,7 +46,7 @@ export const test_ttscgraph_serve_envelope_is_validated_before_it_is_routed = ...base, mode: "rebuild", changed: true, - dump: { any: "body" }, + snapshot: { any: "body" }, }); TestValidator.equals( "a changed frame keeps the compiler's own word for what it did", @@ -54,8 +54,8 @@ export const test_ttscgraph_serve_envelope_is_validated_before_it_is_routed = "rebuild", ); TestValidator.equals( - "a changed frame hands its dump on untouched, for the adapter to judge", - changed.dump, + "a changed frame hands its shard transaction on untouched, for the store to judge", + changed.snapshot, { any: "body" }, ); @@ -180,25 +180,25 @@ export const test_ttscgraph_serve_envelope_is_validated_before_it_is_routed = dump: {}, }); - // `changed` decides whether a dump rides along. The producer stakes its - // atomicity claim on that pairing, so a broken one is refused here rather - // than surfacing later as an absent dump nobody expected. - rejects("a changed frame with no dump", { + // `changed` decides whether a shard transaction rides along. The producer + // stakes its atomicity claim on that pairing, so a broken one is refused + // here rather than surfacing later as absent state. + rejects("a changed frame with no shard transaction", { ...base, mode: "initial", changed: true, }); - rejects("an unchanged frame carrying a dump anyway", { + rejects("an unchanged frame carrying a shard transaction anyway", { ...base, mode: "unchanged", changed: false, - dump: {}, + snapshot: {}, }); rejects("an unchanged mode that claims the graph moved", { ...base, mode: "unchanged", changed: true, - dump: {}, + snapshot: {}, }); rejects("a rebuild mode that claims nothing moved", { ...base, diff --git a/tests/test-graph/src/internal/fake-ttscgraph-server.cjs b/tests/test-graph/src/internal/fake-ttscgraph-server.cjs index 0ed31cae..892a06ac 100644 --- a/tests/test-graph/src/internal/fake-ttscgraph-server.cjs +++ b/tests/test-graph/src/internal/fake-ttscgraph-server.cjs @@ -5,8 +5,17 @@ const readline = require("node:readline"); const args = process.argv.slice(2); const cwdIndex = args.indexOf("--cwd"); -const project = cwdIndex === -1 ? process.cwd() : path.resolve(args[cwdIndex + 1]); -const invalidMode = args.find((arg) => arg.startsWith("--invalid")); +const requestedProject = + cwdIndex === -1 ? process.cwd() : path.resolve(args[cwdIndex + 1]); +const project = args.includes("--canonical-project") + ? fs.realpathSync.native(requestedProject) + : requestedProject; +const nativeInvalidMode = args.find((arg) => + arg.startsWith("--native-invalid"), +); +const invalidMode = args.find( + (arg) => arg.startsWith("--invalid") && !arg.startsWith("--native-invalid"), +); const markerArg = args.find((arg) => arg.startsWith("--marker=")); const marker = markerArg?.slice("--marker=".length); const stdinClosedMarkerArg = args.find((arg) => @@ -71,7 +80,9 @@ const envelopeCapabilityMismatch = args.includes( // the protocol, source-manifest, and lifecycle fixtures below untouched. const conformance = args.includes("--conformance"); const conformanceHeuristic = args.includes("--conformance-heuristic"); +const phaseTrace = args.includes("--phase-trace"); let requests = 0; +let nativeState; const CAPABILITIES = [ "universe", @@ -90,6 +101,17 @@ const BUNDLED_FILES = ["bundled:///libs/lib.es2015.collection.d.ts"]; const digestOf = (text) => crypto.createHash("sha256").update(text).digest("hex"); +const goJSON = (value) => + JSON.stringify(value).replace(/[<>&\u2028\u2029]/gu, (character) => { + if (character === "<") return "\\u003c"; + if (character === ">") return "\\u003e"; + if (character === "&") return "\\u0026"; + return character === "\u2028" ? "\\u2028" : "\\u2029"; + }); + +const compareUtf8 = (left, right) => + Buffer.compare(Buffer.from(left, "utf8"), Buffer.from(right, "utf8")); + const readProjectFile = (rel) => { try { return fs.readFileSync(path.join(project, rel), "utf8"); @@ -222,6 +244,304 @@ const graph = (name, options = {}) => ({ ], }); +/** Convert the fake compiler document into the same native shards as ttscgraph. */ +function nativeSnapshot(dump) { + const shards = new Map(); + const nodeFiles = new Map(dump.nodes.map((node) => [node.id, node.file])); + const sourceOccurrences = new Map(); + for (const source of dump.provenance.sources) { + const occurrence = sourceOccurrences.get(source.file) ?? 0; + sourceOccurrences.set(source.file, occurrence + 1); + const key = `1:source:${JSON.stringify([ + source.file, + source.checkerDigest, + ...(occurrence === 0 ? [] : [occurrence]), + ])}`; + shards.set(key, { + key, + source, + nodes: dump.nodes.filter( + (node) => !node.external && node.file === source.file, + ), + edges: dump.edges.filter( + (edge) => nodeFiles.get(edge.from) === source.file, + ), + diagnostics: dump.diagnostics.filter( + (diagnostic) => diagnostic.file === source.file, + ), + }); + } + for (const config of dump.provenance.universe.configs) { + const key = `3:config:${JSON.stringify([config.file, config.digest])}`; + shards.set(key, { + key, + config, + nodes: [], + edges: [], + diagnostics: dump.diagnostics.filter( + (diagnostic) => diagnostic.file === config.file, + ), + }); + } + const externalKey = "0:external"; + shards.set(externalKey, { + key: externalKey, + nodes: dump.nodes.filter((node) => node.external), + edges: [], + diagnostics: [], + }); + const metadataKey = "0:metadata"; + const inputFiles = new Set([ + ...dump.provenance.sources.map((source) => source.file), + ...dump.provenance.universe.configs.map((config) => config.file), + ]); + shards.set(metadataKey, { + key: metadataKey, + nodes: [], + edges: [], + diagnostics: dump.diagnostics.filter( + (diagnostic) => + diagnostic.file === "" || !inputFiles.has(diagnostic.file), + ), + }); + const committed = new Map( + [...shards].map(([key, shard]) => [ + key, + { digest: digestOf(goJSON(shard)), shard }, + ]), + ); + const manifest = [...committed] + .sort(([left], [right]) => compareUtf8(left, right)) + .map(([key, value]) => ({ key, digest: value.digest })); + const sequence = (nativeState?.sequence ?? 0) + 1; + const transaction = { + protocolVersion: 1, + schemaVersion: dump.provenance.schemaVersion, + project: dump.project, + tsconfig: dump.tsconfig, + producer: dump.provenance.producer, + capabilities: dump.provenance.capabilities, + universe: dump.provenance.universe, + sequence, + generation: digestOf( + goJSON({ + tsconfig: dump.tsconfig, + producer: dump.provenance.producer, + capabilities: dump.provenance.capabilities, + universe: dump.provenance.universe, + manifest, + }), + ), + ...(nativeState === undefined + ? {} + : { + baseSequence: nativeState.sequence, + baseGeneration: nativeState.generation, + }), + upserts: [...committed] + .filter( + ([key, value]) => nativeState?.shards.get(key)?.digest !== value.digest, + ) + .map(([, value]) => ({ digest: value.digest, shard: value.shard })), + deletes: + nativeState === undefined + ? [] + : [...nativeState.shards.keys()].filter((key) => !committed.has(key)), + manifest, + }; + nativeState = { + sequence, + generation: transaction.generation, + shards: committed, + }; + return transaction; +} + +function resignNativeGeneration(snapshot) { + snapshot.generation = digestOf( + goJSON({ + tsconfig: snapshot.tsconfig, + producer: snapshot.producer, + capabilities: snapshot.capabilities, + universe: snapshot.universe, + manifest: snapshot.manifest, + }), + ); +} + +function resignCompleteNativeSnapshot(snapshot) { + snapshot.upserts.forEach((upsert) => { + upsert.digest = digestOf(goJSON(upsert.shard)); + }); + snapshot.manifest = snapshot.upserts + .map((upsert) => ({ + key: upsert.shard.key, + digest: upsert.digest, + })) + .sort((left, right) => compareUtf8(left.key, right.key)); + resignNativeGeneration(snapshot); +} + +function nativeUniverseFingerprint(snapshot) { + const hash = crypto.createHash("sha256"); + const push = (text) => hash.update(`${String(text.length)}:${text}`); + push("configs"); + for (const config of snapshot.universe.configs) { + push(config.file); + push(config.digest); + } + push("roots"); + for (const root of snapshot.universe.roots) { + push(root.config); + push(root.file); + } + return hash.digest("hex"); +} + +function corruptNativeSnapshot(snapshot, mode) { + const source = (file) => + snapshot.upserts.find((upsert) => upsert.shard.source?.file === file); + const config = () => + snapshot.upserts.find((upsert) => upsert.shard.config !== undefined); + const external = () => + snapshot.upserts.find((upsert) => upsert.shard.key === "0:external"); + const metadata = () => + snapshot.upserts.find((upsert) => upsert.shard.key === "0:metadata"); + + if (mode === "--native-invalid-digest" || mode === "--native-invalid-digest-third") { + snapshot.upserts[0].digest = "0".repeat(64); + } else if (mode === "--native-invalid-manifest") { + snapshot.manifest.push({ ...snapshot.manifest[0] }); + } else if (mode === "--native-invalid-base" || mode === "--native-invalid-base-third") { + snapshot.baseSequence = 1; + snapshot.baseGeneration = "0".repeat(64); + } else if (mode === "--native-invalid-protocol") { + snapshot.protocolVersion = 2; + } else if (mode === "--native-invalid-schema") { + snapshot.schemaVersion = 4; + } else if (mode === "--native-invalid-sequence-zero") { + snapshot.sequence = 0; + } else if (mode === "--native-invalid-sequence-fraction") { + snapshot.sequence = 1.5; + } else if (mode === "--native-invalid-generation-format") { + snapshot.generation = "invalid"; + } else if (mode === "--native-invalid-generation") { + snapshot.generation = "0".repeat(64); + } else if (mode === "--native-invalid-initial-sequence") { + snapshot.sequence = 2; + } else if (mode === "--native-invalid-base-sequence-only") { + snapshot.baseSequence = 1; + } else if (mode === "--native-invalid-base-generation-only") { + snapshot.baseGeneration = "0".repeat(64); + } else if (mode === "--native-invalid-base-sequence-type") { + snapshot.baseSequence = "one"; + snapshot.baseGeneration = "0".repeat(64); + } else if (mode === "--native-invalid-project-third") { + snapshot.project = path.join(snapshot.project, "other"); + } else if (mode === "--native-invalid-tsconfig-third") { + snapshot.tsconfig = "other-tsconfig.json"; + } else if (mode === "--native-invalid-delete-unknown-third") { + snapshot.deletes.push("missing-shard"); + } else if (mode === "--native-invalid-delete-duplicate-third") { + snapshot.deletes.push(snapshot.manifest[0].key, snapshot.manifest[0].key); + } else if (mode === "--native-invalid-upsert-duplicate-third") { + snapshot.upserts.push(structuredClone(snapshot.upserts[0])); + } else if (mode === "--native-invalid-key-empty") { + snapshot.upserts[0].shard.key = ""; + resignCompleteNativeSnapshot(snapshot); + } else if (mode === "--native-invalid-key-nul") { + snapshot.upserts[0].shard.key = "bad\0key"; + resignCompleteNativeSnapshot(snapshot); + } else if (mode === "--native-invalid-reserved-coverage") { + snapshot.upserts[0].shard.key = `0:coverage:${JSON.stringify([ + 1, + "ttscgraph", + snapshot.producer.version, + snapshot.producer.typescript, + "typescript", + snapshot.tsconfig, + nativeUniverseFingerprint(snapshot), + ])}`; + resignCompleteNativeSnapshot(snapshot); + } else if (mode === "--native-invalid-two-input-kinds") { + source("src/empty.ts").shard.config = structuredClone( + snapshot.universe.configs[0], + ); + resignCompleteNativeSnapshot(snapshot); + } else if (mode === "--native-invalid-duplicate-source") { + source("src/empty.ts").shard.source.file = "src/index.ts"; + resignCompleteNativeSnapshot(snapshot); + } else if (mode === "--native-invalid-duplicate-config") { + const duplicate = structuredClone(config()); + duplicate.shard.key += ":duplicate"; + snapshot.upserts.push(duplicate); + resignCompleteNativeSnapshot(snapshot); + } else if (mode === "--native-invalid-config-facts") { + config().shard.nodes.push(structuredClone(external().shard.nodes[0])); + resignCompleteNativeSnapshot(snapshot); + } else if (mode === "--native-invalid-nonsource-edges") { + metadata().shard.edges.push(structuredClone(source("src/index.ts").shard.edges[0])); + resignCompleteNativeSnapshot(snapshot); + } else if (mode === "--native-invalid-source-external-node") { + source("src/core/order.ts").shard.nodes[0].external = true; + resignCompleteNativeSnapshot(snapshot); + } else if (mode === "--native-invalid-source-foreign-node") { + source("src/core/order.ts").shard.nodes[0].file = "src/index.ts"; + resignCompleteNativeSnapshot(snapshot); + } else if (mode === "--native-invalid-external-local-node") { + external().shard.nodes[0].external = false; + resignCompleteNativeSnapshot(snapshot); + } else if (mode === "--native-invalid-duplicate-node") { + metadata().shard.nodes.push(structuredClone(external().shard.nodes[0])); + resignCompleteNativeSnapshot(snapshot); + } else if (mode === "--native-invalid-source-diagnostic") { + source("src/core/order.ts").shard.diagnostics[0].file = "src/index.ts"; + resignCompleteNativeSnapshot(snapshot); + } else if (mode === "--native-invalid-config-diagnostic") { + config().shard.diagnostics.push({ file: "src/index.ts" }); + resignCompleteNativeSnapshot(snapshot); + } else if (mode === "--native-invalid-metadata-diagnostic") { + metadata().shard.diagnostics.push({ file: "src/index.ts" }); + resignCompleteNativeSnapshot(snapshot); + } else if (mode === "--native-invalid-edge-owner") { + source("src/index.ts").shard.edges[0].from = + source("src/core/order.ts").shard.nodes[0].id; + resignCompleteNativeSnapshot(snapshot); + } else if (mode === "--native-invalid-config-coverage") { + snapshot.upserts = snapshot.upserts.filter( + (upsert) => upsert.shard.config === undefined, + ); + resignCompleteNativeSnapshot(snapshot); + } else if (mode === "--native-invalid-config-digest") { + config().shard.config = { + ...config().shard.config, + digest: "0".repeat(64), + }; + resignCompleteNativeSnapshot(snapshot); + } else if (mode === "--native-invalid-manifest-sort") { + snapshot.manifest.reverse(); + resignNativeGeneration(snapshot); + } else if (mode === "--native-invalid-manifest-entry") { + snapshot.manifest[0].digest = "0".repeat(64); + resignNativeGeneration(snapshot); + } else if (mode === "--native-invalid-manifest-digest-format") { + snapshot.manifest[0].digest = "invalid"; + } else if (mode === "--native-invalid-producer-array") { + snapshot.producer = []; + } else if (mode === "--native-invalid-capabilities-array") { + snapshot.capabilities = {}; + } else if (mode === "--native-invalid-project-string") { + snapshot.project = 1; + } else if (mode === "--native-invalid-nodes-array") { + snapshot.upserts[0].shard.nodes = {}; + } else if (mode === "--native-invalid-node-boolean") { + source("src/core/order.ts").shard.nodes[0].external = "false"; + resignCompleteNativeSnapshot(snapshot); + } else { + throw new Error(`unknown native invalid mode: ${mode}`); + } +} + function conformanceNodes() { const ranges = conformanceRanges(); const nodes = [ @@ -389,6 +709,16 @@ input.on("line", (line) => { requests += 1; if (requestLog !== undefined) fs.writeFileSync(requestLog, `${requests}\n`); if (hangRequests) return; + if (request.graphSnapshotVersion !== 1) { + emit( + frame(request.id, { + changed: false, + mode: "error", + error: "graph snapshot protocol v1 was not requested", + }), + ); + return; + } let response; if (firstUnchanged) { // A first answer that reuses a snapshot that does not exist yet. @@ -435,12 +765,16 @@ input.on("line", (line) => { } else { throw new Error(`unknown invalid mode: ${invalidMode}`); } - response = frame(request.id, { changed: true, mode: "initial", dump }); + response = frame(request.id, { + changed: true, + mode: "initial", + snapshot: nativeSnapshot(dump), + }); } else if (requests === 1) { response = frame(request.id, { changed: true, mode: "initial", - dump: graph("first"), + snapshot: nativeSnapshot(graph("first")), }); } else if (requests === 2) { response = frame(request.id, { changed: false, mode: "unchanged" }); @@ -448,7 +782,9 @@ input.on("line", (line) => { response = frame(request.id, { changed: true, mode: "incremental", - dump: graph("second", universeDrift ? { drift: "moved" } : {}), + snapshot: nativeSnapshot( + graph("second", universeDrift ? { drift: "moved" } : {}), + ), }); } else { response = frame(request.id, { @@ -457,6 +793,26 @@ input.on("line", (line) => { error: "synthetic failure", }); } + if ( + nativeInvalidMode !== undefined && + response.snapshot !== undefined && + (nativeInvalidMode.endsWith("-third") ? requests === 3 : requests === 1) + ) { + if (nativeInvalidMode === "--native-invalid-snapshot-string") { + response.snapshot = "invalid"; + } else if (nativeInvalidMode === "--native-invalid-snapshot-null") { + response.snapshot = null; + } else corruptNativeSnapshot(response.snapshot, nativeInvalidMode); + } + if (phaseTrace) { + process.stderr.write( + "@samchon/graph: ttscgraph-phase C:\\private\\spoof.ts\n", + ); + process.stderr.write( + `@samchon/graph: ttscgraph-phase owner=producer request=${String(request.id)}` + + ` mode=${response.mode} phase=shard-export durationMs=1.000\n`, + ); + } emit(response); if (closeStdinAfterFirst && requests === 1) { input.close(); From 412628d0e2d9fff48f4a65ec712b293bf1bb4bac Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Sat, 1 Aug 2026 09:32:22 +0900 Subject: [PATCH 21/52] fix: harden incremental TypeScript graph snapshots --- packages/graph/build/provider-support.mjs | 9 + .../ttscgraph/TtscGraphSnapshotStore.ts | 356 ++++++++++++------ .../provider/ttscgraph/adaptTtscGraphDump.ts | 131 ++++--- tests/experiment/src/catalog.mjs | 8 + tests/experiment/src/run-language.mjs | 44 ++- tests/experiment/src/setup-language.mjs | 7 +- ...euses_and_atomically_replaces_snapshots.ts | 5 +- ...tive_shard_transactions_fail_atomically.ts | 7 +- ...tocol_adapter_deletes_dependency_shards.ts | 109 ++++++ .../src/internal/fake-ttscgraph-server.cjs | 115 ++++-- 10 files changed, 602 insertions(+), 189 deletions(-) diff --git a/packages/graph/build/provider-support.mjs b/packages/graph/build/provider-support.mjs index c386d9e7..81494e84 100644 --- a/packages/graph/build/provider-support.mjs +++ b/packages/graph/build/provider-support.mjs @@ -295,6 +295,15 @@ function validateManifest( ), `${language} experiment capabilities differ from the support manifest`, ); + if (experiment.strictReleaseBoundary !== undefined) { + for (const field of ["version", "warning", "reason"]) { + invariant( + typeof experiment.strictReleaseBoundary[field] === "string" && + experiment.strictReleaseBoundary[field] !== "", + `${language} strict release boundary must state ${field}`, + ); + } + } for (const fact of experiment.semanticEdges ?? []) { invariant( documented.facts.includes(fact), diff --git a/packages/graph/src/provider/ttscgraph/TtscGraphSnapshotStore.ts b/packages/graph/src/provider/ttscgraph/TtscGraphSnapshotStore.ts index 6fafdb95..c7cd066f 100644 --- a/packages/graph/src/provider/ttscgraph/TtscGraphSnapshotStore.ts +++ b/packages/graph/src/provider/ttscgraph/TtscGraphSnapshotStore.ts @@ -44,6 +44,23 @@ interface ICommittedNativeShard { shard: INativeShard; } +interface INativeShardSummary { + key: string; + sourceFile?: string; + configFile?: string; + configDigest?: string; + nodeIds: string[]; + edgeTargets: string[]; +} + +interface INativeValidation { + summaries: Map; + nodeById: Map; + sourceOwners: Map; + configs: Map; + edgeOwnersByTarget: Map>; +} + /** Validates native ttsc shards and maps only their deltas into common shards. */ export class TtscGraphSnapshotStore { public static readonly VERSION = 1; @@ -54,6 +71,8 @@ export class TtscGraphSnapshotStore { private tsconfig: string | undefined; private native = new Map(); private normalized = new Map(); + private validation = emptyNativeValidation(); + private coverageKey: string | undefined; public constructor(private readonly root: string) {} @@ -65,6 +84,8 @@ export class TtscGraphSnapshotStore { this.tsconfig = undefined; this.native = new Map(); this.normalized = new Map(); + this.validation = emptyNativeValidation(); + this.coverageKey = undefined; } /** @@ -114,20 +135,28 @@ export class TtscGraphSnapshotStore { } assertNativeManifest(transaction, nextNative); assertNativeGeneration(transaction); - const nodeById = assertNativeGenerationFacts(transaction, nextNative); + const nextValidation = prepareNativeGenerationFacts( + transaction, + transaction.baseGeneration === undefined + ? emptyNativeValidation() + : this.validation, + ); + const nodeById = nextValidation.nodeById; const provenance = nativeProvenance(transaction, nextNative); - const metadata = adaptTtscGraphDump( - { - project: transaction.project, - tsconfig: transaction.tsconfig, - provenance, - diagnostics: [], - nodes: [], - edges: [], - }, + const metadataInput = { + project: transaction.project, + tsconfig: transaction.tsconfig, + provenance, + diagnostics: [], + nodes: [], + edges: [], + }; + const adapterContext = adaptTtscGraphDump.prepareContext( + metadataInput, this.root, ); + const metadata = adapterContext.adapt(metadataInput); const nextNormalized = transaction.baseGeneration === undefined ? new Map() @@ -139,7 +168,7 @@ export class TtscGraphSnapshotStore { adaptNativeShard( upsert.shard, transaction, - provenance, + adapterContext, nodeById, metadata, this.root, @@ -149,15 +178,11 @@ export class TtscGraphSnapshotStore { const hello = helloOf(metadata, transaction.schemaVersion); const coverage = coverageShard(metadata, hello); - if (nextNative.has(coverage.key)) { - throw new Error( - `ttscgraph: native shard uses reserved normalized key ${coverage.key}`, - ); - } - for (const key of nextNormalized.keys()) { - if (key.startsWith("0:coverage:") && key !== coverage.key) { - nextNormalized.delete(key); - } + if ( + this.coverageKey !== undefined && + this.coverageKey !== coverage.key + ) { + nextNormalized.delete(this.coverageKey); } nextNormalized.set(coverage.key, coverage); @@ -222,6 +247,8 @@ export class TtscGraphSnapshotStore { this.tsconfig = transaction.tsconfig; this.native = nextNative; this.normalized = nextNormalized; + this.validation = nextValidation; + this.coverageKey = coverage.key; }, }; } @@ -293,7 +320,7 @@ export namespace TtscGraphSnapshotStore { function adaptNativeShard( shard: INativeShard, transaction: INativeTransaction, - provenance: Record, + adapterContext: ReturnType, nodeById: ReadonlyMap, metadata: ReturnType, root: string, @@ -316,17 +343,14 @@ function adaptNativeShard( includedIds.add(target); } } - const adapted = adaptTtscGraphDump( - { - project: transaction.project, - tsconfig: transaction.tsconfig, - provenance, - diagnostics: shard.diagnostics, - nodes, - edges: shard.edges, - }, - root, - ); + const adapted = adapterContext.adapt({ + project: transaction.project, + tsconfig: transaction.tsconfig, + provenance: {}, + diagnostics: shard.diagnostics, + nodes, + edges: shard.edges, + }); const localModuleFiles = new Set(); for (const node of shard.nodes) { const raw = objectOf(node, `${shard.key}.node`); @@ -470,101 +494,196 @@ function assembledSnapshot( }; } -function assertNativeGenerationFacts( +function prepareNativeGenerationFacts( transaction: INativeTransaction, - shards: ReadonlyMap, -): Map { - const nodeById = new Map(); - const nodeOwners = new Map(); - const sourceFiles = new Set(); - const configs = new Map(); - for (const [key, { shard }] of shards) { - if (shard.source !== undefined && shard.config !== undefined) { - throw new Error(`ttscgraph: native shard ${key} owns two input kinds`); + previous: INativeValidation, +): INativeValidation { + // Clone only the compact indexes. Raw node, edge, and diagnostic arrays are + // revisited solely for changed shards; the common protocol still walks its + // manifest once to bind the atomic commit, but validation is delta-sized. + const next: INativeValidation = { + summaries: new Map(previous.summaries), + nodeById: new Map(previous.nodeById), + sourceOwners: new Map(previous.sourceOwners), + configs: new Map(previous.configs), + edgeOwnersByTarget: new Map(previous.edgeOwnersByTarget), + }; + const affectedTargets = new Set(); + const mutableEdgeOwners = (target: string): Set => { + const current = next.edgeOwnersByTarget.get(target) ?? new Set(); + const copied = new Set(current); + next.edgeOwnersByTarget.set(target, copied); + return copied; + }; + const remove = (key: string): void => { + const summary = next.summaries.get(key); + if (summary === undefined) return; + next.summaries.delete(key); + if (summary.sourceFile !== undefined) { + next.sourceOwners.delete(summary.sourceFile); } - const sourceFile = - shard.source === undefined - ? undefined - : stringOf(shard.source.file, `${key}.source.file`); - const configFile = - shard.config === undefined - ? undefined - : stringOf(shard.config.file, `${key}.config.file`); - if (sourceFile !== undefined) { - if (sourceFiles.has(sourceFile)) { - throw new Error(`ttscgraph: native source ${sourceFile} has two shards`); - } - sourceFiles.add(sourceFile); + if (summary.configFile !== undefined) { + next.configs.delete(summary.configFile); } - if (configFile !== undefined) { - const digest = stringOf(shard.config!.digest, `${key}.config.digest`); - if (configs.has(configFile)) { - throw new Error(`ttscgraph: native config ${configFile} has two shards`); - } - configs.set(configFile, digest); - if (shard.nodes.length !== 0 || shard.edges.length !== 0) { - throw new Error(`ttscgraph: native config shard ${key} owns facts`); - } + for (const id of summary.nodeIds) { + next.nodeById.delete(id); + affectedTargets.add(id); } - if (sourceFile === undefined && shard.edges.length !== 0) { - throw new Error(`ttscgraph: native non-source shard ${key} owns edges`); + for (const target of summary.edgeTargets) { + const owners = mutableEdgeOwners(target); + owners.delete(key); + if (owners.size === 0) next.edgeOwnersByTarget.delete(target); } - for (let index = 0; index < shard.nodes.length; index++) { - const node = objectOf(shard.nodes[index], `${key}.nodes[${String(index)}]`); - const id = stringOf(node.id, `${key}.nodes[${String(index)}].id`); - const file = stringOf(node.file, `${key}.nodes[${String(index)}].file`); - const external = booleanOf( - node.external, - `${key}.nodes[${String(index)}].external`, - ); - if ( - (sourceFile !== undefined && (external || file !== sourceFile)) || - (sourceFile === undefined && !external) - ) { - throw new Error(`ttscgraph: native shard ${key} misowns node ${id}`); + }; + + for (const key of transaction.deletes) remove(key); + // An identity-stable external or metadata shard is replaced by an upsert, + // so remove all old ownership before installing any new ownership. This also + // permits an atomic node move between two simultaneously changed shards. + for (const upsert of transaction.upserts) remove(upsert.shard.key); + + for (const upsert of transaction.upserts) { + const summary = summarizeNativeShard(upsert.shard); + if (summary.sourceFile !== undefined) { + if (next.sourceOwners.has(summary.sourceFile)) { + throw new Error( + `ttscgraph: native source ${summary.sourceFile} has two shards`, + ); } - if (nodeById.has(id)) { - throw new Error(`ttscgraph: native node ${id} has two owners`); + next.sourceOwners.set(summary.sourceFile, summary.key); + } + if ( + summary.configFile !== undefined && + summary.configDigest !== undefined + ) { + if (next.configs.has(summary.configFile)) { + throw new Error( + `ttscgraph: native config ${summary.configFile} has two shards`, + ); } - nodeById.set(id, shard.nodes[index]); - nodeOwners.set(id, key); + next.configs.set(summary.configFile, summary.configDigest); } - for (let index = 0; index < shard.diagnostics.length; index++) { - const diagnostic = objectOf( - shard.diagnostics[index], - `${key}.diagnostics[${String(index)}]`, - ); - const file = stringOf( - diagnostic.file, - `${key}.diagnostics[${String(index)}].file`, - ); - if ( - (sourceFile !== undefined && file !== sourceFile) || - (configFile !== undefined && file !== configFile) || - (sourceFile === undefined && configFile === undefined && file !== "") - ) { - throw new Error(`ttscgraph: native shard ${key} misowns diagnostic`); + for (let index = 0; index < summary.nodeIds.length; index++) { + const id = summary.nodeIds[index]!; + if (next.nodeById.has(id)) { + throw new Error(`ttscgraph: native node ${id} has two owners`); } + next.nodeById.set(id, upsert.shard.nodes[index]); + affectedTargets.add(id); + } + for (const target of summary.edgeTargets) { + mutableEdgeOwners(target).add(summary.key); + affectedTargets.add(target); } + next.summaries.set(summary.key, summary); } - for (const [key, { shard }] of shards) { - for (let index = 0; index < shard.edges.length; index++) { - const edge = objectOf(shard.edges[index], `${key}.edges[${String(index)}]`); - const from = stringOf(edge.from, `${key}.edges[${String(index)}].from`); - const to = stringOf(edge.to, `${key}.edges[${String(index)}].to`); - if (nodeOwners.get(from) !== key) { - throw new Error(`ttscgraph: native shard ${key} misowns edge ${from}`); - } - if (!nodeById.has(to)) { - throw new Error(`ttscgraph: native edge target is absent: ${to}`); - } + + for (const target of affectedTargets) { + if ( + next.edgeOwnersByTarget.has(target) && + !next.nodeById.has(target) + ) { + throw new Error(`ttscgraph: native edge target is absent: ${target}`); + } + } + assertNativeUniverseConfigs(transaction, next.configs); + return next; +} + +function summarizeNativeShard(shard: INativeShard): INativeShardSummary { + const key = shard.key; + if (shard.source !== undefined && shard.config !== undefined) { + throw new Error(`ttscgraph: native shard ${key} owns two input kinds`); + } + const sourceFile = + shard.source === undefined + ? undefined + : stringOf(shard.source.file, `${key}.source.file`); + const configFile = + shard.config === undefined + ? undefined + : stringOf(shard.config.file, `${key}.config.file`); + const configDigest = + shard.config === undefined + ? undefined + : stringOf(shard.config.digest, `${key}.config.digest`); + if ( + configFile !== undefined && + (shard.nodes.length !== 0 || shard.edges.length !== 0) + ) { + throw new Error(`ttscgraph: native config shard ${key} owns facts`); + } + if (sourceFile === undefined && shard.edges.length !== 0) { + throw new Error(`ttscgraph: native non-source shard ${key} owns edges`); + } + const nodeIds: string[] = []; + const localIds = new Set(); + for (let index = 0; index < shard.nodes.length; index++) { + const node = objectOf(shard.nodes[index], `${key}.nodes[${String(index)}]`); + const id = stringOf(node.id, `${key}.nodes[${String(index)}].id`); + const file = stringOf(node.file, `${key}.nodes[${String(index)}].file`); + const external = booleanOf( + node.external, + `${key}.nodes[${String(index)}].external`, + ); + if ( + (sourceFile !== undefined && (external || file !== sourceFile)) || + (sourceFile === undefined && !external) + ) { + throw new Error(`ttscgraph: native shard ${key} misowns node ${id}`); + } + if (localIds.has(id)) { + throw new Error(`ttscgraph: native node ${id} has two owners`); } + localIds.add(id); + nodeIds.push(id); } + for (let index = 0; index < shard.diagnostics.length; index++) { + const diagnostic = objectOf( + shard.diagnostics[index], + `${key}.diagnostics[${String(index)}]`, + ); + const file = stringOf( + diagnostic.file, + `${key}.diagnostics[${String(index)}].file`, + ); + if ( + (sourceFile !== undefined && file !== sourceFile) || + (configFile !== undefined && file !== configFile) || + (sourceFile === undefined && configFile === undefined && file !== "") + ) { + throw new Error(`ttscgraph: native shard ${key} misowns diagnostic`); + } + } + const edgeTargets = new Set(); + for (let index = 0; index < shard.edges.length; index++) { + const edge = objectOf(shard.edges[index], `${key}.edges[${String(index)}]`); + const from = stringOf(edge.from, `${key}.edges[${String(index)}].from`); + const to = stringOf(edge.to, `${key}.edges[${String(index)}].to`); + if (!localIds.has(from)) { + throw new Error(`ttscgraph: native shard ${key} misowns edge ${from}`); + } + edgeTargets.add(to); + } + return { + key, + ...(sourceFile === undefined ? {} : { sourceFile }), + ...(configFile === undefined ? {} : { configFile, configDigest }), + nodeIds, + edgeTargets: [...edgeTargets], + }; +} + +function assertNativeUniverseConfigs( + transaction: INativeTransaction, + configs: ReadonlyMap, +): void { const universe = objectOf(transaction.universe, "native universe"); const universeConfigs = arrayOf(universe.configs, "native universe.configs"); if (universeConfigs.length !== configs.size) { throw new Error("ttscgraph: native config shards do not cover the universe"); } + const seen = new Set(); for (let index = 0; index < universeConfigs.length; index++) { const config = objectOf( universeConfigs[index], @@ -572,11 +691,21 @@ function assertNativeGenerationFacts( ); const file = stringOf(config.file, "native config.file"); const digest = stringOf(config.digest, "native config.digest"); - if (configs.get(file) !== digest || !configs.delete(file)) { + if (seen.has(file) || configs.get(file) !== digest) { throw new Error(`ttscgraph: native config shard disagrees at ${file}`); } + seen.add(file); } - return nodeById; +} + +function emptyNativeValidation(): INativeValidation { + return { + summaries: new Map(), + nodeById: new Map(), + sourceOwners: new Map(), + configs: new Map(), + edgeOwnersByTarget: new Map(), + }; } function assertNativeManifest( @@ -722,6 +851,11 @@ function assertShardKey(key: string): void { if (key === "" || key.includes("\0")) { throw new Error(`ttscgraph: native shard key is invalid: ${key}`); } + if (key.startsWith("0:coverage:")) { + throw new Error( + `ttscgraph: native shard uses reserved normalized namespace: ${key}`, + ); + } } function assertDigest(value: string, label: string): void { diff --git a/packages/graph/src/provider/ttscgraph/adaptTtscGraphDump.ts b/packages/graph/src/provider/ttscgraph/adaptTtscGraphDump.ts index b8e245a4..2e38e4aa 100644 --- a/packages/graph/src/provider/ttscgraph/adaptTtscGraphDump.ts +++ b/packages/graph/src/provider/ttscgraph/adaptTtscGraphDump.ts @@ -37,6 +37,17 @@ interface IAdaptedDump { warnings: string[]; } +/** Metadata and source evidence validated once for one native generation. */ +interface ITtscGraphDumpContext { + target: string; + capabilities: string[]; + manifest: ReadonlyMap; + sources: Map; + provenance: Omit; + warnings: string[]; + adapt: (input: unknown) => IAdaptedDump; +} + /** * Adapt a `ttscgraph serve` dump to one strict TypeScript language slice. * @@ -57,35 +68,19 @@ interface IAdaptedDump { export function adaptTtscGraphDump( input: unknown, expectedRoot: string, +): IAdaptedDump { + return prepareTtscGraphDumpContext(input, expectedRoot).adapt(input); +} + +function adaptTtscGraphDumpWithContext( + input: unknown, + context: ITtscGraphDumpContext, ): IAdaptedDump { const dump = objectOf(input, "dump"); - const rawProvenance = objectOf(dump.provenance, "dump.provenance"); - const schemaVersion = rawProvenance.schemaVersion; - if ( - !Number.isSafeInteger(schemaVersion) || - !ITtscGraphSnapshot.SUPPORTED_DUMP_SCHEMA_VERSIONS.includes( - schemaVersion as number, - ) - ) { - throw new Error( - `ttscgraph: dump is schema ${ - Number.isSafeInteger(schemaVersion) - ? `v${String(schemaVersion)}` - : "unknown" - }, this client reads ${ITtscGraphSnapshot.SUPPORTED_DUMP_SCHEMA_VERSIONS.map( - (version) => `v${String(version)}`, - ).join(" and ")}. Install a matching ttsc (the binary resolves from the target project, or from TTSC_GRAPH_BINARY).`, - ); - } - const warnings: string[] = []; - const project = stringOf(dump.project, "dump.project"); - if (!samePath(project, expectedRoot)) { - throw new Error( - `ttscgraph: response project ${project} does not match ${expectedRoot}`, - ); - } - const target = stringOf(dump.tsconfig, "dump.tsconfig"); - validateGraphFile(target, "dump.tsconfig"); + const warnings = [...context.warnings]; + const target = context.target; + const capabilities = context.capabilities; + const manifest = context.manifest; const rawNodes = arrayOf(dump.nodes, "dump.nodes"); const rawEdges = arrayOf(dump.edges, "dump.edges"); const moduleIds = new Map(); @@ -240,12 +235,6 @@ export function adaptTtscGraphDump( edges.push(edge); } - const capabilities = stringArrayOf( - objectOf(dump.provenance, "dump.provenance").capabilities, - "dump.provenance.capabilities", - ); - const manifest = manifestOf(dump.provenance); - mergeConfigurationSources(manifest, dump.provenance, capabilities); for (const file of [...factFiles].sort(compareOrdinal)) { if (!manifest.has(file)) { throw new Error( @@ -253,16 +242,6 @@ export function adaptTtscGraphDump( ); } } - const sources = new Map(); - // Preserve the complete compiler-owned manifest. Relative identities become - // absolute keys for the bulk-session contract; identities that are already - // absolute stay canonical, and bundled virtual identities must never pass - // through `path.resolve`, which would turn them into unrelated disk paths. - for (const [file, digest] of [...manifest].sort(([left], [right]) => - compareOrdinal(left, right), - )) { - sources.set(sourceManifestKey(expectedRoot, file), digest); - } const diagnostics = capabilities.includes( ITtscGraphSnapshot.CAPABILITY_DIAGNOSTICS, @@ -275,6 +254,65 @@ export function adaptTtscGraphDump( nodes, edges, diagnostics, + sources: context.sources, + provenance: context.provenance, + warnings, + }; +} + +/** Validate the generation-wide coordinates and source manifest once. */ +function prepareTtscGraphDumpContext( + input: unknown, + expectedRoot: string, +): ITtscGraphDumpContext { + const dump = objectOf(input, "dump"); + const rawProvenance = objectOf(dump.provenance, "dump.provenance"); + const schemaVersion = rawProvenance.schemaVersion; + if ( + !Number.isSafeInteger(schemaVersion) || + !ITtscGraphSnapshot.SUPPORTED_DUMP_SCHEMA_VERSIONS.includes( + schemaVersion as number, + ) + ) { + throw new Error( + `ttscgraph: dump is schema ${ + Number.isSafeInteger(schemaVersion) + ? `v${String(schemaVersion)}` + : "unknown" + }, this client reads ${ITtscGraphSnapshot.SUPPORTED_DUMP_SCHEMA_VERSIONS.map( + (version) => `v${String(version)}`, + ).join(" and ")}. Install a matching ttsc (the binary resolves from the target project, or from TTSC_GRAPH_BINARY).`, + ); + } + const project = stringOf(dump.project, "dump.project"); + if (!samePath(project, expectedRoot)) { + throw new Error( + `ttscgraph: response project ${project} does not match ${expectedRoot}`, + ); + } + const target = stringOf(dump.tsconfig, "dump.tsconfig"); + validateGraphFile(target, "dump.tsconfig"); + const capabilities = stringArrayOf( + rawProvenance.capabilities, + "dump.provenance.capabilities", + ); + const manifest = manifestOf(dump.provenance); + mergeConfigurationSources(manifest, dump.provenance, capabilities); + const sources = new Map(); + // Preserve the complete compiler-owned manifest. Relative identities become + // absolute keys for the bulk-session contract; identities that are already + // absolute stay canonical, and bundled virtual identities must never pass + // through `path.resolve`, which would turn them into unrelated disk paths. + for (const [file, digest] of [...manifest].sort(([left], [right]) => + compareOrdinal(left, right), + )) { + sources.set(sourceManifestKey(expectedRoot, file), digest); + } + + const context: ITtscGraphDumpContext = { + target, + capabilities, + manifest, sources, provenance: provenanceOf( dump.provenance, @@ -282,8 +320,10 @@ export function adaptTtscGraphDump( capabilities, target, ), - warnings, + warnings: [], + adapt: (facts) => adaptTtscGraphDumpWithContext(facts, context), }; + return context; } /** @@ -560,6 +600,9 @@ const NODE_KINDS = new Set([ * cannot run: the function declaration above it is always evaluated first. * The constants inside run unconditionally, so nothing testable is hidden. */ export namespace adaptTtscGraphDump { + /** Reuse generation-wide validation while adapting native shard deltas. */ + export const prepareContext = prepareTtscGraphDumpContext; + /** The registry identity every `ttscgraph` snapshot is published under. */ export const PROVIDER = "ttscgraph"; diff --git a/tests/experiment/src/catalog.mjs b/tests/experiment/src/catalog.mjs index 31f77a8a..4425c10e 100644 --- a/tests/experiment/src/catalog.mjs +++ b/tests/experiment/src/catalog.mjs @@ -13,6 +13,12 @@ export const LANGUAGE_EXPERIMENTS = [ strictProvider: "ttscgraph", strictAuthority: "compiler", strictTool: "ttscgraph", + strictReleaseBoundary: { + version: "0.23.0", + warning: "legacy full dump", + reason: + "No published ttsc release implements graph snapshot protocol v1; 0.23.0 is provisioned to prove the explicit ttscserver fallback until the native producer ships.", + }, // The pinned starter has no construction expression. The lifecycle below // creates one and checks the real ttscgraph generation that contains it. semanticEdges: ["calls", "type_ref"], @@ -23,6 +29,8 @@ export const LANGUAGE_EXPERIMENTS = [ "diskDigests", "diagnostics", ], + minNodes: 1, + minEdges: 1, prepare: "npm ci --ignore-scripts", lifecycle: { sourceFile: "src/app.service.ts", diff --git a/tests/experiment/src/run-language.mjs b/tests/experiment/src/run-language.mjs index a02b8f30..c814e260 100644 --- a/tests/experiment/src/run-language.mjs +++ b/tests/experiment/src/run-language.mjs @@ -24,7 +24,9 @@ const pinned = cloneRepository(experiment, { refresh: args.refresh === "true" }) // Some language servers need the checkout prepared before they can boot — // ruby-lsp, for one, composes a bundle from the project's Gemfile. That runs in // a copy for both lanes, so the clone keeps proving which revision was measured. -const strict = experiment.strictProvider !== undefined; +const strictDeclared = experiment.strictProvider !== undefined; +const releaseBoundary = experiment.strictReleaseBoundary; +const strict = strictDeclared && releaseBoundary === undefined; // Read before anything is indexed: a row that cannot be satisfied should // fail in seconds rather than after a full real-server build. @@ -33,7 +35,7 @@ const strict = experiment.strictProvider !== undefined; // resolved this" and "an index built from a navigation skeleton reports this" // are different grades of evidence, and a row that does not say which one it // expects cannot detect a provider that silently changed grade. -if (strict) { +if (strictDeclared) { for (const field of [ "strictAuthority", "strictTool", @@ -84,6 +86,18 @@ if (strict) { `${experiment.language}: a strict row that accepts an unreproducible regeneration must state why`, ); } + if (releaseBoundary !== undefined) { + for (const field of ["version", "warning", "reason"]) { + if ( + typeof releaseBoundary[field] !== "string" || + releaseBoundary[field].trim() === "" + ) { + throw new Error( + `${experiment.language}: a strict release boundary must state ${field}`, + ); + } + } + } } else if ( // A language without a strict row has to say why it has none. Otherwise the // catalog cannot distinguish a producer that was investigated and found @@ -127,6 +141,25 @@ if (strict) { elapsedMs = Math.round(performance.now() - started); } const warnings = dump.warnings ?? []; +const declaredProvenance = strictDeclared + ? dump.provenance?.find( + (row) => row.provider === experiment.strictProvider, + ) + : undefined; + +if ( + releaseBoundary !== undefined && + (declaredProvenance !== undefined || + !warnings.some( + (warning) => + warning.includes(experiment.strictProvider) && + warning.includes(releaseBoundary.warning), + )) +) { + throw new Error( + `${experiment.language}: published ${releaseBoundary.version} did not prove the declared strict release boundary: ${warnings.join("; ")}`, + ); +} if (dump.indexer === "static") { throw new Error(`${experiment.language}: expected real LSP indexing, got static fallback: ${warnings.join("; ")}`); @@ -141,9 +174,7 @@ const minEdges = experiment.minEdges ?? 0; if (!strict && dump.edges.length < minEdges) { throw new Error(`${experiment.language}: expected at least ${minEdges} relationship edges, got ${dump.edges.length}`); } -const provenance = strict - ? dump.provenance?.find((row) => row.provider === experiment.strictProvider) - : undefined; +const provenance = strict ? declaredProvenance : undefined; if (strict && provenance === undefined) { throw new Error( `${experiment.language}: strict provider ${experiment.strictProvider} did not publish provenance: ${warnings.join("; ")}`, @@ -204,7 +235,7 @@ const edgeKindCounts = Object.fromEntries( // required this exact edge in both generations; count that evidence instead of // pre-editing the pinned baseline merely to make the final cold dump contain it. const lifecycleCreatedEdge = experiment.lifecycle?.createdEdge; -for (const kind of experiment.semanticEdges ?? []) { +for (const kind of strict ? experiment.semanticEdges ?? [] : []) { if ( (edgeKindCounts[kind] ?? 0) === 0 && lifecycleCreatedEdge?.kind !== kind @@ -294,6 +325,7 @@ const result = { edgeCount: dump.edges.length, diagnosticCount: dump.diagnostics?.length ?? 0, strictProvider: experiment.strictProvider, + strictReleaseBoundary: releaseBoundary, provenance, edgeKindCounts, semanticLimitation: experiment.semanticLimitation, diff --git a/tests/experiment/src/setup-language.mjs b/tests/experiment/src/setup-language.mjs index f3927f8a..78655f0a 100644 --- a/tests/experiment/src/setup-language.mjs +++ b/tests/experiment/src/setup-language.mjs @@ -467,7 +467,12 @@ switch (experiment.language) { // so the PATH fallback is the only route — and the binary lives inside the // platform package rather than in `@ttsc/graph`, whose npm `bin` publishes // `ttsc-graph` and not this. - const ttscVersion = "0.22.0"; + const ttscVersion = experiment.strictReleaseBoundary?.version; + if (ttscVersion === undefined) { + throw new Error( + "typescript: the published ttsc setup must name its strict release boundary", + ); + } shell(`npm install -g @ttsc/linux-x64@${ttscVersion}`); const globalRoot = shell("npm root -g", { stdio: ["ignore", "pipe", "inherit"], diff --git a/tests/test-graph/src/features/test_ttscgraph_bulk_provider_reuses_and_atomically_replaces_snapshots.ts b/tests/test-graph/src/features/test_ttscgraph_bulk_provider_reuses_and_atomically_replaces_snapshots.ts index 6043337f..708958c7 100644 --- a/tests/test-graph/src/features/test_ttscgraph_bulk_provider_reuses_and_atomically_replaces_snapshots.ts +++ b/tests/test-graph/src/features/test_ttscgraph_bulk_provider_reuses_and_atomically_replaces_snapshots.ts @@ -228,7 +228,10 @@ export const test_ttscgraph_bulk_provider_reuses_and_atomically_replaces_snapsho initialShards.has(key) && initialShards.get(key) !== digest, ).length, ], - [1, initial.snapshot.protocol?.generation, 6, 2], + // The edited source itself has a new content-addressed producer key, so + // it appears as delete+upsert. The one same-key digest replacement is + // the unchanged importer whose outgoing export edge was invalidated. + [1, initial.snapshot.protocol?.generation, 6, 1], ); await rejects(client.refresh(), "serve errors are surfaced"); TestValidator.predicate( diff --git a/tests/test-graph/src/features/test_ttscgraph_native_shard_transactions_fail_atomically.ts b/tests/test-graph/src/features/test_ttscgraph_native_shard_transactions_fail_atomically.ts index 4edaddbe..606c6968 100644 --- a/tests/test-graph/src/features/test_ttscgraph_native_shard_transactions_fail_atomically.ts +++ b/tests/test-graph/src/features/test_ttscgraph_native_shard_transactions_fail_atomically.ts @@ -36,7 +36,9 @@ export const test_ttscgraph_native_shard_transactions_fail_atomically = "--native-invalid-key-empty": "native shard key is invalid", "--native-invalid-key-nul": "native shard key is invalid", "--native-invalid-reserved-coverage": - "uses reserved normalized key", + "uses reserved normalized namespace", + "--native-invalid-reserved-coverage-alternate": + "uses reserved normalized namespace", "--native-invalid-two-input-kinds": "owns two input kinds", "--native-invalid-duplicate-source": "has two shards", "--native-invalid-duplicate-config": "has two shards", @@ -46,6 +48,7 @@ export const test_ttscgraph_native_shard_transactions_fail_atomically = "--native-invalid-source-foreign-node": "misowns node", "--native-invalid-external-local-node": "misowns node", "--native-invalid-duplicate-node": "has two owners", + "--native-invalid-local-duplicate-node": "has two owners", "--native-invalid-source-diagnostic": "misowns diagnostic", "--native-invalid-config-diagnostic": "misowns diagnostic", "--native-invalid-metadata-diagnostic": "misowns diagnostic", @@ -89,6 +92,8 @@ export const test_ttscgraph_native_shard_transactions_fail_atomically = "--native-invalid-delete-unknown-third": "deletes unknown shard", "--native-invalid-delete-duplicate-third": "touches shard", "--native-invalid-upsert-duplicate-third": "touches shard", + "--native-invalid-retained-edge-target-third": + "native edge target is absent", }; for (const [mode, expected] of Object.entries(incrementalFailures)) { const client = create(fixture(), mode); diff --git a/tests/test-graph/src/features/test_ttscgraph_protocol_adapter_deletes_dependency_shards.ts b/tests/test-graph/src/features/test_ttscgraph_protocol_adapter_deletes_dependency_shards.ts index 227cd978..f349e4c4 100644 --- a/tests/test-graph/src/features/test_ttscgraph_protocol_adapter_deletes_dependency_shards.ts +++ b/tests/test-graph/src/features/test_ttscgraph_protocol_adapter_deletes_dependency_shards.ts @@ -1,10 +1,12 @@ import { TestValidator } from "@nestia/e2e"; import { createHash } from "node:crypto"; +import fs from "node:fs"; import path from "node:path"; import { GraphSnapshotProtocol } from "../../../../packages/graph/src/provider/GraphSnapshotProtocol"; import { adaptTtscGraphDump } from "../../../../packages/graph/src/provider/ttscgraph/adaptTtscGraphDump"; import { createTtscGraphProtocolTransaction } from "../../../../packages/graph/src/provider/ttscgraph/createTtscGraphProtocolTransaction"; +import { TtscGraphClient } from "../../../../packages/graph/src/provider/ttscgraph/TtscGraphClient"; import { GraphPaths } from "../internal/GraphPaths"; const sha256 = (text: string): string => @@ -18,6 +20,7 @@ const sha256 = (text: string): string => */ export const test_ttscgraph_protocol_adapter_deletes_dependency_shards = async () => { + await assertNativeProducerDeltas(); const root = GraphPaths.createTempDirectory( "samchon-graph-ttscgraph-protocol-", ); @@ -64,6 +67,112 @@ export const test_ttscgraph_protocol_adapter_deletes_dependency_shards = ); }; +async function assertNativeProducerDeltas(): Promise { + const root = GraphPaths.createTempDirectory( + "samchon-graph-ttscgraph-native-delta-", + ); + fs.mkdirSync(path.join(root, "src", "core"), { recursive: true }); + fs.writeFileSync(path.join(root, "tsconfig.json"), "{}\n"); + fs.writeFileSync(path.join(root, "src", "index.ts"), "export {};\n"); + fs.writeFileSync( + path.join(root, "src", "core", "order.ts"), + "export function first() {}\n", + ); + fs.writeFileSync(path.join(root, "src", "empty.ts"), "export {};\n"); + + const bodyLog = path.join(root, "body-native.ndjson"); + const body = new TtscGraphClient({ + root, + command: process.execPath, + args: [ + GraphPaths.fakeTtscGraphServer, + `--native-log=${bodyLog}`, + ], + }); + try { + const initial = await body.refresh(); + await body.refresh(); + const changed = await body.refresh(); + const [coldTransaction, bodyTransaction] = nativeTransactions(bodyLog); + const oldSourceKey = coldTransaction!.manifest.find((entry) => + entry.key.includes('"src/core/order.ts"'), + )!.key; + const newSource = bodyTransaction!.upserts.find((entry) => + entry.shard.source?.file === "src/core/order.ts", + ); + TestValidator.predicate( + "a real-client body delta deletes the old content-addressed source key", + bodyTransaction!.deletes.includes(oldSourceKey) && + newSource !== undefined && + newSource.shard.key !== oldSourceKey && + newSource.shard.key.startsWith("1:source:"), + ); + TestValidator.predicate( + "the committed client generation contains only the replacement fact", + initial.snapshot.nodes.some((node) => node.name === "first") && + changed.snapshot.nodes.some((node) => node.name === "second") && + !changed.snapshot.nodes.some((node) => node.name === "first"), + ); + } finally { + await body.close(); + } + + const reloadLog = path.join(root, "reload-native.ndjson"); + const reload = new TtscGraphClient({ + root, + command: process.execPath, + args: [ + GraphPaths.fakeTtscGraphServer, + "--universe-reload", + `--native-log=${reloadLog}`, + ], + }); + try { + await reload.refresh(); + await reload.refresh(); + await reload.refresh(); + const [coldTransaction, reloadTransaction] = + nativeTransactions(reloadLog); + const oldKeys = new Set( + coldTransaction!.manifest.map((entry) => entry.key), + ); + const newKeys = new Set( + reloadTransaction!.manifest.map((entry) => entry.key), + ); + TestValidator.predicate( + "a universe reload replaces every producer identity and leaves no stale key", + reloadTransaction!.deletes.length === oldKeys.size && + reloadTransaction!.upserts.length === newKeys.size && + [...oldKeys].every( + (key) => + reloadTransaction!.deletes.includes(key) && !newKeys.has(key), + ), + ); + } finally { + await reload.close(); + } +} + +interface INativeLogTransaction { + manifest: { key: string; digest: string }[]; + upserts: { + digest: string; + shard: { + key: string; + source?: { file: string }; + }; + }[]; + deletes: string[]; +} + +function nativeTransactions(file: string): INativeLogTransaction[] { + return fs + .readFileSync(file, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as INativeLogTransaction); +} + function dump( root: string, dependency: boolean, diff --git a/tests/test-graph/src/internal/fake-ttscgraph-server.cjs b/tests/test-graph/src/internal/fake-ttscgraph-server.cjs index 892a06ac..590533a7 100644 --- a/tests/test-graph/src/internal/fake-ttscgraph-server.cjs +++ b/tests/test-graph/src/internal/fake-ttscgraph-server.cjs @@ -26,6 +26,8 @@ const stdinClosedMarker = stdinClosedMarkerArg?.slice( ); const requestLogArg = args.find((arg) => arg.startsWith("--request-log=")); const requestLog = requestLogArg?.slice("--request-log=".length); +const nativeLogArg = args.find((arg) => arg.startsWith("--native-log=")); +const nativeLog = nativeLogArg?.slice("--native-log=".length); // Stands in for a producer that speaks a protocol this client refuses, so the // pin can be proved without shipping a second fake. const protocolArg = args.find((arg) => arg.startsWith("--protocol=")); @@ -37,6 +39,7 @@ const dropped = dropArg?.slice("--drop-capability=".length); // Moves the build universe under an `incremental` label — a producer claiming it // reused a program whose own inputs say it could not have. const universeDrift = args.includes("--universe-drift"); +const universeReload = args.includes("--universe-reload"); // Transport- and process-level fault injection. These stand in for the wire // conditions a well-formed producer never emits but a real one can: a process // that dies mid-serve, a stream chunked or blank-padded by the OS, a line that @@ -83,6 +86,7 @@ const conformanceHeuristic = args.includes("--conformance-heuristic"); const phaseTrace = args.includes("--phase-trace"); let requests = 0; let nativeState; +let nativeBase; const CAPABILITIES = [ "universe", @@ -129,19 +133,25 @@ const readProjectFile = (rel) => { * this fake needs the client to go looking on disk, and a file the client cannot * read still has a perfectly well-defined digest here. */ -const manifest = (drift) => +const manifest = (semantic) => [...WORKSPACE_FILES, ...BUNDLED_FILES].map((file) => { if (BUNDLED_FILES.includes(file)) { return { file, - checkerDigest: digestOf(`${file}:checker${drift ?? ""}`), + checkerDigest: digestOf(`${file}:checker`), diskDigest: "", }; } const text = readProjectFile(file); return { file, - checkerDigest: digestOf(text ?? `absent:${file}${drift ?? ""}`), + checkerDigest: digestOf( + `${text ?? `absent:${file}`}${ + file === "src/core/order.ts" && semantic !== "first" + ? `:program:${semantic}` + : "" + }`, + ), diskDigest: dropped === "diskDigests" || text === undefined ? "" : digestOf(text), }; @@ -157,7 +167,7 @@ const universe = (drift) => ({ roots: WORKSPACE_FILES.map((file) => ({ config: "tsconfig.json", file })), }); -const provenance = (drift) => ({ +const provenance = (semantic, drift) => ({ schemaVersion: 6, capabilities: CAPABILITIES, producer: { @@ -166,13 +176,13 @@ const provenance = (drift) => ({ typescript: "5.9.0", }, universe: universe(drift), - sources: manifest(drift), + sources: manifest(semantic), }); const graph = (name, options = {}) => ({ project, tsconfig: "tsconfig.json", - provenance: provenance(options.drift), + provenance: provenance(name, options.drift), diagnostics: dropped === "diagnostics" ? [] @@ -246,17 +256,34 @@ const graph = (name, options = {}) => ({ /** Convert the fake compiler document into the same native shards as ttscgraph. */ function nativeSnapshot(dump) { + nativeBase = nativeState; const shards = new Map(); const nodeFiles = new Map(dump.nodes.map((node) => [node.id, node.file])); const sourceOccurrences = new Map(); + const universeFingerprint = nativeUniverseFingerprint({ + universe: dump.provenance.universe, + }); + const coordinates = (...values) => + JSON.stringify([ + 1, + dump.provenance.producer.tool, + dump.provenance.producer.version, + dump.provenance.producer.typescript, + dump.tsconfig, + universeFingerprint, + ...values, + ]); for (const source of dump.provenance.sources) { const occurrence = sourceOccurrences.get(source.file) ?? 0; sourceOccurrences.set(source.file, occurrence + 1); - const key = `1:source:${JSON.stringify([ + const prefix = source.file.startsWith("bundled:///") ? "2" : "1"; + const key = `${prefix}:source:${coordinates( source.file, source.checkerDigest, + source.diskDigest, + digestOf(`resolution:${source.file}`), ...(occurrence === 0 ? [] : [occurrence]), - ])}`; + )}`; shards.set(key, { key, source, @@ -272,7 +299,7 @@ function nativeSnapshot(dump) { }); } for (const config of dump.provenance.universe.configs) { - const key = `3:config:${JSON.stringify([config.file, config.digest])}`; + const key = `3:config:${coordinates(config.file, config.digest)}`; shards.set(key, { key, config, @@ -283,14 +310,14 @@ function nativeSnapshot(dump) { ), }); } - const externalKey = "0:external"; + const externalKey = `0:external:${coordinates("external")}`; shards.set(externalKey, { key: externalKey, nodes: dump.nodes.filter((node) => node.external), edges: [], diagnostics: [], }); - const metadataKey = "0:metadata"; + const metadataKey = `0:metadata:${coordinates("metadata")}`; const inputFiles = new Set([ ...dump.provenance.sources.map((source) => source.file), ...dump.provenance.universe.configs.map((config) => config.file), @@ -404,9 +431,9 @@ function corruptNativeSnapshot(snapshot, mode) { const config = () => snapshot.upserts.find((upsert) => upsert.shard.config !== undefined); const external = () => - snapshot.upserts.find((upsert) => upsert.shard.key === "0:external"); + snapshot.upserts.find((upsert) => upsert.shard.key.startsWith("0:external:")); const metadata = () => - snapshot.upserts.find((upsert) => upsert.shard.key === "0:metadata"); + snapshot.upserts.find((upsert) => upsert.shard.key.startsWith("0:metadata:")); if (mode === "--native-invalid-digest" || mode === "--native-invalid-digest-third") { snapshot.upserts[0].digest = "0".repeat(64); @@ -446,22 +473,46 @@ function corruptNativeSnapshot(snapshot, mode) { snapshot.deletes.push(snapshot.manifest[0].key, snapshot.manifest[0].key); } else if (mode === "--native-invalid-upsert-duplicate-third") { snapshot.upserts.push(structuredClone(snapshot.upserts[0])); + } else if (mode === "--native-invalid-retained-edge-target-third") { + snapshot.upserts = snapshot.upserts.filter( + (upsert) => + upsert.shard.source?.file !== "src/core/order.ts" && + upsert.shard.source?.file !== "src/index.ts", + ); + snapshot.manifest = [...nativeBase.shards] + .filter( + ([, value]) => value.shard.source?.file !== "src/core/order.ts", + ) + .sort(([left], [right]) => compareUtf8(left, right)) + .map(([key, value]) => ({ key, digest: value.digest })); + snapshot.deletes = [...nativeBase.shards] + .filter( + ([, value]) => value.shard.source?.file === "src/core/order.ts", + ) + .map(([key]) => key); + resignNativeGeneration(snapshot); } else if (mode === "--native-invalid-key-empty") { snapshot.upserts[0].shard.key = ""; resignCompleteNativeSnapshot(snapshot); } else if (mode === "--native-invalid-key-nul") { snapshot.upserts[0].shard.key = "bad\0key"; resignCompleteNativeSnapshot(snapshot); - } else if (mode === "--native-invalid-reserved-coverage") { - snapshot.upserts[0].shard.key = `0:coverage:${JSON.stringify([ - 1, - "ttscgraph", - snapshot.producer.version, - snapshot.producer.typescript, - "typescript", - snapshot.tsconfig, - nativeUniverseFingerprint(snapshot), - ])}`; + } else if ( + mode === "--native-invalid-reserved-coverage" || + mode === "--native-invalid-reserved-coverage-alternate" + ) { + snapshot.upserts[0].shard.key = + mode === "--native-invalid-reserved-coverage" + ? `0:coverage:${JSON.stringify([ + 1, + "ttscgraph", + snapshot.producer.version, + snapshot.producer.typescript, + "typescript", + snapshot.tsconfig, + nativeUniverseFingerprint(snapshot), + ])}` + : "0:coverage:foreign-native-shard"; resignCompleteNativeSnapshot(snapshot); } else if (mode === "--native-invalid-two-input-kinds") { source("src/empty.ts").shard.config = structuredClone( @@ -469,7 +520,11 @@ function corruptNativeSnapshot(snapshot, mode) { ); resignCompleteNativeSnapshot(snapshot); } else if (mode === "--native-invalid-duplicate-source") { - source("src/empty.ts").shard.source.file = "src/index.ts"; + const duplicate = source("src/empty.ts"); + duplicate.shard.source.file = "src/index.ts"; + duplicate.shard.nodes.forEach((node) => { + node.file = "src/index.ts"; + }); resignCompleteNativeSnapshot(snapshot); } else if (mode === "--native-invalid-duplicate-config") { const duplicate = structuredClone(config()); @@ -494,6 +549,9 @@ function corruptNativeSnapshot(snapshot, mode) { } else if (mode === "--native-invalid-duplicate-node") { metadata().shard.nodes.push(structuredClone(external().shard.nodes[0])); resignCompleteNativeSnapshot(snapshot); + } else if (mode === "--native-invalid-local-duplicate-node") { + external().shard.nodes.push(structuredClone(external().shard.nodes[0])); + resignCompleteNativeSnapshot(snapshot); } else if (mode === "--native-invalid-source-diagnostic") { source("src/core/order.ts").shard.diagnostics[0].file = "src/index.ts"; resignCompleteNativeSnapshot(snapshot); @@ -783,9 +841,13 @@ input.on("line", (line) => { changed: true, mode: "incremental", snapshot: nativeSnapshot( - graph("second", universeDrift ? { drift: "moved" } : {}), + graph( + "second", + universeDrift || universeReload ? { drift: "moved" } : {}, + ), ), }); + if (universeReload) response.mode = "reload"; } else { response = frame(request.id, { changed: false, @@ -804,6 +866,9 @@ input.on("line", (line) => { response.snapshot = null; } else corruptNativeSnapshot(response.snapshot, nativeInvalidMode); } + if (nativeLog !== undefined && response.snapshot !== undefined) { + fs.appendFileSync(nativeLog, `${JSON.stringify(response.snapshot)}\n`); + } if (phaseTrace) { process.stderr.write( "@samchon/graph: ttscgraph-phase C:\\private\\spoof.ts\n", From 78b86f2f3feba74769c7e898b7b23e5a2f0f17de Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Sat, 1 Aug 2026 09:56:05 +0900 Subject: [PATCH 22/52] fix: prove the TypeScript release boundary --- .../ttscgraph/TtscGraphSnapshotStore.ts | 7 +- tests/experiment/src/run-language.mjs | 8 +- ...st_experiment_corpora_are_commit_pinned.ts | 29 +++ ...elta_revalidates_only_changed_raw_facts.ts | 219 ++++++++++++++++++ ...tocol_adapter_deletes_dependency_shards.ts | 30 +++ .../src/internal/fake-ttscgraph-server.cjs | 13 +- 6 files changed, 296 insertions(+), 10 deletions(-) create mode 100644 tests/test-graph/src/features/test_ttscgraph_native_delta_revalidates_only_changed_raw_facts.ts diff --git a/packages/graph/src/provider/ttscgraph/TtscGraphSnapshotStore.ts b/packages/graph/src/provider/ttscgraph/TtscGraphSnapshotStore.ts index c7cd066f..e610bcc3 100644 --- a/packages/graph/src/provider/ttscgraph/TtscGraphSnapshotStore.ts +++ b/packages/graph/src/provider/ttscgraph/TtscGraphSnapshotStore.ts @@ -498,9 +498,10 @@ function prepareNativeGenerationFacts( transaction: INativeTransaction, previous: INativeValidation, ): INativeValidation { - // Clone only the compact indexes. Raw node, edge, and diagnostic arrays are - // revisited solely for changed shards; the common protocol still walks its - // manifest once to bind the atomic commit, but validation is delta-sized. + // Raw node, edge, and diagnostic arrays are revisited solely for changed + // shards. The compact indexes and common protocol reconstruction still make + // one O(N) pass over the generation to bind an atomic commit; only raw-fact + // parsing, not total normalization work, is delta-sized. const next: INativeValidation = { summaries: new Map(previous.summaries), nodeById: new Map(previous.nodeById), diff --git a/tests/experiment/src/run-language.mjs b/tests/experiment/src/run-language.mjs index c814e260..487ef721 100644 --- a/tests/experiment/src/run-language.mjs +++ b/tests/experiment/src/run-language.mjs @@ -133,7 +133,13 @@ if (strict) { mode: "lsp", languages: [experiment.language], maxFiles: experiment.maxFiles, - lspReferenceLimit: experiment.referenceLimit ?? 250, + // A published-release boundary must launch the registered provider once + // so the experiment proves its exact incompatibility before observing the + // ordinary fallback. The default cap deliberately disables whole-project + // providers and would turn that proof into a selection refusal. + ...(releaseBoundary === undefined + ? { lspReferenceLimit: experiment.referenceLimit ?? 250 } + : {}), lspTimeoutMs: experiment.timeoutMs ?? 60_000, lspReadyTimeoutMs: experiment.readyTimeoutMs ?? 180_000, lspWarmupTimeoutMs: experiment.warmupTimeoutMs ?? 180_000, diff --git a/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts b/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts index 4639b02f..bf376687 100644 --- a/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts +++ b/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts @@ -25,6 +25,11 @@ export const test_experiment_corpora_are_commit_pinned = () => { [...catalog.matchAll(/strictProvider:\s*"[^"]+"/g)].length, [...catalog.matchAll(/lifecycle:\s*\{/g)].length, ); + const typescript = region( + catalog, + 'language: "typescript"', + 'language: "rust"', + ); const python = region(catalog, 'language: "python"', 'language: "ruby"'); const java = region(catalog, 'language: "java"', 'language: "csharp"'); const csharp = region(catalog, 'language: "csharp"', 'language: "kotlin"'); @@ -345,6 +350,30 @@ export const test_experiment_corpora_are_commit_pinned = () => { !runner.includes('experiment.strictAuthority ?? "compiler"') && !runner.includes("experiment.strictTool ?? experiment.strictProvider"), ); + TestValidator.predicate( + "the TypeScript published-release boundary launches before fallback selection", + typescript.includes("strictReleaseBoundary: {") && + typescript.includes('version: "0.23.0"') && + typescript.includes('warning: "legacy full dump"') && + typescript.includes("reason:") && + runner.includes( + "const strictDeclared = experiment.strictProvider !== undefined", + ) && + runner.includes( + "const releaseBoundary = experiment.strictReleaseBoundary", + ) && + runner.includes( + "const strict = strictDeclared && releaseBoundary === undefined", + ) && + runner.includes("...(releaseBoundary === undefined") && + runner.includes( + "? { lspReferenceLimit: experiment.referenceLimit ?? 250 }", + ) && + runner.includes("releaseBoundary !== undefined &&") && + runner.includes("declaredProvenance !== undefined") && + runner.includes("warning.includes(releaseBoundary.warning)") && + setup.includes("experiment.strictReleaseBoundary?.version"), + ); TestValidator.predicate( "the runner proves declared families are present and undeclared ones absent", runner.includes("provenance.facts.includes(kind)") && diff --git a/tests/test-graph/src/features/test_ttscgraph_native_delta_revalidates_only_changed_raw_facts.ts b/tests/test-graph/src/features/test_ttscgraph_native_delta_revalidates_only_changed_raw_facts.ts new file mode 100644 index 00000000..3407f859 --- /dev/null +++ b/tests/test-graph/src/features/test_ttscgraph_native_delta_revalidates_only_changed_raw_facts.ts @@ -0,0 +1,219 @@ +import { TestValidator } from "@nestia/e2e"; +import { createHash } from "node:crypto"; + +import { IBulkGraphSession } from "../../../../packages/graph/src/provider/IBulkGraphSession"; +import { TtscGraphSnapshotStore } from "../../../../packages/graph/src/provider/ttscgraph/TtscGraphSnapshotStore"; +import { GraphPaths } from "../internal/GraphPaths"; + +/** + * A compact atomic manifest is generation-wide, but semantic fact parsing is + * not: one source replacement must never touch retained raw node arrays. + */ +export const test_ttscgraph_native_delta_revalidates_only_changed_raw_facts = + () => { + const root = GraphPaths.createTempDirectory( + "samchon-graph-native-delta-cost-", + ); + const fixture = initialTransaction(root, 32); + const store = new TtscGraphSnapshotStore(root); + const initial = store.prepare(fixture.transaction, { sequence: 1 }); + initial.commit(); + for (const counter of fixture.counters) counter.reads = 0; + + const changedCounter = { reads: 0 }; + const changedShard = sourceShard( + fixture.files[0]!, + "1:source:replacement", + digest("replacement checker text"), + changedCounter, + ); + const changedDigest = digestJson(changedShard); + const manifest = [ + ...fixture.transaction.manifest.filter( + (entry) => entry.key !== fixture.sourceKeys[0], + ), + { key: changedShard.key, digest: changedDigest }, + ].sort((left, right) => compareUtf8(left.key, right.key)); + const transaction = { + ...fixture.transaction, + sequence: 2, + baseSequence: 1, + baseGeneration: fixture.transaction.generation, + generation: digestJson({ + tsconfig: fixture.transaction.tsconfig, + producer: fixture.transaction.producer, + capabilities: fixture.transaction.capabilities, + universe: fixture.transaction.universe, + manifest, + }), + upserts: [{ digest: changedDigest, shard: changedShard }], + deletes: [fixture.sourceKeys[0]], + manifest, + }; + // Signing the changed shard exercises its getter before the measured + // prepare. Only accesses caused by delta validation count below. + changedCounter.reads = 0; + store.prepare(transaction, { + sequence: 2, + previous: { + protocol: { + sequence: 1, + generation: fixture.transaction.generation, + }, + } as unknown as IBulkGraphSession.ISnapshot, + }); + + TestValidator.equals( + "a one-source delta never reparses retained raw facts", + fixture.counters.slice(1).reduce((sum, row) => sum + row.reads, 0), + 0, + ); + TestValidator.predicate( + "the replacement source is still parsed and validated", + changedCounter.reads > 0, + ); + }; + +function initialTransaction(root: string, size: number): { + transaction: ReturnType; + counters: { reads: number }[]; + files: string[]; + sourceKeys: string[]; +} { + const producer = { + tool: "ttscgraph", + version: "test", + typescript: "5.9.0", + }; + const capabilities = [ + "universe", + "sourceDigests", + "diskDigests", + "diagnostics", + ]; + const files = Array.from( + { length: size }, + (_, index) => `src/f${String(index).padStart(3, "0")}.ts`, + ); + const config = { file: "tsconfig.json", digest: digest("configuration") }; + const universe = { + configs: [config], + roots: files.map((file) => ({ config: config.file, file })), + }; + const counters = files.map(() => ({ reads: 0 })); + const sourceKeys = files.map( + (_, index) => `1:source:${String(index).padStart(3, "0")}`, + ); + const shards = files.map((file, index) => + sourceShard( + file, + sourceKeys[index]!, + digest(`checker:${file}`), + counters[index]!, + ), + ); + shards.push({ + key: "3:config", + config, + nodes: [], + edges: [], + diagnostics: [], + } as ReturnType); + const upserts = shards.map((shard) => ({ + digest: digestJson(shard), + shard, + })); + const manifest = upserts + .map((entry) => ({ key: entry.shard.key, digest: entry.digest })) + .sort((left, right) => compareUtf8(left.key, right.key)); + const transaction = transactionOf({ + root, + producer, + capabilities, + universe, + upserts, + manifest, + }); + return { transaction, counters, files, sourceKeys }; +} + +function transactionOf(input: { + root: string; + producer: Record; + capabilities: string[]; + universe: Record; + upserts: { digest: string; shard: ReturnType }[]; + manifest: { key: string; digest: string }[]; +}) { + const transaction = { + protocolVersion: 1, + schemaVersion: 6, + project: input.root, + tsconfig: "tsconfig.json", + producer: input.producer, + capabilities: input.capabilities, + universe: input.universe, + sequence: 1, + generation: "", + upserts: input.upserts, + deletes: [] as string[], + manifest: input.manifest, + }; + transaction.generation = digestJson({ + tsconfig: transaction.tsconfig, + producer: transaction.producer, + capabilities: transaction.capabilities, + universe: transaction.universe, + manifest: transaction.manifest, + }); + return transaction; +} + +function sourceShard( + file: string, + key: string, + checkerDigest: string, + counter: { reads: number }, +) { + const id = `${file}#${file}:module`; + const node = { + get id(): string { + counter.reads += 1; + return id; + }, + kind: "module", + name: file, + file, + external: false, + }; + return { + key, + source: { + file, + checkerDigest, + diskDigest: digest(`disk:${file}`), + }, + nodes: [node], + edges: [], + diagnostics: [], + }; +} + +function digest(text: string): string { + return createHash("sha256").update(text).digest("hex"); +} + +function digestJson(value: unknown): string { + return digest( + JSON.stringify(value).replace(/[<>&\u2028\u2029]/gu, (character) => { + if (character === "<") return "\\u003c"; + if (character === ">") return "\\u003e"; + if (character === "&") return "\\u0026"; + return character === "\u2028" ? "\\u2028" : "\\u2029"; + }), + ); +} + +function compareUtf8(left: string, right: string): number { + return Buffer.compare(Buffer.from(left, "utf8"), Buffer.from(right, "utf8")); +} diff --git a/tests/test-graph/src/features/test_ttscgraph_protocol_adapter_deletes_dependency_shards.ts b/tests/test-graph/src/features/test_ttscgraph_protocol_adapter_deletes_dependency_shards.ts index f349e4c4..eb917a4e 100644 --- a/tests/test-graph/src/features/test_ttscgraph_protocol_adapter_deletes_dependency_shards.ts +++ b/tests/test-graph/src/features/test_ttscgraph_protocol_adapter_deletes_dependency_shards.ts @@ -94,6 +94,24 @@ async function assertNativeProducerDeltas(): Promise { await body.refresh(); const changed = await body.refresh(); const [coldTransaction, bodyTransaction] = nativeTransactions(bodyLog); + const coldSource = coldTransaction!.upserts.find((entry) => + entry.shard.source?.file === "src/core/order.ts", + )!; + const coldCoordinates = JSON.parse( + coldSource.shard.key.slice("1:source:".length), + ) as unknown[]; + TestValidator.equals( + "the fake producer uses ttsc's exact native shard identity coordinates", + coldCoordinates.slice(0, 6), + [ + 1, + coldTransaction!.producer.tool, + coldTransaction!.producer.version, + coldTransaction!.producer.typescript, + coldTransaction!.tsconfig, + sha256(goJson(coldTransaction!.universe)), + ], + ); const oldSourceKey = coldTransaction!.manifest.find((entry) => entry.key.includes('"src/core/order.ts"'), )!.key; @@ -154,6 +172,9 @@ async function assertNativeProducerDeltas(): Promise { } interface INativeLogTransaction { + tsconfig: string; + producer: { tool: string; version: string; typescript: string }; + universe: Record; manifest: { key: string; digest: string }[]; upserts: { digest: string; @@ -165,6 +186,15 @@ interface INativeLogTransaction { deletes: string[]; } +function goJson(value: unknown): string { + return JSON.stringify(value).replace(/[<>&\u2028\u2029]/gu, (character) => { + if (character === "<") return "\\u003c"; + if (character === ">") return "\\u003e"; + if (character === "&") return "\\u0026"; + return character === "\u2028" ? "\\u2028" : "\\u2029"; + }); +} + function nativeTransactions(file: string): INativeLogTransaction[] { return fs .readFileSync(file, "utf8") diff --git a/tests/test-graph/src/internal/fake-ttscgraph-server.cjs b/tests/test-graph/src/internal/fake-ttscgraph-server.cjs index 590533a7..d9063cf6 100644 --- a/tests/test-graph/src/internal/fake-ttscgraph-server.cjs +++ b/tests/test-graph/src/internal/fake-ttscgraph-server.cjs @@ -260,9 +260,10 @@ function nativeSnapshot(dump) { const shards = new Map(); const nodeFiles = new Map(dump.nodes.map((node) => [node.id, node.file])); const sourceOccurrences = new Map(); - const universeFingerprint = nativeUniverseFingerprint({ - universe: dump.provenance.universe, - }); + // ttsc binds shard identities to SHA-256(Go JSON(normalized Universe)). + // This is deliberately not the graph protocol's length-prefixed universe + // fingerprint, which is a separate downstream identity. + const producerUniverse = digestOf(goJSON(dump.provenance.universe)); const coordinates = (...values) => JSON.stringify([ 1, @@ -270,7 +271,7 @@ function nativeSnapshot(dump) { dump.provenance.producer.version, dump.provenance.producer.typescript, dump.tsconfig, - universeFingerprint, + producerUniverse, ...values, ]); for (const source of dump.provenance.sources) { @@ -409,7 +410,7 @@ function resignCompleteNativeSnapshot(snapshot) { resignNativeGeneration(snapshot); } -function nativeUniverseFingerprint(snapshot) { +function graphUniverseFingerprint(snapshot) { const hash = crypto.createHash("sha256"); const push = (text) => hash.update(`${String(text.length)}:${text}`); push("configs"); @@ -510,7 +511,7 @@ function corruptNativeSnapshot(snapshot, mode) { snapshot.producer.typescript, "typescript", snapshot.tsconfig, - nativeUniverseFingerprint(snapshot), + graphUniverseFingerprint(snapshot), ])}` : "0:coverage:foreign-native-shard"; resignCompleteNativeSnapshot(snapshot); From 296e993249bf1468180469eb02cb83fa63e33e9f Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Sat, 1 Aug 2026 10:16:52 +0900 Subject: [PATCH 23/52] test: match native TypeScript dependency boundaries --- .../ttscgraph/TtscGraphSnapshotStore.ts | 10 ++-- ...st_experiment_corpora_are_commit_pinned.ts | 2 +- ...delta_bounds_raw_facts_to_dependencies.ts} | 36 ++++++++++--- ...tocol_adapter_deletes_dependency_shards.ts | 30 ++++++++++- .../src/internal/fake-ttscgraph-server.cjs | 51 +++++++++++++------ 5 files changed, 101 insertions(+), 28 deletions(-) rename tests/test-graph/src/features/{test_ttscgraph_native_delta_revalidates_only_changed_raw_facts.ts => test_ttscgraph_native_delta_bounds_raw_facts_to_dependencies.ts} (85%) diff --git a/packages/graph/src/provider/ttscgraph/TtscGraphSnapshotStore.ts b/packages/graph/src/provider/ttscgraph/TtscGraphSnapshotStore.ts index e610bcc3..aeb725ee 100644 --- a/packages/graph/src/provider/ttscgraph/TtscGraphSnapshotStore.ts +++ b/packages/graph/src/provider/ttscgraph/TtscGraphSnapshotStore.ts @@ -498,10 +498,12 @@ function prepareNativeGenerationFacts( transaction: INativeTransaction, previous: INativeValidation, ): INativeValidation { - // Raw node, edge, and diagnostic arrays are revisited solely for changed - // shards. The compact indexes and common protocol reconstruction still make - // one O(N) pass over the generation to bind an atomic commit; only raw-fact - // parsing, not total normalization work, is delta-sized. + // Raw node, edge, and diagnostic arrays are revisited for changed shards and + // the retained target nodes their cross-shard edges depend on, never by an + // unrelated retained-shard scan. The compact indexes and common protocol + // reconstruction still make one O(N) pass over the generation to bind an + // atomic commit; only dependency-bounded raw-fact parsing, not total + // normalization work, is delta-sized. const next: INativeValidation = { summaries: new Map(previous.summaries), nodeById: new Map(previous.nodeById), diff --git a/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts b/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts index bf376687..d57befb2 100644 --- a/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts +++ b/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts @@ -28,7 +28,7 @@ export const test_experiment_corpora_are_commit_pinned = () => { const typescript = region( catalog, 'language: "typescript"', - 'language: "rust"', + 'language: "go"', ); const python = region(catalog, 'language: "python"', 'language: "ruby"'); const java = region(catalog, 'language: "java"', 'language: "csharp"'); diff --git a/tests/test-graph/src/features/test_ttscgraph_native_delta_revalidates_only_changed_raw_facts.ts b/tests/test-graph/src/features/test_ttscgraph_native_delta_bounds_raw_facts_to_dependencies.ts similarity index 85% rename from tests/test-graph/src/features/test_ttscgraph_native_delta_revalidates_only_changed_raw_facts.ts rename to tests/test-graph/src/features/test_ttscgraph_native_delta_bounds_raw_facts_to_dependencies.ts index 3407f859..53e0f00f 100644 --- a/tests/test-graph/src/features/test_ttscgraph_native_delta_revalidates_only_changed_raw_facts.ts +++ b/tests/test-graph/src/features/test_ttscgraph_native_delta_bounds_raw_facts_to_dependencies.ts @@ -7,9 +7,9 @@ import { GraphPaths } from "../internal/GraphPaths"; /** * A compact atomic manifest is generation-wide, but semantic fact parsing is - * not: one source replacement must never touch retained raw node arrays. + * bounded to one changed source and the retained targets its edges require. */ -export const test_ttscgraph_native_delta_revalidates_only_changed_raw_facts = +export const test_ttscgraph_native_delta_bounds_raw_facts_to_dependencies = () => { const root = GraphPaths.createTempDirectory( "samchon-graph-native-delta-cost-", @@ -26,6 +26,7 @@ export const test_ttscgraph_native_delta_revalidates_only_changed_raw_facts = "1:source:replacement", digest("replacement checker text"), changedCounter, + `${fixture.files[1]}#${fixture.files[1]}:function`, ); const changedDigest = digestJson(changedShard); const manifest = [ @@ -64,10 +65,14 @@ export const test_ttscgraph_native_delta_revalidates_only_changed_raw_facts = }); TestValidator.equals( - "a one-source delta never reparses retained raw facts", - fixture.counters.slice(1).reduce((sum, row) => sum + row.reads, 0), + "a one-source delta never scans unrelated retained raw facts", + fixture.counters.slice(2).reduce((sum, row) => sum + row.reads, 0), 0, ); + TestValidator.predicate( + "a changed cross-shard edge reparses its retained target dependency", + fixture.counters[1]!.reads > 0, + ); TestValidator.predicate( "the replacement source is still parsed and validated", changedCounter.reads > 0, @@ -110,6 +115,7 @@ function initialTransaction(root: string, size: number): { sourceKeys[index]!, digest(`checker:${file}`), counters[index]!, + index === 0 ? `${files[1]}#${files[1]}:function` : undefined, ), ); shards.push({ @@ -174,14 +180,15 @@ function sourceShard( key: string, checkerDigest: string, counter: { reads: number }, + target?: string, ) { - const id = `${file}#${file}:module`; + const id = `${file}#${file}:function`; const node = { get id(): string { counter.reads += 1; return id; }, - kind: "module", + kind: "function", name: file, file, external: false, @@ -194,7 +201,22 @@ function sourceShard( diskDigest: digest(`disk:${file}`), }, nodes: [node], - edges: [], + edges: + target === undefined + ? [] + : [ + { + from: id, + to: target, + kind: "calls", + evidence: { + startLine: 1, + startCol: 1, + endLine: 1, + endCol: 2, + }, + }, + ], diagnostics: [], }; } diff --git a/tests/test-graph/src/features/test_ttscgraph_protocol_adapter_deletes_dependency_shards.ts b/tests/test-graph/src/features/test_ttscgraph_protocol_adapter_deletes_dependency_shards.ts index eb917a4e..64b071ba 100644 --- a/tests/test-graph/src/features/test_ttscgraph_protocol_adapter_deletes_dependency_shards.ts +++ b/tests/test-graph/src/features/test_ttscgraph_protocol_adapter_deletes_dependency_shards.ts @@ -12,6 +12,9 @@ import { GraphPaths } from "../internal/GraphPaths"; const sha256 = (text: string): string => createHash("sha256").update(text).digest("hex"); +const compareUtf8 = (left: string, right: string): number => + Buffer.compare(Buffer.from(left, "utf8"), Buffer.from(right, "utf8")); + /** * The TypeScript reference adapter keeps dependency churn inside the protocol: * a dependency that leaves the compiler manifest becomes an explicit shard @@ -100,6 +103,14 @@ async function assertNativeProducerDeltas(): Promise { const coldCoordinates = JSON.parse( coldSource.shard.key.slice("1:source:".length), ) as unknown[]; + const normalizedUniverse = normalizeProducerUniverse( + coldTransaction!.universe, + ); + TestValidator.equals( + "the fake producer publishes ttsc's normalized universe order", + coldTransaction!.universe, + normalizedUniverse, + ); TestValidator.equals( "the fake producer uses ttsc's exact native shard identity coordinates", coldCoordinates.slice(0, 6), @@ -109,7 +120,7 @@ async function assertNativeProducerDeltas(): Promise { coldTransaction!.producer.version, coldTransaction!.producer.typescript, coldTransaction!.tsconfig, - sha256(goJson(coldTransaction!.universe)), + sha256(goJson(normalizedUniverse)), ], ); const oldSourceKey = coldTransaction!.manifest.find((entry) => @@ -195,6 +206,23 @@ function goJson(value: unknown): string { }); } +function normalizeProducerUniverse( + universe: Record, +): Record { + const configs = universe.configs as { file: string; digest: string }[]; + const roots = universe.roots as { config: string; file: string }[]; + return { + configs: [...configs].sort((left, right) => + compareUtf8(left.file, right.file), + ), + roots: [...roots].sort( + (left, right) => + compareUtf8(left.config, right.config) || + compareUtf8(left.file, right.file), + ), + }; +} + function nativeTransactions(file: string): INativeLogTransaction[] { return fs .readFileSync(file, "utf8") diff --git a/tests/test-graph/src/internal/fake-ttscgraph-server.cjs b/tests/test-graph/src/internal/fake-ttscgraph-server.cjs index d9063cf6..0c080282 100644 --- a/tests/test-graph/src/internal/fake-ttscgraph-server.cjs +++ b/tests/test-graph/src/internal/fake-ttscgraph-server.cjs @@ -260,21 +260,22 @@ function nativeSnapshot(dump) { const shards = new Map(); const nodeFiles = new Map(dump.nodes.map((node) => [node.id, node.file])); const sourceOccurrences = new Map(); + const nativeProvenance = normalizeNativeProvenance(dump.provenance); // ttsc binds shard identities to SHA-256(Go JSON(normalized Universe)). // This is deliberately not the graph protocol's length-prefixed universe // fingerprint, which is a separate downstream identity. - const producerUniverse = digestOf(goJSON(dump.provenance.universe)); + const producerUniverse = digestOf(goJSON(nativeProvenance.universe)); const coordinates = (...values) => JSON.stringify([ 1, - dump.provenance.producer.tool, - dump.provenance.producer.version, - dump.provenance.producer.typescript, + nativeProvenance.producer.tool, + nativeProvenance.producer.version, + nativeProvenance.producer.typescript, dump.tsconfig, producerUniverse, ...values, ]); - for (const source of dump.provenance.sources) { + for (const source of nativeProvenance.sources) { const occurrence = sourceOccurrences.get(source.file) ?? 0; sourceOccurrences.set(source.file, occurrence + 1); const prefix = source.file.startsWith("bundled:///") ? "2" : "1"; @@ -299,7 +300,7 @@ function nativeSnapshot(dump) { ), }); } - for (const config of dump.provenance.universe.configs) { + for (const config of nativeProvenance.universe.configs) { const key = `3:config:${coordinates(config.file, config.digest)}`; shards.set(key, { key, @@ -320,8 +321,8 @@ function nativeSnapshot(dump) { }); const metadataKey = `0:metadata:${coordinates("metadata")}`; const inputFiles = new Set([ - ...dump.provenance.sources.map((source) => source.file), - ...dump.provenance.universe.configs.map((config) => config.file), + ...nativeProvenance.sources.map((source) => source.file), + ...nativeProvenance.universe.configs.map((config) => config.file), ]); shards.set(metadataKey, { key: metadataKey, @@ -344,19 +345,19 @@ function nativeSnapshot(dump) { const sequence = (nativeState?.sequence ?? 0) + 1; const transaction = { protocolVersion: 1, - schemaVersion: dump.provenance.schemaVersion, + schemaVersion: nativeProvenance.schemaVersion, project: dump.project, tsconfig: dump.tsconfig, - producer: dump.provenance.producer, - capabilities: dump.provenance.capabilities, - universe: dump.provenance.universe, + producer: nativeProvenance.producer, + capabilities: nativeProvenance.capabilities, + universe: nativeProvenance.universe, sequence, generation: digestOf( goJSON({ tsconfig: dump.tsconfig, - producer: dump.provenance.producer, - capabilities: dump.provenance.capabilities, - universe: dump.provenance.universe, + producer: nativeProvenance.producer, + capabilities: nativeProvenance.capabilities, + universe: nativeProvenance.universe, manifest, }), ), @@ -385,6 +386,26 @@ function nativeSnapshot(dump) { return transaction; } +function normalizeNativeProvenance(provenance) { + return { + ...provenance, + capabilities: [...provenance.capabilities], + universe: { + configs: [...provenance.universe.configs].sort((left, right) => + compareUtf8(left.file, right.file), + ), + roots: [...provenance.universe.roots].sort( + (left, right) => + compareUtf8(left.config, right.config) || + compareUtf8(left.file, right.file), + ), + }, + sources: [...provenance.sources].sort((left, right) => + compareUtf8(left.file, right.file), + ), + }; +} + function resignNativeGeneration(snapshot) { snapshot.generation = digestOf( goJSON({ From b4eb6074662bbb804e56064afe0a4accd68626b2 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Sat, 1 Aug 2026 10:33:29 +0900 Subject: [PATCH 24/52] test: encode native TypeScript keys like Go --- ...tocol_adapter_deletes_dependency_shards.ts | 14 +++++++++++++ .../src/internal/fake-ttscgraph-server.cjs | 21 +++++++++++++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/tests/test-graph/src/features/test_ttscgraph_protocol_adapter_deletes_dependency_shards.ts b/tests/test-graph/src/features/test_ttscgraph_protocol_adapter_deletes_dependency_shards.ts index 64b071ba..0152f0c2 100644 --- a/tests/test-graph/src/features/test_ttscgraph_protocol_adapter_deletes_dependency_shards.ts +++ b/tests/test-graph/src/features/test_ttscgraph_protocol_adapter_deletes_dependency_shards.ts @@ -82,6 +82,7 @@ async function assertNativeProducerDeltas(): Promise { "export function first() {}\n", ); fs.writeFileSync(path.join(root, "src", "empty.ts"), "export {};\n"); + fs.writeFileSync(path.join(root, "src", "a&b.ts"), "export {};\n"); const bodyLog = path.join(root, "body-native.ndjson"); const body = new TtscGraphClient({ @@ -89,6 +90,7 @@ async function assertNativeProducerDeltas(): Promise { command: process.execPath, args: [ GraphPaths.fakeTtscGraphServer, + "--native-coordinate-escape", `--native-log=${bodyLog}`, ], }); @@ -123,6 +125,18 @@ async function assertNativeProducerDeltas(): Promise { sha256(goJson(normalizedUniverse)), ], ); + const escapedSource = coldTransaction!.upserts.find( + (entry) => entry.shard.source?.file === "src/a&b.ts", + )!; + const escapedCoordinates = escapedSource.shard.key.slice( + "1:source:".length, + ); + TestValidator.predicate( + "native shard coordinates use Go's HTML-sensitive JSON escaping", + escapedCoordinates.includes('"src/a\\u0026b.ts"') && + !escapedCoordinates.includes("src/a&b.ts") && + (JSON.parse(escapedCoordinates) as unknown[])[6] === "src/a&b.ts", + ); const oldSourceKey = coldTransaction!.manifest.find((entry) => entry.key.includes('"src/core/order.ts"'), )!.key; diff --git a/tests/test-graph/src/internal/fake-ttscgraph-server.cjs b/tests/test-graph/src/internal/fake-ttscgraph-server.cjs index 0c080282..b974e486 100644 --- a/tests/test-graph/src/internal/fake-ttscgraph-server.cjs +++ b/tests/test-graph/src/internal/fake-ttscgraph-server.cjs @@ -84,6 +84,7 @@ const envelopeCapabilityMismatch = args.includes( const conformance = args.includes("--conformance"); const conformanceHeuristic = args.includes("--conformance-heuristic"); const phaseTrace = args.includes("--phase-trace"); +const coordinateEscape = args.includes("--native-coordinate-escape"); let requests = 0; let nativeState; let nativeBase; @@ -99,7 +100,12 @@ if (duplicateCapability) CAPABILITIES.push(CAPABILITIES[0]); // Every workspace and bundled file the fake program loaded. The manifest must // cover every file the nodes below name, because that is what the client checks. -const WORKSPACE_FILES = ["src/index.ts", "src/core/order.ts", "src/empty.ts"]; +const WORKSPACE_FILES = [ + "src/index.ts", + "src/core/order.ts", + "src/empty.ts", + ...(coordinateEscape ? ["src/a&b.ts"] : []), +]; const BUNDLED_FILES = ["bundled:///libs/lib.es2015.collection.d.ts"]; const digestOf = (text) => @@ -233,6 +239,17 @@ const graph = (name, options = {}) => ({ file: "src/empty.ts", external: false, }, + ...(coordinateEscape + ? [ + { + id: "src/a&b.ts#src/a&b.ts:module", + kind: "module", + name: "src/a&b.ts", + file: "src/a&b.ts", + external: false, + }, + ] + : []), { id: "bundled:///libs/lib.es2015.collection.d.ts#Map:interface", kind: "interface", @@ -266,7 +283,7 @@ function nativeSnapshot(dump) { // fingerprint, which is a separate downstream identity. const producerUniverse = digestOf(goJSON(nativeProvenance.universe)); const coordinates = (...values) => - JSON.stringify([ + goJSON([ 1, nativeProvenance.producer.tool, nativeProvenance.producer.version, From 74bdc6093c03ddcc0ef573519747c4267197bb9e Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Sat, 1 Aug 2026 19:12:53 +0900 Subject: [PATCH 25/52] feat: integrate resident Rust HIR snapshots --- README.md | 10 +- docs/provider-support.json | 39 +- packages/graph/build/provider-support.mjs | 19 +- packages/graph/src/indexer/buildLspGraph.ts | 173 +-- packages/graph/src/lsp/LspClient.ts | 33 +- packages/graph/src/lsp/LspResponseError.ts | 11 + packages/graph/src/lsp/index.ts | 1 + .../graph/src/provider/GRAPH_PROVIDERS.ts | 4 +- packages/graph/src/provider/IGraphProvider.ts | 9 + .../src/provider/rust/IRustGraphCacheState.ts | 11 + .../src/provider/rust/IRustGraphCheckpoint.ts | 15 + .../rust/IRustGraphCheckpointSource.ts | 4 + .../src/provider/rust/IRustGraphCoverage.ts | 4 + .../src/provider/rust/IRustGraphDiagnostic.ts | 8 + .../graph/src/provider/rust/IRustGraphEdge.ts | 8 + .../src/provider/rust/IRustGraphEvidence.ts | 7 + .../provider/rust/IRustGraphManifestEntry.ts | 4 + .../graph/src/provider/rust/IRustGraphNode.ts | 13 + .../src/provider/rust/IRustGraphPhases.ts | 7 + .../src/provider/rust/IRustGraphProducer.ts | 5 + .../src/provider/rust/IRustGraphShard.ts | 27 + .../src/provider/rust/IRustGraphSnapshot.ts | 19 + .../provider/rust/IRustGraphSnapshotParams.ts | 6 + .../src/provider/rust/IRustGraphUniverse.ts | 7 + .../rust/RUST_GRAPH_PRODUCER_COMMIT.ts | 2 + .../graph/src/provider/rust/RUST_HIR_FACTS.ts | 5 + .../src/provider/rust/RUST_HIR_PRODUCER.ts | 1 + .../src/provider/rust/RUST_HIR_PROVIDER.ts | 1 + .../graph/src/provider/rust/RustGraphCache.ts | 195 ++++ .../src/provider/rust/RustGraphClient.ts | 385 +++++++ .../provider/rust/RustGraphSnapshotAdapter.ts | 984 ++++++++++++++++++ packages/graph/src/provider/rust/index.ts | 23 + .../src/provider/rust/rustGraphProvider.ts | 101 ++ .../src/provider/selectGraphProviders.ts | 106 +- tests/experiment/src/catalog.mjs | 39 +- ...rvers_that_break_the_shutdown_handshake.ts | 91 +- ...registry_selects_one_owner_per_language.ts | 147 +++ ...lient_restores_retries_and_fails_closed.ts | 474 +++++++++ ...hir_snapshot_adapter_fences_generations.ts | 765 ++++++++++++++ ...providers_execute_their_exact_contracts.ts | 38 +- tests/test-graph/src/internal/GraphPaths.ts | 1 + .../src/internal/fake-rust-graph-server.cjs | 390 +++++++ 42 files changed, 4014 insertions(+), 178 deletions(-) create mode 100644 packages/graph/src/lsp/LspResponseError.ts create mode 100644 packages/graph/src/provider/rust/IRustGraphCacheState.ts create mode 100644 packages/graph/src/provider/rust/IRustGraphCheckpoint.ts create mode 100644 packages/graph/src/provider/rust/IRustGraphCheckpointSource.ts create mode 100644 packages/graph/src/provider/rust/IRustGraphCoverage.ts create mode 100644 packages/graph/src/provider/rust/IRustGraphDiagnostic.ts create mode 100644 packages/graph/src/provider/rust/IRustGraphEdge.ts create mode 100644 packages/graph/src/provider/rust/IRustGraphEvidence.ts create mode 100644 packages/graph/src/provider/rust/IRustGraphManifestEntry.ts create mode 100644 packages/graph/src/provider/rust/IRustGraphNode.ts create mode 100644 packages/graph/src/provider/rust/IRustGraphPhases.ts create mode 100644 packages/graph/src/provider/rust/IRustGraphProducer.ts create mode 100644 packages/graph/src/provider/rust/IRustGraphShard.ts create mode 100644 packages/graph/src/provider/rust/IRustGraphSnapshot.ts create mode 100644 packages/graph/src/provider/rust/IRustGraphSnapshotParams.ts create mode 100644 packages/graph/src/provider/rust/IRustGraphUniverse.ts create mode 100644 packages/graph/src/provider/rust/RUST_GRAPH_PRODUCER_COMMIT.ts create mode 100644 packages/graph/src/provider/rust/RUST_HIR_FACTS.ts create mode 100644 packages/graph/src/provider/rust/RUST_HIR_PRODUCER.ts create mode 100644 packages/graph/src/provider/rust/RUST_HIR_PROVIDER.ts create mode 100644 packages/graph/src/provider/rust/RustGraphCache.ts create mode 100644 packages/graph/src/provider/rust/RustGraphClient.ts create mode 100644 packages/graph/src/provider/rust/RustGraphSnapshotAdapter.ts create mode 100644 packages/graph/src/provider/rust/rustGraphProvider.ts create mode 100644 tests/test-graph/src/features/test_rust_hir_client_restores_retries_and_fails_closed.ts create mode 100644 tests/test-graph/src/features/test_rust_hir_snapshot_adapter_fences_generations.ts create mode 100644 tests/test-graph/src/internal/fake-rust-graph-server.cjs diff --git a/README.md b/README.md index ddd83fed..2ea2f6bf 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ Strict selection is per registered provider and may decline for missing tools, i | `ttscgraph` | `typescript` | `compiler` | `exports`, `calls`, `accesses`, `instantiates`, `type_ref`, `extends`, `implements`, `overrides`, `renders` | [upstream](https://github.com/samchon/ttsc) / [route #63](https://github.com/samchon/compiler-graph/issues/63) | | `samchon-graph-go` | `go` | `compiler` | `contains`, `exports`, `imports`, `calls`, `accesses`, `instantiates`, `type_ref`, `implements`, `dispatches`, `tests`, `references` | [upstream](https://github.com/scip-code/scip-go) / [route #63](https://github.com/samchon/compiler-graph/issues/63) | | `samchon-graph-lua` | `lua` | `analyzer` | `references` | [upstream](https://github.com/LuaLS/lua-language-server) / [route #83](https://github.com/samchon/compiler-graph/issues/83) | -| `rust-analyzer-scip` | `rust` | `semantic-index` | `contains`, `references` | [upstream](https://github.com/rust-lang/rust-analyzer) / [route #72](https://github.com/samchon/compiler-graph/issues/72) | +| `samchon-rust-analyzer-hir` | `rust` | `analyzer` | `contains`, `exports`, `imports`, `calls`, `accesses`, `instantiates`, `type_ref`, `extends`, `implements`, `overrides`, `dispatches`, `decorates`, `tests`, `references` | [upstream](https://github.com/samchon/rust-analyzer) / [route #72](https://github.com/samchon/compiler-graph/issues/72) | | `scip-clang` | `c`, `cpp` | `semantic-index` | **none** | [upstream](https://github.com/sourcegraph/scip-clang) / [route #73](https://github.com/samchon/compiler-graph/issues/73) | | `scip-java` | `java`, `kotlin` | `semantic-index` | `contains`, `references` | [upstream](https://github.com/scip-code/scip-java) / [route #74](https://github.com/samchon/compiler-graph/issues/74) / [route #76](https://github.com/samchon/compiler-graph/issues/76) | | `scip-dotnet` | `csharp` | `semantic-index` | **none** | [upstream](https://github.com/sourcegraph/scip-dotnet) / [route #75](https://github.com/samchon/compiler-graph/issues/75) | @@ -91,7 +91,7 @@ These are current implementation modes, not future route claims. Preparation and | `ttscgraph` | `resident-no-op-reuse; invalidated-closure shard deltas with a compatible producer` | A matching ttsc/TypeScript project and tsconfig/jsconfig/package inputs. | A compatible target-project ttsc checker owns one resident compiler process and its incremental semantic state. | Changed compiler-owned raw shards cross a versioned transaction; the client validates the complete manifest and adapts only upserts before atomic publication. | Unchanged requests reuse the exact snapshot; body edits reuse unaffected native and normalized shards, while build-universe changes reload safely. | | `samchon-graph-go` | `unchanged-snapshot-reuse; full-rebuild-on-change` | Go workspace/module inputs, selected GOOS/GOARCH/cgo environment, embedded files and vendored inputs. | The shipped exporter runs one compiler-owned go/packages batch against the selected build universe. | A changed-input batch emits and validates one whole-workspace graph before snapshot publication. | Unchanged inputs reuse the validated snapshot; no resident go/packages checker survives changed builds. | | `samchon-graph-lua` | `unchanged-snapshot-reuse; full-rebuild-on-change` | LuaLS workspace configuration and the shipped readable exporter. | LuaLS analyzes the workspace and the shipped exporter asks its semantic VM for declaration references. | A changed-input run publishes one references-only whole-workspace graph. | Unchanged inputs reuse the validated snapshot; the current exporter is not a resident incremental session. | -| `rust-analyzer-scip` | `unchanged-snapshot-reuse; full-rebuild-on-change` | Cargo metadata/config, lock/toolchain inputs, target/features/cfg and build-script/proc-macro universe. | Stock rust-analyzer produces one batch SCIP artifact for the selected Cargo universe. | The decoder maps the complete artifact to a contains/references graph before atomic snapshot publication. | Unchanged inputs reuse the validated snapshot; no rust-analyzer semantic session remains resident. | +| `samchon-rust-analyzer-hir` | `resident-no-op-reuse; invalidated-closure shard deltas; validated restart checkpoints` | Cargo metadata/config, lock/toolchain inputs, target/features/cfg and build-script/proc-macro universe. | The pinned rust-analyzer fork owns one resident HIR database and exports declarations, semantic relationships, diagnostics, coverage and unresolved boundaries from that exact analysis revision. | Content-addressed source shards cross a versioned LSP transaction; the client verifies producer identity, universe, complete manifests, shard digests and graph invariants before atomic publication. | No-op requests reuse the resident snapshot; interface changes invalidate dependent shards, while a complete consumer checkpoint restores the same generation after process restart. | | `scip-clang` | `unchanged-snapshot-reuse; full-rebuild-on-change` | A valid compilation database and every named compiler/working directory/generated build input. | scip-clang runs one batch over the exact compilation database and its per-unit compiler commands. | The complete decoded artifact publishes declarations but no currently defensible edge family; producer scheduling can move header selection. | Unchanged inputs reuse the validated snapshot; every changed build reruns the batch producer. | | `scip-java` | `unchanged-snapshot-reuse; full-rebuild-on-change` | Maven or Gradle project metadata, dependency/classpath state and Java/Kotlin compiler inputs. | scip-java drives the selected Maven or Gradle build and its Java/Kotlin producers as one batch. | The complete decoded artifact is merged as a contains/references graph before atomic publication. | Unchanged inputs reuse the validated snapshot; no javac, kotlinc or build session remains resident. | | `scip-dotnet` | `unchanged-snapshot-reuse; full-rebuild-on-change` | Solution/project/TFM/NuGet/MSBuild inputs and a resolvable SDK. | scip-dotnet loads and analyzes the selected solution through one batch producer run. | The complete decoded artifact publishes declarations but no currently defensible edge family. | Unchanged inputs reuse the validated snapshot; no Roslyn workspace remains resident. | @@ -109,7 +109,7 @@ The troubleshooting table names the ordinary language-server/static fallback for | `ttscgraph` | Install a ttsc release that supports graph snapshot protocol v1. `ttsc@0.23.0` provides the ordinary `ttscserver` fallback but predates this strict protocol. | [ttsc 0.23.0 legacy release](https://www.npmjs.com/package/ttsc/v/0.23.0), [native shard producer PR](https://github.com/samchon/ttsc/pull/1056) | `ttscgraph`, `ttscserver` | — | `TTSC_GRAPH_BINARY` | Absolute `TTSC_GRAPH_BINARY`, target-project `ttsc` package/binary, target-project `.bin`, then PATH/global compatibility fallback. | A matching ttsc/TypeScript project and tsconfig/jsconfig/package inputs. | `linux`, `macos`, `windows` | | `samchon-graph-go` | Go 1.25+; the package ships the Go exporter source. Install corroboration with `go install github.com/scip-code/scip-go/cmd/scip-go@v0.2.7`. | [Go downloads](https://go.dev/dl/), [scip-go 0.2.7 source](https://github.com/scip-code/scip-go/tree/v0.2.7) | `samchon-graph-go`, `go`, `scip-go` | — | `SAMCHON_GRAPH_GO`, `SAMCHON_GRAPH_GO_TOOLCHAIN`, `SAMCHON_GRAPH_SCIP_GO` | Project/PATH `samchon-graph-go`, then the shipped source runner through Go; absolute environment overrides take precedence. | Go workspace/module inputs, selected GOOS/GOARCH/cgo environment, embedded files and vendored inputs. | `linux`, `macos`, `windows` | | `samchon-graph-lua` | Install `lua-language-server`; the package ships `sidecars/lua/export.lua`. | [LuaLS releases](https://github.com/LuaLS/lua-language-server/releases) | `lua-language-server` | — | `SAMCHON_GRAPH_LUA`, `SAMCHON_GRAPH_LUA_EXPORTER` | Absolute `SAMCHON_GRAPH_LUA`, then project/PATH LuaLS; the shipped exporter may be replaced by `SAMCHON_GRAPH_LUA_EXPORTER`. | LuaLS workspace configuration and the shipped readable exporter. | `linux`, `macos`, `windows` | -| `rust-analyzer-scip` | `rustup component add rust-analyzer`; install the `scip` decoder and provide matching rustc/Cargo. | [rust-analyzer installation](https://rust-analyzer.github.io/book/rust_analyzer_binary.html), [SCIP releases](https://github.com/sourcegraph/scip/releases) | `rust-analyzer`, `scip`, `rustc`, `cargo` | — | `SAMCHON_GRAPH_RUST_ANALYZER`, `SAMCHON_GRAPH_SCIP`, `SAMCHON_GRAPH_RUSTC`, `SAMCHON_GRAPH_CARGO` | Project-local tools precede PATH; each absolute environment override replaces only its named tool. | Cargo metadata/config, lock/toolchain inputs, target/features/cfg and build-script/proc-macro universe. | `linux`, `macos`, `windows` | +| `samchon-rust-analyzer-hir` | Build the `samchon/rust-analyzer` graph-snapshot fork at commit `3e8db3829e471b6af9acd1f14052e641fb81c4fd`; point `SAMCHON_GRAPH_RUST_ANALYZER_HIR` at that binary or install it as `samchon-rust-analyzer`. | [native HIR graph producer PR](https://github.com/samchon/rust-analyzer/pull/1), [rust-analyzer build instructions](https://rust-analyzer.github.io/book/contributing.html) | `samchon-rust-analyzer`, `rust-analyzer` | — | `SAMCHON_GRAPH_RUST_ANALYZER_HIR` | Absolute `SAMCHON_GRAPH_RUST_ANALYZER_HIR`, then project/PATH `samchon-rust-analyzer`, then a project/PATH `rust-analyzer` only when its version reports the pinned producer commit. | Cargo metadata/config, lock/toolchain inputs, target/features/cfg and build-script/proc-macro universe. | `linux`, `macos`, `windows` | | `scip-clang` | Install `scip-clang` 0.4.0 and the `scip` decoder; provide a generated `compile_commands.json`. | [scip-clang 0.4.0 release](https://github.com/sourcegraph/scip-clang/releases/tag/v0.4.0), [SCIP releases](https://github.com/sourcegraph/scip/releases) | `scip-clang`, `scip` | `compile_commands.json`, `build/compile_commands.json` | `SAMCHON_GRAPH_SCIP_CLANG`, `SAMCHON_GRAPH_SCIP` | Project-local producer/decoder precede PATH; `SAMCHON_GRAPH_SCIP_CLANG` and `SAMCHON_GRAPH_SCIP` select absolute tools. Compilers come from the compilation database. | A valid compilation database and every named compiler/working directory/generated build input. | `linux`, `macos`, `windows-when-installed` | | `scip-java` | Install `scip-java` 0.13.1, the `scip` decoder and a compatible JDK; Kotlin experiments pin a compatible source build. | [scip-java 0.13.1 release](https://github.com/scip-code/scip-java/releases/tag/v0.13.1), [SCIP releases](https://github.com/sourcegraph/scip/releases) | `scip-java`, `scip`, `java` | — | `SAMCHON_GRAPH_SCIP_JAVA`, `SAMCHON_GRAPH_SCIP`, `SAMCHON_GRAPH_JAVA_TOOLCHAIN` | Project-local producer/decoder/JDK precede PATH; absolute environment overrides select each tool. | Maven or Gradle project metadata, dependency/classpath state and Java/Kotlin compiler inputs. | `linux`, `macos`, `windows` | | `scip-dotnet` | `dotnet tool install --global scip-dotnet`; install the `scip` decoder and matching .NET SDK. | [scip-dotnet on NuGet](https://www.nuget.org/packages/scip-dotnet), [SCIP releases](https://github.com/sourcegraph/scip/releases) | `scip-dotnet`, `scip`, `dotnet` | — | `SAMCHON_GRAPH_SCIP_DOTNET`, `SAMCHON_GRAPH_SCIP`, `SAMCHON_GRAPH_DOTNET_TOOLCHAIN` | Project-local producer/decoder/toolchain precede PATH; absolute environment overrides select each tool. | Solution/project/TFM/NuGet/MSBuild inputs and a resolvable SDK. | `linux`, `macos`, `windows` | @@ -127,7 +127,7 @@ These are exact same-run cold end-to-end strict/strict-disabled pairs from [`tes | `excalidraw` | `ttscgraph` | 5,340.296 ms | 2,977.720 ms | | `gin` | `samchon-graph-go` | 38,097.048 ms | 687.107 ms | | `lualine` | `samchon-graph-lua` | 18,889.245 ms | 27,848.007 ms | -| `tokio` | `rust-analyzer-scip` | 55,238.180 ms | 229,860.996 ms | +| `tokio` | `rust-analyzer-scip` (prior fallback evidence; `samchon-rust-analyzer-hir` not yet measured) | 55,238.180 ms | 229,860.996 ms | | `redis` | `scip-clang` | 22,794.688 ms | 262,905.796 ms | | `leveldb` | `scip-clang` | 8,352.928 ms | 26,451.952 ms | | `gson` | `scip-java` | 88,653.499 ms | 231,398.489 ms | @@ -147,7 +147,7 @@ A strict result's provenance name must equal the provider below. If it is absent | `typescript` | `ttscgraph` | No compatible ttsc release is published yet. Version 0.23.0 returns a legacy complete dump and therefore falls back honestly until the native shard producer ships. | A missing target-project ttsc binary, legacy full-dump producer, incompatible request cap, malformed transaction or unsupported schema declines the strict provider. | `ttscserver`, then `@samchon/graph-sitter`. | | `go` | `samchon-graph-go` | Current changed-input export is a full rebuild and does not retain a resident `go/packages` checker session. | A missing Go 1.25+ toolchain, missing pinned scip-go corroborator or invalid workspace/module load declines the strict provider. | `gopls`, then `@samchon/graph-sitter`. | | `lua` | `samchon-graph-lua` | The current exporter calls `vm.getRefs` per declaration and proves references only; #83 replaces it with an occurrence-oriented resident traversal. | A missing LuaLS binary/exporter, invalid workspace result or bounded request declines the strict provider. | Generic LuaLS, then `@samchon/graph-sitter`. | -| `rust` | `rust-analyzer-scip` | Stock rust-analyzer SCIP has empty relationships/diagnostics and is navigation evidence, not the final HIR graph. | A missing analyzer, decoder, rustc or Cargo component, or an invalid Cargo project load, declines the strict provider. | Generic rust-analyzer, then `@samchon/graph-sitter`. | +| `rust` | `samchon-rust-analyzer-hir` | The producer is currently available from the draft fork PR rather than a rust-analyzer release, and Rust has no `renders` relationship family. | A missing pinned producer, incompatible commit/schema, malformed transaction, invalid checkpoint, unsupported bounded option or failed Cargo workspace load declines this route. | Stock `rust-analyzer-scip`, then generic rust-analyzer, then `@samchon/graph-sitter`. | | `c`, `cpp` | `scip-clang` | The current artifact proves declarations but no graph edge family because enclosing attribution and type-definition relationships are absent. | A missing producer/decoder, missing or invalid compilation database, or an unresolved per-unit compiler declines the strict provider. | `clangd`, then `@samchon/graph-sitter`. | | `java`, `kotlin` | `scip-java` | The released Gradle path disables configuration cache and runs `clean scipCompileAll`; it is navigation fallback for #74/#76, not compiler-owned calls or accesses. | A missing producer/decoder/JDK, unsupported Maven or Gradle project, or invalid dependency/build configuration declines the strict provider. | `jdtls` or `kotlin-language-server`, then `@samchon/graph-sitter`. | | `csharp` | `scip-dotnet` | The artifact proves declarations but no graph edge family and can log MSBuildWorkspace failures after publishing. | A missing producer/decoder/.NET SDK, absent solution/project input or invalid MSBuild load declines the strict provider. | `csharp-ls`, then `@samchon/graph-sitter`. | diff --git a/docs/provider-support.json b/docs/provider-support.json index 65d7e82b..14c858e6 100644 --- a/docs/provider-support.json +++ b/docs/provider-support.json @@ -94,33 +94,34 @@ "childIssues": ["https://github.com/samchon/compiler-graph/issues/83"] }, { - "provider": "rust-analyzer-scip", + "provider": "samchon-rust-analyzer-hir", "languages": ["rust"], "status": "registered", - "authority": "semantic-index", - "facts": ["contains", "references"], - "commands": ["rust-analyzer", "scip", "rustc", "cargo"], - "environmentOverrides": ["SAMCHON_GRAPH_RUST_ANALYZER", "SAMCHON_GRAPH_SCIP", "SAMCHON_GRAPH_RUSTC", "SAMCHON_GRAPH_CARGO"], - "install": "`rustup component add rust-analyzer`; install the `scip` decoder and provide matching rustc/Cargo.", + "authority": "analyzer", + "facts": ["contains", "exports", "imports", "calls", "accesses", "instantiates", "type_ref", "extends", "implements", "overrides", "dispatches", "decorates", "tests", "references"], + "commands": ["samchon-rust-analyzer", "rust-analyzer"], + "environmentOverrides": ["SAMCHON_GRAPH_RUST_ANALYZER_HIR"], + "install": "Build the `samchon/rust-analyzer` graph-snapshot fork at commit `3e8db3829e471b6af9acd1f14052e641fb81c4fd`; point `SAMCHON_GRAPH_RUST_ANALYZER_HIR` at that binary or install it as `samchon-rust-analyzer`.", "installSources": [ - {"label": "rust-analyzer installation", "url": "https://rust-analyzer.github.io/book/rust_analyzer_binary.html"}, - {"label": "SCIP releases", "url": "https://github.com/sourcegraph/scip/releases"} + {"label": "native HIR graph producer PR", "url": "https://github.com/samchon/rust-analyzer/pull/1"}, + {"label": "rust-analyzer build instructions", "url": "https://rust-analyzer.github.io/book/contributing.html"} ], - "resolution": "Project-local tools precede PATH; each absolute environment override replaces only its named tool.", + "resolution": "Absolute `SAMCHON_GRAPH_RUST_ANALYZER_HIR`, then project/PATH `samchon-rust-analyzer`, then a project/PATH `rust-analyzer` only when its version reports the pinned producer commit.", "requirements": "Cargo metadata/config, lock/toolchain inputs, target/features/cfg and build-script/proc-macro universe.", "platforms": ["linux", "macos", "windows"], - "mode": "unchanged-snapshot-reuse; full-rebuild-on-change", - "nativeAnalysis": "Stock rust-analyzer produces one batch SCIP artifact for the selected Cargo universe.", - "exportMerge": "The decoder maps the complete artifact to a contains/references graph before atomic snapshot publication.", - "reuseResident": "Unchanged inputs reuse the validated snapshot; no rust-analyzer semantic session remains resident.", - "limitations": "Stock rust-analyzer SCIP has empty relationships/diagnostics and is navigation evidence, not the final HIR graph.", - "decline": "A missing analyzer, decoder, rustc or Cargo component, or an invalid Cargo project load, declines the strict provider.", - "fallback": "Generic rust-analyzer, then `@samchon/graph-sitter`.", + "mode": "resident-no-op-reuse; invalidated-closure shard deltas; validated restart checkpoints", + "nativeAnalysis": "The pinned rust-analyzer fork owns one resident HIR database and exports declarations, semantic relationships, diagnostics, coverage and unresolved boundaries from that exact analysis revision.", + "exportMerge": "Content-addressed source shards cross a versioned LSP transaction; the client verifies producer identity, universe, complete manifests, shard digests and graph invariants before atomic publication.", + "reuseResident": "No-op requests reuse the resident snapshot; interface changes invalidate dependent shards, while a complete consumer checkpoint restores the same generation after process restart.", + "limitations": "The producer is currently available from the draft fork PR rather than a rust-analyzer release, and Rust has no `renders` relationship family.", + "decline": "A missing pinned producer, incompatible commit/schema, malformed transaction, invalid checkpoint, unsupported bounded option or failed Cargo workspace load declines this route.", + "fallback": "Stock `rust-analyzer-scip`, then generic rust-analyzer, then `@samchon/graph-sitter`.", "experimentLanguages": ["rust"], - "experimentTool": "rust-analyzer", - "experimentCapabilities": ["universe", "diskDigests"], + "experimentTool": "samchon-rust-analyzer", + "experimentCapabilities": ["coverage", "diagnostics", "incremental", "sourceDigests", "universe", "unresolved", "validatedConsumerCheckpoint"], + "benchmarkProvider": "rust-analyzer-scip", "benchmarks": [{"project": "tokio", "strictMs": 55238.18003, "fallbackMs": 229860.9964}], - "upstream": "https://github.com/rust-lang/rust-analyzer", + "upstream": "https://github.com/samchon/rust-analyzer", "childIssues": ["https://github.com/samchon/compiler-graph/issues/72"] }, { diff --git a/packages/graph/build/provider-support.mjs b/packages/graph/build/provider-support.mjs index 81494e84..8af2d3a9 100644 --- a/packages/graph/build/provider-support.mjs +++ b/packages/graph/build/provider-support.mjs @@ -317,6 +317,14 @@ function validateManifest( documented.benchmarks.length > 0, `${documented.provider} must name benchmark evidence`, ); + if (documented.benchmarkProvider !== undefined) { + invariant( + typeof documented.benchmarkProvider === "string" && + documented.benchmarkProvider.trim() !== "" && + documented.benchmarkProvider !== documented.provider, + `${documented.provider} benchmark provider must name a different non-empty producer`, + ); + } for (const row of documented.benchmarks) { invariant( typeof row.project === "string" && row.project !== "", @@ -328,6 +336,7 @@ function validateManifest( ); benchmarkRows.set(row.project, { provider: documented.provider, + benchmarkProvider: documented.benchmarkProvider, row, }); } @@ -395,8 +404,10 @@ function validateManifest( `${project} benchmark cells must come from one paired measurement`, ); invariant( - strict[0].servedBy.includes(documented.provider), - `${project} strict cell does not name ${documented.provider}`, + strict[0].servedBy.includes( + documented.benchmarkProvider ?? documented.provider, + ), + `${project} strict cell does not name ${documented.benchmarkProvider ?? documented.provider}`, ); if ( Object.hasOwn(documented.row, "strictTimedOutMs") || @@ -466,7 +477,9 @@ function renderSupport(manifest) { const benchmarkRows = manifest.providers.flatMap((provider) => provider.benchmarks.map((benchmark) => [ code(benchmark.project), - code(provider.provider), + provider.benchmarkProvider === undefined + ? code(provider.provider) + : `${code(provider.benchmarkProvider)} (prior fallback evidence; ${code(provider.provider)} not yet measured)`, Object.hasOwn(benchmark, "strictTimedOutMs") ? `did not finish before ${seconds(benchmark.strictTimedOutMs)} s` : milliseconds(benchmark.strictMs), diff --git a/packages/graph/src/indexer/buildLspGraph.ts b/packages/graph/src/indexer/buildLspGraph.ts index 194f3f66..9f5dd9b2 100644 --- a/packages/graph/src/indexer/buildLspGraph.ts +++ b/packages/graph/src/indexer/buildLspGraph.ts @@ -172,95 +172,100 @@ async function buildLspGraphAttempt( // empty log three times over: no provider named, no reason recorded, and no // way to tell a slow strict indexer from a slow fallback. announceProviderSelection(selection.candidates, selection.warnings); - for (const candidate of selection.candidates) { - try { - const { refresh, session } = - await resolvedDependencies.collectProviderGraph( - root, - candidate, - options, - ); - const snapshot = refresh.snapshot; + for (const selectedCandidate of selection.candidates) { + const attempts = [selectedCandidate, ...selectedCandidate.fallbacks]; + for (const [routeIndex, candidate] of attempts.entries()) { try { - assertGraphSnapshotContract( - snapshot, - candidate.provider, - candidate.languages, - root, - ); - // Closing a one-shot session is part of accepting its candidate. A - // close failure declines it before its manifest or facts can enter - // the aggregate. Resident candidates stay live only after the same - // collision gate admits their source evidence. - if (!options.keepAlive) await session.close(); - mergeProviderSourceDigests(strictDigests, snapshot.sources); - } catch (error) { - // `collectProviderGraph` has handed this live session to the - // coordinator, but a rejected snapshot never enters `sessions`. - // Close it here: otherwise a resident build falls through to the - // generic lane while the invalid provider's child remains orphaned. + const { refresh, session } = + await resolvedDependencies.collectProviderGraph( + root, + candidate, + options, + ); + const snapshot = refresh.snapshot; try { - await session.close(); - } catch (closeError) { - throw new AggregateError( - [error, closeError], - "@samchon/graph: strict provider snapshot was refused and its unpublished session could not close", + assertGraphSnapshotContract( + snapshot, + candidate.provider, + candidate.languages, + root, ); + // Closing a one-shot session is part of accepting its candidate. A + // close failure declines it before its manifest or facts can enter + // the aggregate. Resident candidates stay live only after the same + // collision gate admits their source evidence. + if (!options.keepAlive) await session.close(); + mergeProviderSourceDigests(strictDigests, snapshot.sources); + } catch (error) { + // `collectProviderGraph` has handed this live session to the + // coordinator, but a rejected snapshot never enters `sessions`. + // Close it here: otherwise a resident build falls through to the + // generic lane while the invalid provider's child remains orphaned. + try { + await session.close(); + } catch (closeError) { + throw new AggregateError( + [error, closeError], + "@samchon/graph: strict provider snapshot was refused and its unpublished session could not close", + ); + } + throw error; } - throw error; - } - appendAll(strictNodes, snapshot.nodes); - appendAll(strictEdges, snapshot.edges); - appendAll(diagnostics, snapshot.diagnostics); - appendAll(coverage, graphCoverageOf(snapshot)); - appendAll(unresolved, graphUnresolvedOf(snapshot)); - appendAll(warnings, snapshot.warnings); - // The manifest names the files, and the provider owns the fact that it - // does. Nothing reads their text here: the strict lane's facts are - // already resolved, and the only thing the generic lane wanted text for - // — deriving export edges — is work this provider has already done - // against the real checker. - provenance.push(dumpProvenanceOf(snapshot)); - modes.set(candidate.provider.name, refresh.mode); - // A complete strict slice can legitimately contain no declarations. - // The provider still answered for its languages, with provenance, - // diagnostics, and an exact manifest. Counting nodes as proof that it - // answered relabelled that valid empty slice as static fallback and - // let a later resident generation change lane authority underneath the - // same kept session. - semanticSliceCount += 1; - // A candidate may own more languages than its snapshot published — a - // Clang provider asked for C and C++ can answer with only the - // translation units it found. Whatever it did not publish falls to the - // generic lane, and that has to be said: a caller who selected a - // compiler-owned provider for C would otherwise be handed navigation - // facts for it with nothing to distinguish them. - const published = new Set(snapshot.languages); - const unpublished = candidate.languages.filter( - (language) => !published.has(language), - ); - if (unpublished.length > 0) { - warnings.push( - `${unpublished.join(", ")}: the ${candidate.provider.name} ${candidate.provider.authority} provider owns these languages but published no slice for them, so they fall through to the generic language-server lane.`, + appendAll(strictNodes, snapshot.nodes); + appendAll(strictEdges, snapshot.edges); + appendAll(diagnostics, snapshot.diagnostics); + appendAll(coverage, graphCoverageOf(snapshot)); + appendAll(unresolved, graphUnresolvedOf(snapshot)); + appendAll(warnings, snapshot.warnings); + // The manifest names the files, and the provider owns the fact that it + // does. Nothing reads their text here: the strict lane's facts are + // already resolved, and the only thing the generic lane wanted text for + // — deriving export edges — is work this provider has already done + // against the real checker. + provenance.push(dumpProvenanceOf(snapshot)); + modes.set(candidate.provider.name, refresh.mode); + // A complete strict slice can legitimately contain no declarations. + // The provider still answered for its languages, with provenance, + // diagnostics, and an exact manifest. Counting nodes as proof that it + // answered relabelled that valid empty slice as static fallback and + // let a later resident generation change lane authority underneath the + // same kept session. + semanticSliceCount += 1; + // A candidate may own more languages than its snapshot published — a + // Clang provider asked for C and C++ can answer with only the + // translation units it found. Whatever it did not publish falls to the + // generic lane, and that has to be said: a caller who selected a + // compiler-owned provider for C would otherwise be handed navigation + // facts for it with nothing to distinguish them. + const published = new Set(snapshot.languages); + const unpublished = candidate.languages.filter( + (language) => !published.has(language), ); - } - for (const language of snapshot.languages) { - strictLanguages.add(language); - servedLanguages.add(language); - // A multi-language provider is one session under several keys. The - // map stays keyed by language because every consumer asks it a - // language question; deduplication is the consumers' job and they do - // it by session identity, not by key. - if (options.keepAlive) { - sessions.set(language, session); - providers.set(language, candidate.provider); + if (unpublished.length > 0) { + warnings.push( + `${unpublished.join(", ")}: the ${candidate.provider.name} ${candidate.provider.authority} provider owns these languages but published no slice for them, so they fall through to the generic language-server lane.`, + ); } + for (const language of snapshot.languages) { + strictLanguages.add(language); + servedLanguages.add(language); + // A multi-language provider is one session under several keys. The + // map stays keyed by language because every consumer asks it a + // language question; deduplication is the consumers' job and they do + // it by session identity, not by key. + if (options.keepAlive) { + sessions.set(language, session); + providers.set(language, candidate.provider); + } + } + break; + } catch (error) { + if (options.signal?.aborted) throw error; + const next = attempts[routeIndex + 1]; + warnings.push( + `${candidate.languages.join(", ")}: the ${candidate.provider.name} ${candidate.provider.authority} provider failed, so these languages fall through to ${next === undefined ? "the generic language-server lane" : `the ${next.provider.name} ${next.provider.authority} provider`}: ${(error as Error).message}`, + ); } - } catch (error) { - if (options.signal?.aborted) throw error; - warnings.push( - `${candidate.languages.join(", ")}: the ${candidate.provider.name} ${candidate.provider.authority} provider failed, so these languages fall through to the generic language-server lane: ${(error as Error).message}`, - ); } } @@ -497,7 +502,7 @@ async function closeKeptSessions( */ async function collectProviderGraph( root: string, - candidate: selectGraphProviders.ICandidate, + candidate: selectGraphProviders.IRouteCandidate, options: IBuildGraphOptions, ): Promise<{ refresh: IBulkGraphSession.IRefresh; @@ -539,7 +544,7 @@ async function collectProviderGraph( /** A provider may not widen or move the candidate the registry selected. */ function assertBulkSessionContract( root: string, - candidate: selectGraphProviders.ICandidate, + candidate: selectGraphProviders.IRouteCandidate, session: IBulkGraphSession, ): void { const label = `@samchon/graph: provider "${candidate.provider.name}"`; diff --git a/packages/graph/src/lsp/LspClient.ts b/packages/graph/src/lsp/LspClient.ts index c6e71962..b788eee3 100644 --- a/packages/graph/src/lsp/LspClient.ts +++ b/packages/graph/src/lsp/LspClient.ts @@ -2,6 +2,7 @@ import { ChildProcessWithoutNullStreams, spawn } from "node:child_process"; import { EventEmitter } from "node:events"; import { ownedProcess } from "../utils/ownedProcess"; +import { LspResponseError } from "./LspResponseError"; const SHUTDOWN_GRACE_MS = 1_000; const DEFAULT_MAX_MESSAGE_BYTES = 256 * 1024 * 1024; @@ -36,6 +37,7 @@ export class LspClient { maxMessageBytes = DEFAULT_MAX_MESSAGE_BYTES, windowsVerbatimArguments?: boolean, private readonly requestObserver?: LspClient.IRequestObserver, + private readonly serverRequestHandler?: LspClient.IServerRequestHandler, ) { if (!Number.isSafeInteger(maxMessageBytes) || maxMessageBytes < 1) { throw new TypeError( @@ -291,7 +293,7 @@ export class LspClient { method?: string; params?: unknown; result?: unknown; - error?: { message?: string }; + error?: { code?: number; message?: string; data?: unknown }; }; } catch { continue; @@ -305,14 +307,29 @@ export class LspClient { method?: string; params?: unknown; result?: unknown; - error?: { message?: string }; + error?: { code?: number; message?: string; data?: unknown }; }): void { // A server-initiated request carries both an id and a method. It must be // answered or some servers block: gopls, for instance, withholds // documentSymbol until its `window/workDoneProgress/create` request is // acknowledged. A null result satisfies the acknowledgements we advertise. if (message.id !== undefined && message.method !== undefined) { - this.write({ jsonrpc: "2.0", id: message.id, result: null }); + if (this.serverRequestHandler === undefined) { + this.write({ jsonrpc: "2.0", id: message.id, result: null }); + return; + } + void Promise.resolve() + .then(() => this.serverRequestHandler!(message.method!, message.params)) + .then((result) => + this.write({ jsonrpc: "2.0", id: message.id, result: result ?? null }), + ) + .catch((error: unknown) => + this.write({ + jsonrpc: "2.0", + id: message.id, + error: { code: -32603, message: asError(error).message }, + }), + ); return; } if (message.id !== undefined) { @@ -321,7 +338,11 @@ export class LspClient { this.deletePending(message.id, pending); if (message.error !== undefined) { pending.reject( - new Error(message.error.message ?? "LSP request failed."), + new LspResponseError( + message.error.code ?? -32603, + message.error.message ?? "LSP request failed.", + message.error.data, + ), ); } else { pending.resolve(message.result); @@ -388,6 +409,10 @@ export class LspClient { export namespace LspClient { export type IRequestObserver = (event: IRequestTrace) => void; + export type IServerRequestHandler = ( + method: string, + params: unknown, + ) => unknown; export type IRequestTrace = | { diff --git a/packages/graph/src/lsp/LspResponseError.ts b/packages/graph/src/lsp/LspResponseError.ts new file mode 100644 index 00000000..cda49bfd --- /dev/null +++ b/packages/graph/src/lsp/LspResponseError.ts @@ -0,0 +1,11 @@ +export class LspResponseError extends Error { + public readonly name = "LspResponseError"; + + public constructor( + public readonly code: number, + message: string, + public readonly data?: unknown, + ) { + super(message); + } +} diff --git a/packages/graph/src/lsp/index.ts b/packages/graph/src/lsp/index.ts index d5f3ea19..8c149d1b 100644 --- a/packages/graph/src/lsp/index.ts +++ b/packages/graph/src/lsp/index.ts @@ -7,3 +7,4 @@ export * from "./IRange"; export * from "./ISymbolInformation"; export * from "./isDocumentSymbol"; export * from "./LspClient"; +export * from "./LspResponseError"; diff --git a/packages/graph/src/provider/GRAPH_PROVIDERS.ts b/packages/graph/src/provider/GRAPH_PROVIDERS.ts index 54c0dcf2..534a8081 100644 --- a/packages/graph/src/provider/GRAPH_PROVIDERS.ts +++ b/packages/graph/src/provider/GRAPH_PROVIDERS.ts @@ -1,7 +1,7 @@ import { IGraphProvider } from "./IGraphProvider"; import { goGraphProvider } from "./go/goGraphProvider"; import { luaGraphProvider } from "./lua/luaGraphProvider"; -import { rustScipProvider } from "./rust/rustScipProvider"; +import { rustGraphProvider } from "./rust/rustGraphProvider"; import { standardScipProviders } from "./scip/standardScipProviders"; import { standardSidecarProviders } from "./sidecar/standardSidecarProviders"; import { ttscGraphProvider } from "./ttscgraph/ttscGraphProvider"; @@ -25,7 +25,7 @@ export const GRAPH_PROVIDERS: readonly IGraphProvider[] = [ ttscGraphProvider, goGraphProvider, luaGraphProvider, - rustScipProvider, + rustGraphProvider, ...standardScipProviders, ...standardSidecarProviders, ]; diff --git a/packages/graph/src/provider/IGraphProvider.ts b/packages/graph/src/provider/IGraphProvider.ts index e2a1b93a..ada62ed0 100644 --- a/packages/graph/src/provider/IGraphProvider.ts +++ b/packages/graph/src/provider/IGraphProvider.ts @@ -78,6 +78,15 @@ export interface IGraphProvider { */ readonly resolution?: IGraphProvider.IResolution; + /** + * Ordered compatibility routes for the same atomic language slice. + * + * These are not additional language owners. The registry still has one + * owner, while selection and the runtime coordinator may step down through + * these routes when a more authoritative producer is absent or fails. + */ + readonly fallbacks?: readonly IGraphProvider[]; + /** * Why this provider cannot serve a build with these options, or `undefined` * when it can. diff --git a/packages/graph/src/provider/rust/IRustGraphCacheState.ts b/packages/graph/src/provider/rust/IRustGraphCacheState.ts new file mode 100644 index 00000000..31ded2df --- /dev/null +++ b/packages/graph/src/provider/rust/IRustGraphCacheState.ts @@ -0,0 +1,11 @@ +import type { GraphSnapshotProtocol } from "../GraphSnapshotProtocol"; +import type { IRustGraphCheckpoint } from "./IRustGraphCheckpoint"; +import type { IRustGraphShard } from "./IRustGraphShard"; + +export interface IRustGraphCacheState { + version: 1; + producerCommit: string; + checkpoint: IRustGraphCheckpoint; + rawShards: IRustGraphShard[]; + frames: GraphSnapshotProtocol.Frame[]; +} diff --git a/packages/graph/src/provider/rust/IRustGraphCheckpoint.ts b/packages/graph/src/provider/rust/IRustGraphCheckpoint.ts new file mode 100644 index 00000000..fa9abc26 --- /dev/null +++ b/packages/graph/src/provider/rust/IRustGraphCheckpoint.ts @@ -0,0 +1,15 @@ +import type { IRustGraphCheckpointSource } from "./IRustGraphCheckpointSource"; +import type { IRustGraphManifestEntry } from "./IRustGraphManifestEntry"; +import type { IRustGraphProducer } from "./IRustGraphProducer"; +import type { IRustGraphShard } from "./IRustGraphShard"; + +export interface IRustGraphCheckpoint { + protocolVersion: number; + schemaVersion: number; + producer: IRustGraphProducer; + universe: string; + generation: string; + manifest: IRustGraphManifestEntry[]; + sources: IRustGraphCheckpointSource[]; + shards: IRustGraphShard[]; +} diff --git a/packages/graph/src/provider/rust/IRustGraphCheckpointSource.ts b/packages/graph/src/provider/rust/IRustGraphCheckpointSource.ts new file mode 100644 index 00000000..daeafad7 --- /dev/null +++ b/packages/graph/src/provider/rust/IRustGraphCheckpointSource.ts @@ -0,0 +1,4 @@ +export interface IRustGraphCheckpointSource { + source: string; + checkerDigest: string; +} diff --git a/packages/graph/src/provider/rust/IRustGraphCoverage.ts b/packages/graph/src/provider/rust/IRustGraphCoverage.ts new file mode 100644 index 00000000..6166e880 --- /dev/null +++ b/packages/graph/src/provider/rust/IRustGraphCoverage.ts @@ -0,0 +1,4 @@ +export interface IRustGraphCoverage { + family: string; + state: string; +} diff --git a/packages/graph/src/provider/rust/IRustGraphDiagnostic.ts b/packages/graph/src/provider/rust/IRustGraphDiagnostic.ts new file mode 100644 index 00000000..b3a09970 --- /dev/null +++ b/packages/graph/src/provider/rust/IRustGraphDiagnostic.ts @@ -0,0 +1,8 @@ +export interface IRustGraphDiagnostic { + file: string; + line: number; + column: number | null; + code: string; + message: string; + severity: string | null; +} diff --git a/packages/graph/src/provider/rust/IRustGraphEdge.ts b/packages/graph/src/provider/rust/IRustGraphEdge.ts new file mode 100644 index 00000000..8a33893a --- /dev/null +++ b/packages/graph/src/provider/rust/IRustGraphEdge.ts @@ -0,0 +1,8 @@ +import type { IRustGraphEvidence } from "./IRustGraphEvidence"; + +export interface IRustGraphEdge { + from: string; + to: string; + kind: string; + evidence: IRustGraphEvidence | null; +} diff --git a/packages/graph/src/provider/rust/IRustGraphEvidence.ts b/packages/graph/src/provider/rust/IRustGraphEvidence.ts new file mode 100644 index 00000000..c71e7e3c --- /dev/null +++ b/packages/graph/src/provider/rust/IRustGraphEvidence.ts @@ -0,0 +1,7 @@ +export interface IRustGraphEvidence { + file: string; + startLine: number; + startColumn: number; + endLine: number; + endColumn: number; +} diff --git a/packages/graph/src/provider/rust/IRustGraphManifestEntry.ts b/packages/graph/src/provider/rust/IRustGraphManifestEntry.ts new file mode 100644 index 00000000..53999a19 --- /dev/null +++ b/packages/graph/src/provider/rust/IRustGraphManifestEntry.ts @@ -0,0 +1,4 @@ +export interface IRustGraphManifestEntry { + key: string; + digest: string; +} diff --git a/packages/graph/src/provider/rust/IRustGraphNode.ts b/packages/graph/src/provider/rust/IRustGraphNode.ts new file mode 100644 index 00000000..23fd898b --- /dev/null +++ b/packages/graph/src/provider/rust/IRustGraphNode.ts @@ -0,0 +1,13 @@ +import type { IRustGraphEvidence } from "./IRustGraphEvidence"; + +export interface IRustGraphNode { + id: string; + kind: string; + name: string; + qualifiedName: string | null; + file: string; + external: boolean; + exported: boolean; + signature: string | null; + evidence: IRustGraphEvidence | null; +} diff --git a/packages/graph/src/provider/rust/IRustGraphPhases.ts b/packages/graph/src/provider/rust/IRustGraphPhases.ts new file mode 100644 index 00000000..d09514ea --- /dev/null +++ b/packages/graph/src/provider/rust/IRustGraphPhases.ts @@ -0,0 +1,7 @@ +export interface IRustGraphPhases { + semanticMillis: number; + shardMillis: number; + encodeMillis: number; + totalMillis: number; + cacheHit: boolean; +} diff --git a/packages/graph/src/provider/rust/IRustGraphProducer.ts b/packages/graph/src/provider/rust/IRustGraphProducer.ts new file mode 100644 index 00000000..26e938d1 --- /dev/null +++ b/packages/graph/src/provider/rust/IRustGraphProducer.ts @@ -0,0 +1,5 @@ +export interface IRustGraphProducer { + name: string; + version: string; + commit: string; +} diff --git a/packages/graph/src/provider/rust/IRustGraphShard.ts b/packages/graph/src/provider/rust/IRustGraphShard.ts new file mode 100644 index 00000000..c73e9c9d --- /dev/null +++ b/packages/graph/src/provider/rust/IRustGraphShard.ts @@ -0,0 +1,27 @@ +import type { IRustGraphCoverage } from "./IRustGraphCoverage"; +import type { IRustGraphDiagnostic } from "./IRustGraphDiagnostic"; +import type { IRustGraphEdge } from "./IRustGraphEdge"; +import type { IRustGraphEvidence } from "./IRustGraphEvidence"; +import type { IRustGraphNode } from "./IRustGraphNode"; + +export interface IRustGraphShard { + key: string; + source: string; + checkerDigest: string; + interfaceFingerprint: string; + digest: string; + nodes: IRustGraphNode[]; + edges: IRustGraphEdge[]; + diagnostics: IRustGraphDiagnostic[]; + coverage: IRustGraphCoverage[]; + unresolved: IRustGraphShard.Unresolved[]; +} + +export declare namespace IRustGraphShard { + export interface Unresolved { + family: string; + evidence: IRustGraphEvidence; + reason: string; + candidates: string[]; + } +} diff --git a/packages/graph/src/provider/rust/IRustGraphSnapshot.ts b/packages/graph/src/provider/rust/IRustGraphSnapshot.ts new file mode 100644 index 00000000..1ca58ef6 --- /dev/null +++ b/packages/graph/src/provider/rust/IRustGraphSnapshot.ts @@ -0,0 +1,19 @@ +import type { IRustGraphManifestEntry } from "./IRustGraphManifestEntry"; +import type { IRustGraphPhases } from "./IRustGraphPhases"; +import type { IRustGraphProducer } from "./IRustGraphProducer"; +import type { IRustGraphShard } from "./IRustGraphShard"; +import type { IRustGraphUniverse } from "./IRustGraphUniverse"; + +export interface IRustGraphSnapshot { + protocolVersion: number; + schemaVersion: number; + producer: IRustGraphProducer; + universe: IRustGraphUniverse; + sequence: number; + generation: string; + baseGeneration: string | null; + upserts: IRustGraphShard[]; + deletes: string[]; + manifest: IRustGraphManifestEntry[]; + phases: IRustGraphPhases; +} diff --git a/packages/graph/src/provider/rust/IRustGraphSnapshotParams.ts b/packages/graph/src/provider/rust/IRustGraphSnapshotParams.ts new file mode 100644 index 00000000..b55901ff --- /dev/null +++ b/packages/graph/src/provider/rust/IRustGraphSnapshotParams.ts @@ -0,0 +1,6 @@ +import type { IRustGraphCheckpoint } from "./IRustGraphCheckpoint"; + +export interface IRustGraphSnapshotParams { + knownGeneration?: string; + checkpoint?: IRustGraphCheckpoint; +} diff --git a/packages/graph/src/provider/rust/IRustGraphUniverse.ts b/packages/graph/src/provider/rust/IRustGraphUniverse.ts new file mode 100644 index 00000000..c7506abc --- /dev/null +++ b/packages/graph/src/provider/rust/IRustGraphUniverse.ts @@ -0,0 +1,7 @@ +export interface IRustGraphUniverse { + digest: string; + target: string; + workspaceRoots: string[]; + toolchains: string[]; + configurations: string[]; +} diff --git a/packages/graph/src/provider/rust/RUST_GRAPH_PRODUCER_COMMIT.ts b/packages/graph/src/provider/rust/RUST_GRAPH_PRODUCER_COMMIT.ts new file mode 100644 index 00000000..b1dddbe9 --- /dev/null +++ b/packages/graph/src/provider/rust/RUST_GRAPH_PRODUCER_COMMIT.ts @@ -0,0 +1,2 @@ +export const RUST_GRAPH_PRODUCER_COMMIT = + "3e8db3829e471b6af9acd1f14052e641fb81c4fd"; diff --git a/packages/graph/src/provider/rust/RUST_HIR_FACTS.ts b/packages/graph/src/provider/rust/RUST_HIR_FACTS.ts new file mode 100644 index 00000000..1fd5f202 --- /dev/null +++ b/packages/graph/src/provider/rust/RUST_HIR_FACTS.ts @@ -0,0 +1,5 @@ +import { GRAPH_EDGE_KINDS, GraphEdgeKind } from "../../typings"; + +export const RUST_HIR_FACTS: readonly GraphEdgeKind[] = GRAPH_EDGE_KINDS.filter( + (kind) => kind !== "renders", +); diff --git a/packages/graph/src/provider/rust/RUST_HIR_PRODUCER.ts b/packages/graph/src/provider/rust/RUST_HIR_PRODUCER.ts new file mode 100644 index 00000000..85443d87 --- /dev/null +++ b/packages/graph/src/provider/rust/RUST_HIR_PRODUCER.ts @@ -0,0 +1 @@ +export const RUST_HIR_PRODUCER = "samchon-rust-analyzer"; diff --git a/packages/graph/src/provider/rust/RUST_HIR_PROVIDER.ts b/packages/graph/src/provider/rust/RUST_HIR_PROVIDER.ts new file mode 100644 index 00000000..1e6ec327 --- /dev/null +++ b/packages/graph/src/provider/rust/RUST_HIR_PROVIDER.ts @@ -0,0 +1 @@ +export const RUST_HIR_PROVIDER = "samchon-rust-analyzer-hir"; diff --git a/packages/graph/src/provider/rust/RustGraphCache.ts b/packages/graph/src/provider/rust/RustGraphCache.ts new file mode 100644 index 00000000..f8ba9c37 --- /dev/null +++ b/packages/graph/src/provider/rust/RustGraphCache.ts @@ -0,0 +1,195 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { GraphSnapshotProtocol } from "../GraphSnapshotProtocol"; +import { IRustGraphCacheState } from "./IRustGraphCacheState"; + +const CACHE_VERSION = 1; +const MAX_CACHE_BYTES = 512 * 1024 * 1024; +const RETAINED_GENERATIONS = 2; +const GENERATION = /^[a-f0-9]{64}$/u; + +export namespace RustGraphCache { + export function load( + props: IProps, + accept: (state: IRustGraphCacheState) => boolean = () => true, + ): IRustGraphCacheState | undefined { + const directory = projectDirectory(props); + let files: string[]; + try { + files = fs + .readdirSync(directory) + .filter((file) => /^\d+-[a-f0-9]{64}\.json$/u.test(file)) + .sort((left, right) => sequenceOf(right) - sequenceOf(left)); + } catch { + return undefined; + } + for (const file of files) { + try { + const coordinates = coordinatesOf(file); + if (coordinates === undefined) continue; + const absolute = path.join(directory, file); + const size = fs.statSync(absolute).size; + if (size < 1 || size > MAX_CACHE_BYTES) continue; + const parsed = JSON.parse(fs.readFileSync(absolute, "utf8")) as IRustGraphCacheState; + if ( + parsed.version === CACHE_VERSION && + parsed.producerCommit === props.producerCommit && + Array.isArray(parsed.frames) && + Array.isArray(parsed.rawShards) && + parsed.checkpoint !== null && + typeof parsed.checkpoint === "object" && + parsed.checkpoint.generation === coordinates.generation && + isMatchingCommitFrame( + parsed.frames.at(-1), + coordinates.sequence, + coordinates.generation, + ) + ) { + if (accept(parsed)) return parsed; + } + } catch { + // A torn or obsolete cache generation is not evidence. Try the prior + // immutable generation and let the live producer validate any winner. + } + } + return undefined; + } + + export function save( + props: IProps, + sequence: number, + generation: string, + state: IRustGraphCacheState, + ): void { + if ( + !Number.isSafeInteger(sequence) || + sequence < 1 || + !GENERATION.test(generation) || + state.checkpoint.generation !== generation || + !isMatchingCommitFrame(state.frames.at(-1), sequence, generation) + ) { + throw new Error("rust HIR graph: invalid persisted generation coordinates"); + } + const encoded = JSON.stringify(state); + /* c8 ignore start -- exercising the hard 512 MiB corruption guard would + * allocate a fixture larger than the test process's bounded heap. */ + if (Buffer.byteLength(encoded, "utf8") > MAX_CACHE_BYTES) { + throw new Error("rust HIR graph: persisted generation exceeds the cache size limit"); + } + /* c8 ignore stop */ + const directory = projectDirectory(props); + fs.mkdirSync(directory, { recursive: true }); + const file = path.join(directory, `${String(sequence)}-${generation}.json`); + if (!fs.existsSync(file)) { + const temporary = path.join( + directory, + `.${String(process.pid)}-${String(sequence)}-${generation}.tmp`, + ); + fs.writeFileSync(temporary, encoded, { + encoding: "utf8", + flag: "wx", + }); + try { + fs.renameSync(temporary, file); + } catch (error) { + if (!fs.existsSync(file)) throw error; + fs.rmSync(temporary); + } + } + const obsolete = fs + .readdirSync(directory) + .filter((entry) => /^\d+-[a-f0-9]{64}\.json$/u.test(entry)) + .sort((left, right) => sequenceOf(right) - sequenceOf(left)) + .slice(RETAINED_GENERATIONS); + for (const entry of obsolete) fs.rmSync(path.join(directory, entry)); + } + + export function clear(props: IProps): void { + const directory = projectDirectory(props); + let entries: string[]; + try { + entries = fs.readdirSync(directory); + } catch { + return; + } + for (const entry of entries) { + if ( + /^\d+-[a-f0-9]{64}\.json$/u.test(entry) || + /^\.\d+-\d+-[a-f0-9]{64}\.tmp$/u.test(entry) + ) { + fs.rmSync(path.join(directory, entry)); + } + } + } + + export interface IProps { + root: string; + producerCommit: string; + cacheRoot?: string; + } +} + +function projectDirectory(props: RustGraphCache.IProps): string { + const root = path.resolve(props.root); + /* c8 ignore start -- coverage runs on one host platform; Windows folds the + * cache identity and POSIX preserves it. */ + const cacheIdentity = process.platform === "win32" ? root.toLowerCase() : root; + /* c8 ignore stop */ + const key = createHash("sha256") + .update(cacheIdentity) + .digest("hex"); + return path.join( + props.cacheRoot ?? defaultCacheRoot(), + "rust", + props.producerCommit, + key, + ); +} + +function defaultCacheRoot(): string { + const configured = process.env.SAMCHON_GRAPH_CACHE_DIR; + if (configured !== undefined && path.isAbsolute(configured)) return configured; + if (process.platform === "win32") { + const local = process.env.LOCALAPPDATA; + if (local !== undefined && path.isAbsolute(local)) { + return path.join(local, "samchon-graph"); + } + } + const xdg = process.env.XDG_CACHE_HOME; + if (xdg !== undefined && path.isAbsolute(xdg)) { + return path.join(xdg, "samchon-graph"); + } + return path.join(os.homedir(), ".cache", "samchon-graph"); +} + +function sequenceOf(file: string): number { + return Number(file.slice(0, file.indexOf("-"))); +} + +function coordinatesOf( + file: string, +): { sequence: number; generation: string } | undefined { + const separator = file.indexOf("-"); + const sequence = Number(file.slice(0, separator)); + const generation = file.slice(separator + 1, -".json".length); + return Number.isSafeInteger(sequence) && sequence >= 1 && GENERATION.test(generation) + ? { sequence, generation } + : undefined; +} + +function isMatchingCommitFrame( + value: unknown, + sequence: number, + generation: string, +): boolean { + if (value === null || typeof value !== "object") return false; + const frame = value as { type?: unknown; sequence?: unknown; generation?: unknown }; + return ( + frame.type === "commit" && + frame.sequence === sequence && + frame.generation === generation + ); +} diff --git a/packages/graph/src/provider/rust/RustGraphClient.ts b/packages/graph/src/provider/rust/RustGraphClient.ts new file mode 100644 index 00000000..04b81928 --- /dev/null +++ b/packages/graph/src/provider/rust/RustGraphClient.ts @@ -0,0 +1,385 @@ +import { pathToFileURL } from "node:url"; + +import { GraphLanguage } from "../../typings"; +import { LspClient } from "../../lsp/LspClient"; +import { LspResponseError } from "../../lsp/LspResponseError"; +import { GraphSnapshotProtocol } from "../GraphSnapshotProtocol"; +import { IBulkGraphSession } from "../IBulkGraphSession"; +import { IRustGraphSnapshot } from "./IRustGraphSnapshot"; +import { IRustGraphSnapshotParams } from "./IRustGraphSnapshotParams"; +import { RustGraphCache } from "./RustGraphCache"; +import { RustGraphSnapshotAdapter } from "./RustGraphSnapshotAdapter"; + +const GRAPH_METHOD = "samchon/graphSnapshot"; +const SERVER_CANCELLED = -32802; +const CONTENT_MODIFIED = -32801; +const DEFAULT_READY_TIMEOUT_MS = 300_000; +const RETRY_DELAY_MS = 50; + +/** Resident LSP client for the pinned HIR graphSnapshot producer. */ +export class RustGraphClient implements IBulkGraphSession { + public readonly kind = "bulk" as const; + public readonly languages: readonly GraphLanguage[] = ["rust"]; + public readonly root: string; + + private readonly lsp: LspClient; + private adapter: RustGraphSnapshotAdapter; + private readonly cache: RustGraphCache.IProps; + private readonly validate: ( + snapshot: IBulkGraphSession.ISnapshot, + ) => void; + private readonly initializationOptions: unknown; + private readonly requestTimeoutMs: number | undefined; + private readonly readyTimeoutMs: number; + private readonly lifecycleAbort = new AbortController(); + private queue: Promise = Promise.resolve(); + private initialized: Promise | undefined; + private checkpointPending = false; + private version: number; + private closed = false; + private closing: Promise | undefined; + + public constructor(options: RustGraphClient.IOptions) { + this.root = options.root; + this.validate = options.validate ?? (() => undefined); + this.cache = { + root: options.root, + producerCommit: options.producerCommit, + ...(options.cacheRoot === undefined + ? {} + : { cacheRoot: options.cacheRoot }), + }; + let restored: RustGraphSnapshotAdapter | undefined; + const cached = RustGraphCache.load(this.cache, (state) => { + const candidate = new RustGraphSnapshotAdapter( + options.root, + options.producerCommit, + state, + ); + if (candidate.store.current !== undefined) { + this.validate(candidate.store.current); + } + restored = candidate; + return true; + }); + if (cached === undefined || restored === undefined) { + RustGraphCache.clear(this.cache); + this.adapter = new RustGraphSnapshotAdapter( + options.root, + options.producerCommit, + ); + } else { + this.adapter = restored; + } + this.checkpointPending = this.adapter.persistedCheckpoint !== undefined; + this.version = this.adapter.store.current?.protocol?.sequence ?? 0; + this.initializationOptions = options.initializationOptions; + this.requestTimeoutMs = options.requestTimeoutMs; + this.readyTimeoutMs = options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS; + /* c8 ignore start -- production native binaries need no arguments; the + * protocol fixture itself is a JavaScript file and therefore needs one. */ + const args = options.args ?? []; + /* c8 ignore stop */ + this.lsp = new LspClient( + options.command, + args, + options.requestTimeoutMs, + options.root, + options.maxMessageBytes, + options.windowsVerbatimArguments, + undefined, + serverRequest, + ); + } + + public get generation(): number { + return this.version; + } + + public get current(): IBulkGraphSession.ISnapshot | undefined { + return this.adapter.store.current; + } + + public refresh( + options: { signal?: AbortSignal } = {}, + ): Promise { + if (this.closed) { + return Promise.reject(new Error("rust HIR graph: session is closed")); + } + return this.enqueue(async () => { + const signal = combineSignals(options.signal, this.lifecycleAbort.signal); + this.assertOpen(); + await this.initialize(signal); + const raw = await this.requestSnapshot(signal); + const prepared = this.adapter.prepare(raw); + if (!prepared.changed) { + return { + changed: false, + generation: this.version, + mode: prepared.mode, + snapshot: prepared.snapshot, + }; + } + new GraphSnapshotProtocol.Store(this.root).apply(prepared.frames, { + signal, + validate: this.validate, + }); + const warnings: string[] = []; + try { + RustGraphCache.save( + this.cache, + prepared.sequence, + prepared.generation, + prepared.state, + ); + } catch (error) { + warnings.push( + `rust HIR graph: the validated snapshot is resident but its restart checkpoint could not be persisted: ${asError(error).message}`, + ); + } + const snapshot = this.adapter.store.apply(prepared.frames, { + signal, + validate: this.validate, + warnings, + }); + prepared.commit(snapshot); + this.version += 1; + return { + changed: true, + generation: this.version, + mode: prepared.mode, + snapshot, + }; + }, options.signal); + } + + public close(): Promise { + if (this.closing !== undefined) return this.closing; + this.closed = true; + this.lifecycleAbort.abort(new Error("rust HIR graph: session is closed")); + this.closing = this.lsp.close(); + return this.closing; + } + + private initialize(signal: AbortSignal): Promise { + this.initialized ??= this.initializeOnce(this.lifecycleAbort.signal); + return signal === this.lifecycleAbort.signal + ? this.initialized + : raceWithAbort(this.initialized, signal); + } + + private async initializeOnce(signal: AbortSignal): Promise { + await this.lsp.request( + "initialize", + { + processId: process.pid, + rootUri: pathToFileURL(this.root).href, + capabilities: { workspace: { configuration: true } }, + ...(this.initializationOptions === undefined + ? {} + : { initializationOptions: this.initializationOptions }), + workspaceFolders: [ + { + uri: pathToFileURL(this.root).href, + name: "samchon-graph-rust", + }, + ], + }, + this.requestTimeoutMs, + signal, + ); + this.lsp.notify("initialized", {}); + } + + private async requestSnapshot(signal: AbortSignal): Promise { + const deadline = performance.now() + this.readyTimeoutMs; + let checkpoint = this.checkpointPending + ? this.adapter.persistedCheckpoint + : undefined; + this.checkpointPending = false; + for (;;) { + throwIfAborted(signal); + const params: IRustGraphSnapshotParams = { + ...(this.adapter.persistedCheckpoint?.generation === undefined + ? {} + : { + knownGeneration: + this.adapter.persistedCheckpoint.generation, + }), + ...(checkpoint === undefined ? {} : { checkpoint }), + }; + try { + return await this.lsp.request( + GRAPH_METHOD, + params, + this.requestTimeoutMs, + signal, + ); + } catch (error) { + if ( + checkpoint !== undefined && + error instanceof LspResponseError && + error.code === SERVER_CANCELLED && + /checkpoint|persisted/iu.test(error.message) + ) { + this.adapter.discardPersistedSnapshot(); + RustGraphCache.clear(this.cache); + checkpoint = undefined; + continue; + } + if ( + !(error instanceof LspResponseError) || + (error.code !== SERVER_CANCELLED && error.code !== CONTENT_MODIFIED) + ) { + throw error; + } + if (performance.now() >= deadline) { + throw new Error( + `rust HIR graph: producer did not become ready within ${String(this.readyTimeoutMs)} ms: ${error.message}`, + ); + } + await delay(RETRY_DELAY_MS, signal); + } + } + } + + private assertOpen(): void { + if (this.closed) throw new Error("rust HIR graph: session is closed"); + } + + private enqueue( + task: () => Promise, + signal?: AbortSignal, + ): Promise { + let resolveResult!: (value: T) => void; + let rejectResult!: (error: Error) => void; + let started = false; + let settled = false; + const result = new Promise((resolve, reject) => { + resolveResult = (value) => { + settled = true; + resolve(value); + }; + rejectResult = (error) => { + settled = true; + reject(error); + }; + }); + const cancelQueued = (): void => { + if (!started) rejectResult(cancelledError(signal)); + }; + if (signal?.aborted) { + rejectResult(cancelledError(signal)); + return result; + } + signal?.addEventListener("abort", cancelQueued, { once: true }); + this.queue = this.queue + .catch(() => undefined) + .then(async () => { + started = true; + signal?.removeEventListener("abort", cancelQueued); + if (settled) return; + try { + resolveResult(await task()); + } catch (error) { + rejectResult(asError(error)); + } + }); + return result; + } +} + +export namespace RustGraphClient { + export interface IOptions { + root: string; + command: string; + args?: readonly string[]; + producerCommit: string; + initializationOptions?: unknown; + requestTimeoutMs?: number; + readyTimeoutMs?: number; + maxMessageBytes?: number; + windowsVerbatimArguments?: boolean; + cacheRoot?: string; + validate?: (snapshot: IBulkGraphSession.ISnapshot) => void; + } +} + +function serverRequest(method: string, params: unknown): unknown { + if (method !== "workspace/configuration") return null; + const items = (params as { items?: unknown })?.items; + return Array.isArray(items) ? items.map(() => null) : []; +} + +function delay(milliseconds: number, signal: AbortSignal): Promise { + /* c8 ignore start -- requestSnapshot checks this signal immediately before + * entering backoff; this closes only the intervening abort race. */ + if (signal.aborted) return Promise.reject(cancelledError(signal)); + /* c8 ignore stop */ + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal.removeEventListener("abort", abort); + resolve(undefined); + }, milliseconds); + timer.unref?.(); + const abort = (): void => { + clearTimeout(timer); + signal.removeEventListener("abort", abort); + reject(cancelledError(signal)); + }; + signal.addEventListener("abort", abort, { once: true }); + }); +} + +function combineSignals( + caller: AbortSignal | undefined, + lifecycle: AbortSignal, +): AbortSignal { + return caller === undefined ? lifecycle : AbortSignal.any([caller, lifecycle]); +} + +function raceWithAbort(task: Promise, signal: AbortSignal): Promise { + /* c8 ignore start -- enqueue rejects pre-aborted callers before a task can + * reach this initialization boundary. */ + if (signal.aborted) return Promise.reject(cancelledError(signal)); + /* c8 ignore stop */ + return new Promise((resolve, reject) => { + const abort = (): void => { + signal.removeEventListener("abort", abort); + reject(cancelledError(signal)); + }; + signal.addEventListener("abort", abort, { once: true }); + void task + .then((value) => { + signal.removeEventListener("abort", abort); + resolve(value); + }) + .catch((error: unknown) => { + signal.removeEventListener("abort", abort); + reject(error); + }); + }); +} + +function throwIfAborted(signal: AbortSignal): void { + /* c8 ignore start -- queue cancellation and the LSP request fence exercise + * deterministic aborts; this is the instruction-boundary race guard. */ + if (signal.aborted) throw cancelledError(signal); + /* c8 ignore stop */ +} + +function cancelledError(signal?: AbortSignal): Error { + /* c8 ignore start -- standards-compliant AbortSignal.abort() always + * supplies a reason; optionality protects foreign signal shims. */ + const reason = signal?.reason === undefined ? "" : `: ${String(signal.reason)}`; + /* c8 ignore stop */ + const error = new Error( + `rust HIR graph: snapshot request cancelled${reason}`, + ); + error.name = "AbortError"; + return error; +} + +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} diff --git a/packages/graph/src/provider/rust/RustGraphSnapshotAdapter.ts b/packages/graph/src/provider/rust/RustGraphSnapshotAdapter.ts new file mode 100644 index 00000000..27db48b4 --- /dev/null +++ b/packages/graph/src/provider/rust/RustGraphSnapshotAdapter.ts @@ -0,0 +1,984 @@ +import { createHash } from "node:crypto"; +import path from "node:path"; + +import { + ISamchonGraphCoverage, + ISamchonGraphDiagnostic, + ISamchonGraphEdge, + ISamchonGraphEvidence, + ISamchonGraphNode, + ISamchonGraphUnresolved, +} from "../../structures"; +import { + GRAPH_EDGE_KINDS, + GraphEdgeKind, + GraphNodeKind, +} from "../../typings"; +import { IBulkGraphSession } from "../IBulkGraphSession"; +import { GraphSnapshotProtocol } from "../GraphSnapshotProtocol"; +import { semanticGraphNodeId } from "../semanticIdentity"; +import { IRustGraphCacheState } from "./IRustGraphCacheState"; +import { IRustGraphCheckpoint } from "./IRustGraphCheckpoint"; +import { IRustGraphCoverage } from "./IRustGraphCoverage"; +import { IRustGraphEvidence } from "./IRustGraphEvidence"; +import { IRustGraphNode } from "./IRustGraphNode"; +import { IRustGraphShard } from "./IRustGraphShard"; +import { IRustGraphSnapshot } from "./IRustGraphSnapshot"; +import { RUST_HIR_FACTS } from "./RUST_HIR_FACTS"; +import { RUST_HIR_PRODUCER } from "./RUST_HIR_PRODUCER"; +import { RUST_HIR_PROVIDER } from "./RUST_HIR_PROVIDER"; + +const DIGEST = /^[a-f0-9]{64}$/u; +const NODE_KINDS = new Set([ + "file", + "package", + "namespace", + "module", + "function", + "class", + "interface", + "type", + "enum", + "variable", + "method", + "property", + "parameter", + "field", + "constructor", +]); +const COVERAGE_STATES = new Set(["complete", "partial", "unsupported"]); +const DIAGNOSTIC_SEVERITIES = new Set(["error", "warning", "info", "hint"]); +const UNRESOLVED_REASONS = new Set([ + "dynamic", + "reflection", + "macro-or-generated", + "conditional-build", + "external-boundary", + "analysis-error", + "excluded-input", + "identity-unstable", + "provider-gap", +]); +const CAPABILITIES = [ + "coverage", + "diagnostics", + "incremental", + "sourceDigests", + "universe", + "unresolved", + "validatedConsumerCheckpoint", +]; + +export class RustGraphSnapshotAdapter { + public store: GraphSnapshotProtocol.Store; + private rawShards = new Map(); + private graphShards = new Map(); + private rawGeneration: string | undefined; + private checkpoint: IRustGraphCheckpoint | undefined; + + public constructor( + private readonly root: string, + private readonly producerCommit: string, + cached?: IRustGraphCacheState, + ) { + this.store = new GraphSnapshotProtocol.Store(root); + if (cached !== undefined) this.restore(cached); + } + + public get persistedCheckpoint(): IRustGraphCheckpoint | undefined { + return this.checkpoint === undefined + ? undefined + : structuredClone(this.checkpoint); + } + + public get hasPersistedSnapshot(): boolean { + return this.store.current !== undefined; + } + + public discardPersistedSnapshot(): void { + if (this.rawGeneration === undefined) return; + this.rawShards.clear(); + this.graphShards.clear(); + this.rawGeneration = undefined; + this.checkpoint = undefined; + this.store = new GraphSnapshotProtocol.Store(this.root); + } + + public prepare( + raw: IRustGraphSnapshot, + ): RustGraphSnapshotAdapter.IPrepared { + assertSnapshot(raw, this.producerCommit); + const prior = this.store.current; + const priorRawGeneration = this.rawGeneration; + if (raw.baseGeneration !== null) { + if (raw.baseGeneration !== priorRawGeneration) { + throw new Error("rust HIR graph: stale producer base generation"); + } + } else if (priorRawGeneration !== undefined && raw.generation === priorRawGeneration) { + throw new Error("rust HIR graph: unchanged generation lost its base"); + } + + const nextRaw = + raw.baseGeneration === null + ? new Map() + : new Map(this.rawShards); + const touched = new Set(); + for (const key of raw.deletes) { + assertKey(key, "delete key"); + if (touched.has(key) || !nextRaw.delete(key)) { + throw new Error(`rust HIR graph: invalid duplicate/missing delete ${key}`); + } + touched.add(key); + } + for (const shard of raw.upserts) { + assertRawShard(shard, raw); + if (touched.has(shard.key)) { + throw new Error(`rust HIR graph: duplicate shard delta ${shard.key}`); + } + touched.add(shard.key); + nextRaw.set(shard.key, structuredClone(shard)); + } + const expectedRawManifest = [...nextRaw.values()] + .sort((left, right) => compareText(left.key, right.key)) + .map((shard) => ({ key: shard.key, digest: shard.digest })); + if (!sameManifest(raw.manifest, expectedRawManifest)) { + throw new Error("rust HIR graph: producer shard manifest mismatch"); + } + if ( + rawGeneration(raw.universe.digest, expectedRawManifest) !== raw.generation + ) { + throw new Error("rust HIR graph: producer generation digest mismatch"); + } + if ( + prior !== undefined && + raw.generation === priorRawGeneration && + raw.baseGeneration === priorRawGeneration && + raw.upserts.length === 0 && + raw.deletes.length === 0 + ) { + return { + changed: false, + mode: "unchanged", + snapshot: prior, + checkpoint: checkpointOf(raw, nextRaw), + }; + } + + const hello = helloOf(raw); + const nodeIds = nodeIdsOf(raw, nextRaw); + const nextGraph = + raw.baseGeneration === null + ? new Map() + : new Map(this.graphShards); + for (const key of raw.deletes) nextGraph.delete(graphKey(key)); + for (const shard of raw.upserts) { + const adapted = adaptShard(this.root, raw, shard, nodeIds); + nextGraph.set(adapted.key, adapted); + } + const metadata = metadataShard(this.root, raw, nextRaw, nodeIds); + nextGraph.set(metadata.key, metadata); + + const sequence = (prior?.protocol?.sequence ?? 0) + 1; + const graphManifest = [...nextGraph] + .sort(([left], [right]) => compareText(left, right)) + .map(([key, shard]) => ({ + key, + digest: GraphSnapshotProtocol.shardDigest(shard), + })); + const begin = beginOf(raw, sequence, graphManifest, nextGraph, prior); + const commit = commitOf(hello, begin, graphManifest, nextGraph); + const frames: GraphSnapshotProtocol.Frame[] = [hello, begin]; + if (begin.baseGeneration === undefined) { + for (const entry of graphManifest) { + frames.push({ + type: "upsertShard", + digest: entry.digest, + shard: structuredClone(nextGraph.get(entry.key)!), + }); + } + } else { + const previous = new Map( + prior!.protocol!.shards.map((entry) => [entry.key, entry.digest]), + ); + for (const entry of graphManifest) { + if (previous.get(entry.key) === entry.digest) continue; + frames.push({ + type: "upsertShard", + digest: entry.digest, + shard: structuredClone(nextGraph.get(entry.key)!), + }); + } + for (const key of previous.keys()) { + if (!nextGraph.has(key)) frames.push({ type: "deleteShard", key }); + } + } + frames.push(commit); + + const fullBegin: GraphSnapshotProtocol.IBegin = { + ...begin, + sequence, + baseSequence: undefined, + baseGeneration: undefined, + }; + const fullFrames: GraphSnapshotProtocol.Frame[] = [hello, fullBegin]; + for (const entry of graphManifest) { + fullFrames.push({ + type: "upsertShard", + digest: entry.digest, + shard: structuredClone(nextGraph.get(entry.key)!), + }); + } + fullFrames.push({ ...commit, sequence }); + new GraphSnapshotProtocol.Store(this.root).apply(fullFrames); + + const checkpoint = checkpointOf(raw, nextRaw); + const state: IRustGraphCacheState = { + version: 1, + producerCommit: this.producerCommit, + checkpoint, + rawShards: [...nextRaw.values()].map((shard) => structuredClone(shard)), + frames: fullFrames, + }; + const mode: IBulkGraphSession.Mode = + prior === undefined + ? "initial" + : raw.baseGeneration === null + ? prior.provenance.universe === raw.universe.digest + ? "rebuild" + : "reload" + : "incremental"; + return { + changed: true, + mode, + frames, + state, + sequence, + generation: raw.generation, + commit: (snapshot) => { + this.rawShards = nextRaw; + this.graphShards = nextGraph; + this.rawGeneration = raw.generation; + this.checkpoint = checkpoint; + return snapshot; + }, + }; + } + + private restore(cached: IRustGraphCacheState): void { + if ( + cached.version !== 1 || + cached.producerCommit !== this.producerCommit || + cached.checkpoint.producer.commit !== this.producerCommit + ) { + throw new Error("rust HIR graph: persisted producer identity mismatch"); + } + const rawShards = [...cached.rawShards].sort((left, right) => + compareText(left.key, right.key), + ); + for (const shard of rawShards) assertRawShardPayload(shard); + if ( + !isSortedUnique(rawShards.map((shard) => shard.key)) || + !rawShards.every((shard) => shard.key.endsWith(`\0${shard.source}`)) + ) { + throw new Error("rust HIR graph: persisted raw shard identity mismatch"); + } + const manifest = rawShards.map((shard) => ({ + key: shard.key, + digest: shard.digest, + })); + const sources = [...rawShards] + .sort((left, right) => compareText(left.source, right.source)) + .map((shard) => ({ + source: shard.source, + checkerDigest: shard.checkerDigest, + })); + if ( + !sameManifest(cached.checkpoint.manifest, manifest) || + canonical(cached.checkpoint.sources) !== canonical(sources) || + canonical(cached.checkpoint.shards) !== canonical(rawShards) || + rawGeneration(cached.checkpoint.universe, manifest) !== + cached.checkpoint.generation + ) { + throw new Error("rust HIR graph: persisted producer checkpoint is corrupt"); + } + const snapshot = this.store.apply(cached.frames); + if ( + snapshot.protocol?.generation !== cached.checkpoint.generation || + snapshot.provenance.universe !== cached.checkpoint.universe + ) { + throw new Error("rust HIR graph: persisted checkpoint generation mismatch"); + } + this.rawShards = new Map( + cached.rawShards.map((shard) => [shard.key, structuredClone(shard)]), + ); + this.graphShards = new Map( + cached.frames + .filter( + (frame): frame is GraphSnapshotProtocol.IUpsertShard => + frame.type === "upsertShard", + ) + .map((frame) => [frame.shard.key, structuredClone(frame.shard)]), + ); + this.rawGeneration = cached.checkpoint.generation; + this.checkpoint = structuredClone(cached.checkpoint); + } +} + +export namespace RustGraphSnapshotAdapter { + export type IPrepared = + | { + changed: false; + mode: "unchanged"; + snapshot: IBulkGraphSession.ISnapshot; + checkpoint: IRustGraphCheckpoint; + } + | { + changed: true; + mode: IBulkGraphSession.Mode; + frames: GraphSnapshotProtocol.Frame[]; + state: IRustGraphCacheState; + sequence: number; + generation: string; + commit: ( + snapshot: IBulkGraphSession.ISnapshot, + ) => IBulkGraphSession.ISnapshot; + }; +} + +function adaptShard( + root: string, + raw: IRustGraphSnapshot, + shard: IRustGraphShard, + nodeIds: ReadonlyMap, +): GraphSnapshotProtocol.IShard { + const source = sourceFile(root, shard.source); + return { + key: graphKey(shard.key), + target: raw.universe.target, + languages: ["rust"], + nodes: shard.nodes + .filter((node) => !node.external) + .map((node) => adaptNode(root, raw, node)), + edges: shard.edges.map((edge) => ({ + from: requireNodeId(nodeIds, edge.from, "edge source"), + to: requireNodeId(nodeIds, edge.to, "edge target"), + kind: edge.kind as GraphEdgeKind, + ...(edge.evidence === null + ? {} + : { evidence: adaptEvidence(root, edge.evidence) }), + })), + diagnostics: shard.diagnostics.map((diagnostic) => ({ + file: graphFile(root, diagnostic.file), + line: diagnostic.line, + ...(diagnostic.column === null ? {} : { column: diagnostic.column }), + code: diagnostic.code, + message: diagnostic.message, + ...(diagnostic.severity === null + ? {} + : { + severity: + diagnostic.severity as ISamchonGraphDiagnostic["severity"], + }), + })), + coverage: [], + unresolved: shard.unresolved.map((site) => ({ + provider: RUST_HIR_PROVIDER, + language: "rust", + target: raw.universe.target, + universe: raw.universe.digest, + family: site.family as GraphEdgeKind, + evidence: adaptEvidence(root, site.evidence), + reason: site.reason as ISamchonGraphUnresolved["reason"], + ...(site.candidates.length === 0 + ? {} + : { + candidates: site.candidates.map( + (candidate) => nodeIds.get(candidate) ?? candidate, + ), + }), + })), + sources: [ + { + file: source, + checkerDigest: shard.checkerDigest, + diskDigest: "", + }, + ], + }; +} + +function metadataShard( + root: string, + raw: IRustGraphSnapshot, + shards: ReadonlyMap, + nodeIds: ReadonlyMap, +): GraphSnapshotProtocol.IShard { + const coverage = coverageOf(raw, shards); + const external = new Map(); + for (const shard of shards.values()) { + for (const node of shard.nodes.filter((node) => node.external)) { + const adapted = adaptNode(root, raw, node); + const prior = external.get(adapted.id); + if (prior !== undefined && canonical(prior) !== canonical(adapted)) { + throw new Error(`rust HIR graph: external node ${adapted.id} disagrees across shards`); + } + external.set(adapted.id, adapted); + } + } + const nodes = [...external.values()].sort((left, right) => + compareText(left.id, right.id), + ); + const dependencyDigest = digest(nodes); + return { + key: `rust-metadata:${raw.universe.digest}`, + target: raw.universe.target, + languages: ["rust"], + nodes, + edges: [], + diagnostics: [], + coverage, + unresolved: [], + sources: [ + { + file: "bundled:///rust/dependencies", + checkerDigest: dependencyDigest, + diskDigest: "", + }, + { + file: "bundled:///rust/universe", + checkerDigest: raw.universe.digest, + diskDigest: "", + }, + ], + }; +} + +function coverageOf( + raw: IRustGraphSnapshot, + shards: ReadonlyMap, +): ISamchonGraphCoverage[] { + let established: string | undefined; + let rows: IRustGraphCoverage[] | undefined; + for (const shard of shards.values()) { + const current = canonical( + [...shard.coverage].sort((left, right) => compareText(left.family, right.family)), + ); + if (established !== undefined && established !== current) { + throw new Error("rust HIR graph: shards disagree about coverage"); + } + established = current; + rows = shard.coverage; + } + if (rows === undefined) throw new Error("rust HIR graph: snapshot has no coverage"); + return [...rows] + .sort((left, right) => compareText(left.family, right.family)) + .map((row) => ({ + provider: RUST_HIR_PROVIDER, + language: "rust", + target: raw.universe.target, + family: row.family as GraphEdgeKind, + state: row.state as ISamchonGraphCoverage["state"], + })); +} + +function adaptNode( + root: string, + raw: IRustGraphSnapshot, + node: IRustGraphNode, +): ISamchonGraphNode { + const kind = node.external ? "external_symbol" : (node.kind as GraphNodeKind); + return { + id: rustGraphNodeId(raw, node), + kind, + language: "rust", + name: node.name, + ...(node.qualifiedName === null + ? {} + : { qualifiedName: node.qualifiedName }), + file: graphFile(root, node.file), + external: node.external, + ...(node.exported ? { exported: true } : {}), + ...(node.signature === null ? {} : { signature: node.signature }), + ...(node.evidence === null + ? {} + : { evidence: adaptEvidence(root, node.evidence) }), + }; +} + +function nodeIdsOf( + raw: IRustGraphSnapshot, + shards: ReadonlyMap, +): Map { + const output = new Map(); + for (const shard of shards.values()) { + for (const node of shard.nodes) { + const adapted = rustGraphNodeId(raw, node); + const prior = output.get(node.id); + if (prior !== undefined && prior !== adapted) { + throw new Error(`rust HIR graph: native node identity disagrees ${node.id}`); + } + output.set(node.id, adapted); + } + } + return output; +} + +function rustGraphNodeId(raw: IRustGraphSnapshot, node: IRustGraphNode): string { + const role = node.external ? "external_symbol" : (node.kind as GraphNodeKind); + const display = node.qualifiedName ?? node.name; + return semanticGraphNodeId( + { + version: 2, + language: "rust", + symbol: display, + role, + native: { key: node.id, stability: "semantic" }, + scope: { target: raw.universe.target }, + stability: "persistent", + }, + display, + ); +} + +function requireNodeId( + nodeIds: ReadonlyMap, + rawId: string, + label: string, +): string { + const id = nodeIds.get(rawId); + if (id === undefined) { + throw new Error(`rust HIR graph: ${label} is absent ${rawId}`); + } + return id; +} + +function adaptEvidence( + root: string, + evidence: IRustGraphEvidence, +): ISamchonGraphEvidence { + return { + file: graphFile(root, evidence.file), + startLine: evidence.startLine, + startCol: evidence.startColumn, + endLine: evidence.endLine, + endCol: evidence.endColumn, + }; +} + +function helloOf(raw: IRustGraphSnapshot): GraphSnapshotProtocol.IHello { + const compilerVersion = + raw.universe.configurations + .find((row) => row.startsWith("rustc-version=")) + ?.slice("rustc-version=".length) ?? "unavailable"; + return { + type: "hello", + protocolVersion: 1, + schemaVersion: 1, + producerSchemaVersion: raw.schemaVersion, + provider: RUST_HIR_PROVIDER, + producer: raw.producer.name, + producerVersion: `${raw.producer.version} (${raw.producer.commit})`, + compilerVersion, + languages: ["rust"], + authority: "analyzer", + supportedFacts: [...RUST_HIR_FACTS], + capabilities: [...CAPABILITIES], + }; +} + +function beginOf( + raw: IRustGraphSnapshot, + sequence: number, + _manifest: readonly IBulkGraphSession.IShard[], + shards: ReadonlyMap, + prior: IBulkGraphSession.ISnapshot | undefined, +): GraphSnapshotProtocol.IBegin { + const sources = [...shards.values()].flatMap((shard) => shard.sources); + const canDelta = raw.baseGeneration !== null && prior !== undefined; + return { + type: "begin", + sequence, + generation: raw.generation, + ...(canDelta + ? { + baseSequence: prior.protocol!.sequence, + baseGeneration: prior.protocol!.generation, + } + : {}), + universe: raw.universe.digest, + manifest: GraphSnapshotProtocol.manifestDigest(sources), + targets: [raw.universe.target], + }; +} + +function commitOf( + hello: GraphSnapshotProtocol.IHello, + begin: GraphSnapshotProtocol.IBegin, + manifest: IBulkGraphSession.IShard[], + shards: ReadonlyMap, +): GraphSnapshotProtocol.ICommit { + const facts = factsOf(hello, begin, manifest, shards); + return { + type: "commit", + sequence: begin.sequence, + generation: begin.generation, + shards: manifest, + factDigest: GraphSnapshotProtocol.factDigest(facts), + }; +} + +function factsOf( + hello: GraphSnapshotProtocol.IHello, + begin: GraphSnapshotProtocol.IBegin, + manifest: readonly IBulkGraphSession.IShard[], + shards: ReadonlyMap, +): Pick< + IBulkGraphSession.ISnapshot, + | "languages" + | "nodes" + | "edges" + | "diagnostics" + | "coverage" + | "unresolved" + | "provenance" +> { + const ordered = manifest.map((entry) => shards.get(entry.key)!); + return { + languages: ["rust"], + nodes: ordered.flatMap((shard) => shard.nodes), + edges: ordered.flatMap((shard) => shard.edges), + diagnostics: ordered.flatMap((shard) => shard.diagnostics), + coverage: ordered.flatMap((shard) => shard.coverage), + unresolved: ordered.flatMap((shard) => shard.unresolved), + provenance: { + provider: hello.provider, + authority: hello.authority, + facts: [...hello.supportedFacts], + schemaVersion: hello.producerSchemaVersion, + tool: hello.producer, + toolVersion: hello.producerVersion, + compilerVersion: hello.compilerVersion, + protocolVersion: hello.protocolVersion, + universe: begin.universe, + capabilities: [...hello.capabilities], + }, + }; +} + +function checkpointOf( + raw: IRustGraphSnapshot, + shards: ReadonlyMap, +): IRustGraphCheckpoint { + return { + protocolVersion: raw.protocolVersion, + schemaVersion: raw.schemaVersion, + producer: structuredClone(raw.producer), + universe: raw.universe.digest, + generation: raw.generation, + manifest: raw.manifest.map((entry) => ({ ...entry })), + sources: [...shards.values()] + .sort((left, right) => compareText(left.source, right.source)) + .map((shard) => ({ + source: shard.source, + checkerDigest: shard.checkerDigest, + })), + shards: [...shards.values()] + .sort((left, right) => compareText(left.key, right.key)) + .map((shard) => structuredClone(shard)), + }; +} + +function assertSnapshot(raw: IRustGraphSnapshot, commit: string): void { + if (raw === null || typeof raw !== "object") { + throw new Error("rust HIR graph: response is not an object"); + } + if (raw.protocolVersion !== 1 || raw.schemaVersion !== 1) { + throw new Error("rust HIR graph: unsupported producer protocol/schema"); + } + if ( + raw.producer?.name !== RUST_HIR_PRODUCER || + raw.producer.commit !== commit || + typeof raw.producer.version !== "string" || + raw.producer.version === "" + ) { + throw new Error("rust HIR graph: producer identity/commit mismatch"); + } + assertDigest(raw.universe?.digest, "universe digest"); + assertString(raw.universe?.target, "universe target"); + assertStringArray(raw.universe?.workspaceRoots, "workspace roots"); + assertStringArray(raw.universe?.toolchains, "toolchains"); + assertStringArray(raw.universe?.configurations, "configurations"); + assertDigest(raw.generation, "generation"); + if ( + !Number.isSafeInteger(raw.sequence) || + raw.sequence < 1 || + !Array.isArray(raw.upserts) || + !Array.isArray(raw.deletes) || + !Array.isArray(raw.manifest) || + raw.phases === null || + typeof raw.phases !== "object" + ) { + throw new Error("rust HIR graph: malformed generation envelope"); + } + if ( + ![ + raw.phases.semanticMillis, + raw.phases.shardMillis, + raw.phases.encodeMillis, + raw.phases.totalMillis, + ].every((value) => Number.isSafeInteger(value) && value >= 0) || + typeof raw.phases.cacheHit !== "boolean" + ) { + throw new Error("rust HIR graph: malformed phase telemetry"); + } + if (raw.baseGeneration !== null) assertDigest(raw.baseGeneration, "base generation"); + for (const entry of raw.manifest) { + assertKey(entry.key, "manifest key"); + assertDigest(entry.digest, "manifest digest"); + } + if (!isSortedUnique(raw.manifest.map((entry) => entry.key))) { + throw new Error("rust HIR graph: manifest is not sorted and unique"); + } + if (!isSortedUnique(raw.deletes)) { + throw new Error("rust HIR graph: deletes are not sorted and unique"); + } +} + +function assertRawShard(shard: IRustGraphShard, raw: IRustGraphSnapshot): void { + assertRawShardPayload(shard); + if (shard.key !== `${raw.universe.target}\0${shard.source}`) { + throw new Error("rust HIR graph: shard key does not match its universe/source"); + } +} + +function assertRawShardPayload(shard: IRustGraphShard): void { + assertKey(shard.key, "shard key"); + assertString(shard.source, "shard source"); + assertDigest(shard.checkerDigest, "checker digest"); + assertDigest(shard.interfaceFingerprint, "interface fingerprint"); + assertDigest(shard.digest, "shard digest"); + if (rawShardDigest(shard) !== shard.digest) { + throw new Error(`rust HIR graph: shard digest mismatch ${shard.key}`); + } + if ( + !Array.isArray(shard.nodes) || + !Array.isArray(shard.edges) || + !Array.isArray(shard.diagnostics) || + !Array.isArray(shard.coverage) || + !Array.isArray(shard.unresolved) + ) { + throw new Error("rust HIR graph: malformed shard arrays"); + } + for (const node of shard.nodes) { + assertNativeNodeId(node.id, "node id"); + assertString(node.name, "node name"); + assertString(node.file, "node file"); + if ( + typeof node.external !== "boolean" || + typeof node.exported !== "boolean" + ) { + throw new Error("rust HIR graph: malformed node flags"); + } + assertNullableString(node.qualifiedName, "qualified node name"); + assertNullableString(node.signature, "node signature"); + if (!NODE_KINDS.has(node.kind as GraphNodeKind)) { + throw new Error(`rust HIR graph: unknown node kind ${node.kind}`); + } + if (node.evidence !== null) assertEvidence(node.evidence); + } + for (const edge of shard.edges) { + assertNativeNodeId(edge.from, "edge from"); + assertNativeNodeId(edge.to, "edge to"); + if (!GRAPH_EDGE_KINDS.includes(edge.kind as GraphEdgeKind) || edge.kind === "renders") { + throw new Error(`rust HIR graph: unknown/unsupported edge kind ${edge.kind}`); + } + if (edge.evidence !== null) assertEvidence(edge.evidence); + } + for (const diagnostic of shard.diagnostics) { + assertString(diagnostic.file, "diagnostic file"); + assertPositiveInteger(diagnostic.line, "diagnostic line"); + if (diagnostic.column !== null) { + assertPositiveInteger(diagnostic.column, "diagnostic column"); + } + assertString(diagnostic.code, "diagnostic code"); + assertString(diagnostic.message, "diagnostic message"); + if ( + diagnostic.severity !== null && + !DIAGNOSTIC_SEVERITIES.has(diagnostic.severity) + ) { + throw new Error("rust HIR graph: invalid diagnostic severity"); + } + } + const coverage = new Map(); + for (const row of shard.coverage) { + if ( + !GRAPH_EDGE_KINDS.includes(row.family as GraphEdgeKind) || + !COVERAGE_STATES.has(row.state) || + coverage.has(row.family) + ) { + throw new Error("rust HIR graph: malformed coverage row"); + } + coverage.set(row.family, row.state); + } + if (coverage.size !== GRAPH_EDGE_KINDS.length) { + throw new Error("rust HIR graph: incomplete coverage matrix"); + } + for (const site of shard.unresolved) { + if ( + !GRAPH_EDGE_KINDS.includes(site.family as GraphEdgeKind) || + !UNRESOLVED_REASONS.has(site.reason as ISamchonGraphUnresolved["reason"]) || + !Array.isArray(site.candidates) || + site.candidates.some( + (candidate) => + typeof candidate !== "string" || + !candidate.startsWith("rust-hir-v1|"), + ) || + new Set(site.candidates).size !== site.candidates.length + ) { + throw new Error("rust HIR graph: malformed unresolved site"); + } + assertEvidence(site.evidence); + } +} + +function assertEvidence(evidence: IRustGraphEvidence): void { + assertString(evidence.file, "evidence file"); + for (const value of [ + evidence.startLine, + evidence.startColumn, + evidence.endLine, + evidence.endColumn, + ]) { + assertPositiveInteger(value, "evidence coordinate"); + } + if ( + evidence.endLine < evidence.startLine || + (evidence.endLine === evidence.startLine && + evidence.endColumn < evidence.startColumn) + ) { + throw new Error("rust HIR graph: reversed evidence range"); + } +} + +function rawShardDigest(shard: IRustGraphShard): string { + return digest({ + key: shard.key, + source: shard.source, + checkerDigest: shard.checkerDigest, + interfaceFingerprint: shard.interfaceFingerprint, + nodes: shard.nodes, + edges: shard.edges, + diagnostics: shard.diagnostics, + coverage: shard.coverage, + unresolved: shard.unresolved, + }); +} + +function rawGeneration( + universe: string, + manifest: readonly { key: string; digest: string }[], +): string { + return digest({ universe, manifest }); +} + +function graphFile(root: string, file: string): string { + if (file.startsWith("bundled:///")) return file; + return path.relative(root, path.resolve(root, file)).split(path.sep).join("/"); +} + +function sourceFile(root: string, file: string): string { + return file.startsWith("bundled:///") + ? file + : path.normalize(path.resolve(root, file)); +} + +function graphKey(rawKey: string): string { + return `rust-shard:${digest(rawKey)}`; +} + +function sameManifest( + left: readonly { key: string; digest: string }[], + right: readonly { key: string; digest: string }[], +): boolean { + return ( + left.length === right.length && + left.every( + (entry, index) => + entry.key === right[index]?.key && entry.digest === right[index]?.digest, + ) + ); +} + +function isSortedUnique(values: readonly string[]): boolean { + return values.every( + (value, index) => index === 0 || compareText(values[index - 1]!, value) < 0, + ); +} + +function assertString(value: unknown, label: string): asserts value is string { + if (typeof value !== "string" || value === "" || value.includes("\0")) { + throw new Error(`rust HIR graph: invalid ${label}`); + } +} + +function assertNativeNodeId(value: unknown, label: string): asserts value is string { + assertString(value, label); + if (!value.startsWith("rust-hir-v1|")) { + throw new Error(`rust HIR graph: invalid ${label}`); + } +} + +function assertNullableString( + value: unknown, + label: string, +): asserts value is string | null { + if (value !== null) assertString(value, label); +} + +function assertPositiveInteger(value: unknown, label: string): asserts value is number { + if (!Number.isSafeInteger(value) || (value as number) < 1) { + throw new Error(`rust HIR graph: invalid ${label}`); + } +} + +function assertKey(value: unknown, label: string): asserts value is string { + if (typeof value !== "string" || value === "") { + throw new Error(`rust HIR graph: invalid ${label}`); + } +} + +function assertDigest(value: unknown, label: string): asserts value is string { + if (typeof value !== "string" || !DIGEST.test(value)) { + throw new Error(`rust HIR graph: invalid ${label}`); + } +} + +function assertStringArray(value: unknown, label: string): asserts value is string[] { + if ( + !Array.isArray(value) || + value.some((entry) => typeof entry !== "string" || entry === "") || + new Set(value).size !== value.length + ) { + throw new Error(`rust HIR graph: invalid ${label}`); + } +} + +function digest(value: unknown): string { + return createHash("sha256").update(canonical(value)).digest("hex"); +} + +function canonical(value: unknown): string { + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) { + return `[${value.map((entry) => canonical(entry)).join(",")}]`; + } + const object = value as Record; + return `{${Object.keys(object) + .sort(compareText) + .map((key) => `${JSON.stringify(key)}:${canonical(object[key])}`) + .join(",")}}`; +} + +function compareText(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} diff --git a/packages/graph/src/provider/rust/index.ts b/packages/graph/src/provider/rust/index.ts index 72139cbe..695b082b 100644 --- a/packages/graph/src/provider/rust/index.ts +++ b/packages/graph/src/provider/rust/index.ts @@ -1 +1,24 @@ +export * from "./IRustGraphCacheState"; +export * from "./IRustGraphCheckpoint"; +export * from "./IRustGraphCheckpointSource"; +export * from "./IRustGraphCoverage"; +export * from "./IRustGraphDiagnostic"; +export * from "./IRustGraphEdge"; +export * from "./IRustGraphEvidence"; +export * from "./IRustGraphManifestEntry"; +export * from "./IRustGraphNode"; +export * from "./IRustGraphPhases"; +export * from "./IRustGraphProducer"; +export * from "./IRustGraphShard"; +export * from "./IRustGraphSnapshot"; +export * from "./IRustGraphSnapshotParams"; +export * from "./IRustGraphUniverse"; +export * from "./RUST_GRAPH_PRODUCER_COMMIT"; +export * from "./RUST_HIR_FACTS"; +export * from "./RUST_HIR_PRODUCER"; +export * from "./RUST_HIR_PROVIDER"; +export * from "./RustGraphCache"; +export * from "./RustGraphClient"; +export * from "./RustGraphSnapshotAdapter"; +export * from "./rustGraphProvider"; export * from "./rustScipProvider"; diff --git a/packages/graph/src/provider/rust/rustGraphProvider.ts b/packages/graph/src/provider/rust/rustGraphProvider.ts new file mode 100644 index 00000000..45acc52b --- /dev/null +++ b/packages/graph/src/provider/rust/rustGraphProvider.ts @@ -0,0 +1,101 @@ +import { spawnSync } from "node:child_process"; + +import { assertGraphSnapshotContract } from "../assertGraphSnapshotContract"; +import { IGraphProvider } from "../IGraphProvider"; +import { resolveProviderCommand } from "../resolveProviderCommand"; +import { spawnableCommand } from "../../utils/spawnableCommand"; +import { RustGraphClient } from "./RustGraphClient"; +import { RUST_HIR_FACTS } from "./RUST_HIR_FACTS"; +import { RUST_GRAPH_PRODUCER_COMMIT } from "./RUST_GRAPH_PRODUCER_COMMIT"; +import { RUST_HIR_PROVIDER } from "./RUST_HIR_PROVIDER"; +import { rustScipProvider } from "./rustScipProvider"; + +const OVERRIDE = "SAMCHON_GRAPH_RUST_ANALYZER_HIR"; + +export const rustGraphProvider: IGraphProvider = { + name: RUST_HIR_PROVIDER, + languages: ["rust"], + authority: "analyzer", + facts: RUST_HIR_FACTS, + resolution: { + commands: ["samchon-rust-analyzer", "rust-analyzer"], + environmentOverrides: [OVERRIDE], + }, + fallbacks: [rustScipProvider], + buildInputs: rustScipProvider.buildInputs, + configuration: (_root, env) => [ + `producer-commit=${RUST_GRAPH_PRODUCER_COMMIT}`, + `${OVERRIDE}=${env[OVERRIDE] ?? "unconfigured"}`, + ], + refuse: (options) => { + const refused = [ + options.server === undefined ? undefined : "server", + options.maxFiles === undefined ? undefined : "maxFiles", + options.lspReferenceLimit === undefined + ? undefined + : "lspReferenceLimit", + ].filter((value): value is string => value !== undefined); + return refused.length === 0 + ? undefined + : `rust: ${RUST_HIR_PROVIDER} publishes whole-program generations and cannot honor ${refused.join(", ")}`; + }, + resolve: (root, env) => resolvePinned(root, env), + open: (props) => + new RustGraphClient({ + root: props.root, + command: props.command.command, + args: props.command.args, + producerCommit: RUST_GRAPH_PRODUCER_COMMIT, + initializationOptions: props.options.initializationOptions, + requestTimeoutMs: props.options.lspTimeoutMs, + readyTimeoutMs: props.options.lspReadyTimeoutMs, + maxMessageBytes: props.options.lspMaxMessageBytes, + windowsVerbatimArguments: props.command.windowsVerbatimArguments, + validate: (snapshot) => + assertGraphSnapshotContract( + snapshot, + rustGraphProvider, + props.languages, + props.root, + ), + }), +}; + +function resolvePinned( + root: string, + env: NodeJS.ProcessEnv, +): IGraphProvider.ICommand | undefined { + for (const command of ["samchon-rust-analyzer", "rust-analyzer"]) { + const candidate = resolveProviderCommand(root, env, { + command, + override: OVERRIDE, + }); + if (candidate !== undefined && hasPinnedVersion(root, env, candidate)) { + return candidate; + } + } + return undefined; +} + +function hasPinnedVersion( + root: string, + env: NodeJS.ProcessEnv, + command: IGraphProvider.ICommand, +): boolean { + const invocation = spawnableCommand.append( + { ...command, args: [...command.args] }, + ["--version"], + ); + const result = spawnSync(invocation.command, invocation.args, { + cwd: root, + encoding: "utf8", + env, + shell: false, + timeout: 10_000, + windowsHide: true, + windowsVerbatimArguments: invocation.windowsVerbatimArguments, + }); + if (result.status !== 0 || result.error !== undefined) return false; + const short = RUST_GRAPH_PRODUCER_COMMIT.slice(0, 9); + return new RegExp(`\\(${short}(?:\\s|\\))`, "u").test(result.stdout); +} diff --git a/packages/graph/src/provider/selectGraphProviders.ts b/packages/graph/src/provider/selectGraphProviders.ts index 4cd8d791..1c0e255e 100644 --- a/packages/graph/src/provider/selectGraphProviders.ts +++ b/packages/graph/src/provider/selectGraphProviders.ts @@ -44,33 +44,36 @@ export function selectGraphProviders( requested.has(language), ); if (owned.length === 0) continue; + const routes: selectGraphProviders.IRouteCandidate[] = []; + for (const route of [provider, ...(provider.fallbacks ?? [])]) { + const refusal = route.refuse(options); + if (refusal !== undefined) { + warnings.push(refusal); + continue; + } - const refusal = provider.refuse(options); - if (refusal !== undefined) { - warnings.push(refusal); - continue; - } - - const command = provider.resolve(root, env); - if (command === undefined) { - warnings.push( - `${owned.join(", ")}: the ${provider.name} ${provider.authority} provider was not found for this project; falling back to the generic language-server lane.`, - ); - continue; - } - - if (prepare && provider.prepare !== undefined) { - try { - provider.prepare(root, options); - } catch (error) { + const command = route.resolve(root, env); + if (command === undefined) { warnings.push( - `${owned.join(", ")}: the ${provider.name} ${provider.authority} provider could not prepare this project, so it cannot answer for it: ${(error as Error).message}`, + `${owned.join(", ")}: the ${route.name} ${route.authority} provider was not found for this project; trying the next strict route if one is available.`, ); continue; } - } - candidates.push({ provider, languages: owned, command }); + if (prepare && route.prepare !== undefined) { + try { + route.prepare(root, options); + } catch (error) { + warnings.push( + `${owned.join(", ")}: the ${route.name} ${route.authority} provider could not prepare this project, so it cannot answer for it: ${(error as Error).message}`, + ); + continue; + } + } + routes.push({ provider: route, languages: owned, command }); + } + const [selected, ...fallbacks] = routes; + if (selected !== undefined) candidates.push({ ...selected, fallbacks }); } return { candidates, warnings }; @@ -91,8 +94,13 @@ export namespace selectGraphProviders { languages: GraphLanguage[]; command: IGraphProvider.ICommand; + + /** Already-resolved strict routes attempted if this route fails. */ + fallbacks: IRouteCandidate[]; } + export type IRouteCandidate = Omit; + export interface IResult { /** Providers that can serve this build, in registry order. */ candidates: ICandidate[]; @@ -127,21 +135,35 @@ function assertOneOwnerPerLanguage( const owners = new Map(); const names = new Set(); for (const provider of registry) { - if (names.has(provider.name)) { - throw new Error( - `@samchon/graph: provider "${provider.name}" is registered more than once; provenance needs one stable provider identity`, - ); - } - names.add(provider.name); - if (provider.languages.length === 0) { - throw new Error( - `@samchon/graph: provider "${provider.name}" owns no language, so nothing can select it`, - ); - } - if (new Set(provider.facts).size !== provider.facts.length) { - throw new Error( - `@samchon/graph: provider "${provider.name}" declares one fact family more than once`, - ); + for (const route of [provider, ...(provider.fallbacks ?? [])]) { + if (names.has(route.name)) { + throw new Error( + `@samchon/graph: provider "${route.name}" is registered more than once; provenance needs one stable provider identity`, + ); + } + names.add(route.name); + if (route.languages.length === 0) { + throw new Error( + `@samchon/graph: provider "${route.name}" owns no language, so nothing can select it`, + ); + } + if (new Set(route.facts).size !== route.facts.length) { + throw new Error( + `@samchon/graph: provider "${route.name}" declares one fact family more than once`, + ); + } + if (route !== provider) { + if (route.fallbacks !== undefined) { + throw new Error( + `@samchon/graph: fallback provider "${route.name}" cannot declare another fallback tier`, + ); + } + if (!sameLanguages(route.languages, provider.languages)) { + throw new Error( + `@samchon/graph: fallback provider "${route.name}" does not own the same atomic languages as "${provider.name}"`, + ); + } + } } for (const language of provider.languages) { const existing = owners.get(language); @@ -154,3 +176,15 @@ function assertOneOwnerPerLanguage( } } } + +function sameLanguages( + left: readonly GraphLanguage[], + right: readonly GraphLanguage[], +): boolean { + const uniqueLeft = new Set(left); + const uniqueRight = new Set(right); + return ( + uniqueLeft.size === uniqueRight.size && + [...uniqueLeft].every((language) => uniqueRight.has(language)) + ); +} diff --git a/tests/experiment/src/catalog.mjs b/tests/experiment/src/catalog.mjs index 4425c10e..d91d813a 100644 --- a/tests/experiment/src/catalog.mjs +++ b/tests/experiment/src/catalog.mjs @@ -83,11 +83,34 @@ export const LANGUAGE_EXPERIMENTS = [ language: "rust", repository: "https://github.com/tokio-rs/mini-redis.git", commit: "3d93b42bc363220f85af4fc9e1bebd35b588a4a3", - strictProvider: "rust-analyzer-scip", - strictAuthority: "semantic-index", - strictTool: "rust-analyzer", - requiredCapabilities: ["universe", "diskDigests"], - semanticEdges: ["contains", "references"], + strictProvider: "samchon-rust-analyzer-hir", + strictAuthority: "analyzer", + strictTool: "samchon-rust-analyzer", + requiredCapabilities: [ + "coverage", + "diagnostics", + "incremental", + "sourceDigests", + "universe", + "unresolved", + "validatedConsumerCheckpoint", + ], + semanticEdges: [ + "contains", + "exports", + "imports", + "calls", + "accesses", + "instantiates", + "type_ref", + "extends", + "implements", + "overrides", + "dispatches", + "decorates", + "tests", + "references", + ], crossFileEdge: "references", lifecycle: { sourceFile: "src/lib.rs", @@ -98,9 +121,9 @@ export const LANGUAGE_EXPERIMENTS = [ 'const samchonGraphExperiment: &str = "strict-lifecycle";\n\nfn main() { println!("{samchonGraphExperiment}"); }\n', createdSymbol: "samchonGraphExperiment", buildFile: "Cargo.toml", - // Stock rust-analyzer's SCIP command recovers from malformed Rust and - // emits no diagnostics. A malformed Cargo manifest is the real strict - // failure boundary that the semantic-index authority can prove. + // A malformed Cargo manifest invalidates the producer's build universe, + // so the HIR snapshot must reject rather than mix an old database with + // new workspace inputs. failureFile: "Cargo.toml", failureSuffix: "\n[malformed", failurePolicy: "reject", diff --git a/tests/test-graph/src/features/test_lsp_client_closes_servers_that_break_the_shutdown_handshake.ts b/tests/test-graph/src/features/test_lsp_client_closes_servers_that_break_the_shutdown_handshake.ts index f2030591..f672e1c6 100644 --- a/tests/test-graph/src/features/test_lsp_client_closes_servers_that_break_the_shutdown_handshake.ts +++ b/tests/test-graph/src/features/test_lsp_client_closes_servers_that_break_the_shutdown_handshake.ts @@ -19,7 +19,18 @@ interface ILspClient { } interface ILspClientInternals { - pending: Map; + pending: Map< + number, + { + resolve(value: unknown): void; + reject(error: Error): void; + timer: NodeJS.Timeout | undefined; + signal?: AbortSignal; + abort?: () => void; + } + >; + handleMessage(message: unknown): void; + write(payload: unknown): void; process: { stdin: { destroy(error?: Error): void; @@ -77,6 +88,7 @@ type LspClientConstructor = new ( maxMessageBytes?: number, windowsVerbatimArguments?: boolean, requestObserver?: (event: LspRequestTrace) => void, + serverRequestHandler?: (method: string, params: unknown) => unknown, ) => ILspClient; /** `LspClient` is internal transport, reached through the shipped artifact. */ @@ -158,6 +170,7 @@ export const test_lsp_client_closes_servers_that_break_the_shutdown_handshake = await assertOversizedHeadersTerminateTransport(LspClient); await assertRequestTracing(LspClient); await assertRequestTraceFormatting(); + await assertServerRequestFailureAndBareResponse(LspClient); // An already-cancelled request never enters the wire or waits for the // otherwise-unlimited default deadline. The client still owns its child and @@ -180,6 +193,82 @@ export const test_lsp_client_closes_servers_that_break_the_shutdown_handshake = await cancelled.close(); }; +const assertServerRequestFailureAndBareResponse = async ( + LspClient: LspClientConstructor, +): Promise => { + const client = new LspClient( + process.execPath, + [GraphPaths.fakeLspServer], + undefined, + undefined, + undefined, + undefined, + undefined, + (method) => { + if (method === "fixture/failure") { + throw "fixture server-request failure"; + } + return undefined; + }, + ); + const internals = client as unknown as ILspClientInternals; + const written: unknown[] = []; + const originalWrite = internals.write.bind(client); + try { + internals.write = (payload) => void written.push(payload); + internals.handleMessage({ + jsonrpc: "2.0", + id: 7001, + method: "fixture/failure", + params: {}, + }); + await new Promise((resolve) => setImmediate(resolve)); + TestValidator.equals( + "a rejected server-request handler returns a normalized JSON-RPC error", + written, + [ + { + jsonrpc: "2.0", + id: 7001, + error: { + code: -32603, + message: "fixture server-request failure", + }, + }, + ], + ); + + internals.handleMessage({ + jsonrpc: "2.0", + id: 7002, + method: "fixture/undefined", + params: {}, + }); + await new Promise((resolve) => setImmediate(resolve)); + TestValidator.equals( + "an undefined server-request handler result remains valid JSON-RPC", + written[1], + { jsonrpc: "2.0", id: 7002, result: null }, + ); + + let bare: Error & { code?: number } | undefined; + internals.pending.set(7003, { + resolve: () => undefined, + reject: (error) => void (bare = error as Error & { code?: number }), + timer: undefined, + }); + internals.handleMessage({ jsonrpc: "2.0", id: 7003, error: {} }); + TestValidator.equals( + "a bare LSP response error receives the protocol defaults", + [bare?.name, bare?.code, bare?.message], + ["LspResponseError", -32603, "LSP request failed."], + ); + } finally { + internals.write = originalWrite; + await client.close(); + } +}; + const assertRequestTracing = async ( LspClient: LspClientConstructor, ): Promise => { diff --git a/tests/test-graph/src/features/test_provider_registry_selects_one_owner_per_language.ts b/tests/test-graph/src/features/test_provider_registry_selects_one_owner_per_language.ts index 4c3e44a5..5ad87abe 100644 --- a/tests/test-graph/src/features/test_provider_registry_selects_one_owner_per_language.ts +++ b/tests/test-graph/src/features/test_provider_registry_selects_one_owner_per_language.ts @@ -36,9 +36,73 @@ export const test_provider_registry_selects_one_owner_per_language = await assertSnapshotContract(); await assertCrossProviderCollisions(); await assertDigestsAndProvenance(); + await assertRuntimeFallback(); await assertStrictBuildCanonicalizesMultiProviderState(); }; +async function assertRuntimeFallback(): Promise { + const root = GraphPaths.createTempDirectory("samchon-graph-provider-fallback-"); + fs.writeFileSync(path.join(root, "index.ts"), "export const value = 1;\n"); + let primaryCloses = 0; + let fallbackCloses = 0; + const fallback = ProviderFixtures.provider({ + name: "fixture-semantic-fallback", + open: (props) => + ProviderFixtures.session({ + root: props.root, + languages: [...props.languages], + snapshots: [ + ProviderFixtures.snapshot({ + root: props.root, + languages: [...props.languages], + provider: "fixture-semantic-fallback", + authority: "compiler", + }), + ], + onClose: () => { + fallbackCloses += 1; + }, + }), + }); + const primary: IGraphProvider = { + ...ProviderFixtures.provider({ + name: "fixture-failing-primary", + open: (props) => + ProviderFixtures.session({ + root: props.root, + languages: [...props.languages], + onRefresh: () => { + throw new Error("primary fixture exploded"); + }, + onClose: () => { + primaryCloses += 1; + }, + }), + }), + fallbacks: [fallback], + }; + + const result = await buildLspGraph( + { cwd: root, languages: ["typescript"] }, + { providers: [primary] }, + ); + TestValidator.equals( + "a failed strict route closes and steps down to its strict fallback", + [ + result.dump.provenance?.map((row) => row.provider), + result.dump.warnings.some( + (warning) => + warning.includes("fixture-failing-primary") && + warning.includes("fixture-semantic-fallback") && + warning.includes("primary fixture exploded"), + ), + primaryCloses, + fallbackCloses, + ], + [["fixture-semantic-fallback"], true, 1, 1], + ); +} + async function assertStrictBuildCanonicalizesMultiProviderState(): Promise { const root = GraphPaths.createTempDirectory("samchon-graph-provider-order-"); fs.writeFileSync(path.join(root, "index.ts"), "export const value = 1;\n"); @@ -254,6 +318,51 @@ async function assertSelection(): Promise { prepared, ], [1, 1]); + const compatibleFallback = ProviderFixtures.provider({ + name: "fake-ts-fallback", + }); + const primaryWithFallback: IGraphProvider = { + ...ProviderFixtures.provider({ name: "fake-ts-primary" }), + fallbacks: [compatibleFallback], + }; + const routed = selectGraphProviders( + "/root", + ["typescript"], + {}, + {}, + [primaryWithFallback], + ); + TestValidator.equals( + "a resolved primary retains its already-resolved fallback route", + [ + routed.candidates[0]?.provider.name, + routed.candidates[0]?.fallbacks.map((route) => route.provider.name), + ], + ["fake-ts-primary", ["fake-ts-fallback"]], + ); + + const missingPrimary = selectGraphProviders( + "/root", + ["typescript"], + {}, + {}, + [ + { + ...primaryWithFallback, + resolve: () => undefined, + }, + ], + ); + TestValidator.equals( + "a missing primary promotes the compatible fallback to the selected route", + [ + missingPrimary.candidates[0]?.provider.name, + missingPrimary.candidates[0]?.fallbacks, + missingPrimary.warnings.length, + ], + ["fake-ts-fallback", [], 1], + ); + // --- registry defects are static, not machine-dependent ----------------- TestValidator.error("two providers cannot own one language", () => selectGraphProviders( @@ -324,6 +433,44 @@ async function assertSelection(): Promise { ], ), ); + TestValidator.error("a fallback cannot own a different atomic language set", () => + selectGraphProviders( + "/root", + ["typescript"], + {}, + {}, + [ + { + ...primaryWithFallback, + fallbacks: [ + ProviderFixtures.provider({ + name: "wrong-language-fallback", + languages: ["go"], + }), + ], + }, + ], + ), + ); + TestValidator.error("a fallback cannot introduce another fallback tier", () => + selectGraphProviders( + "/root", + ["typescript"], + {}, + {}, + [ + { + ...primaryWithFallback, + fallbacks: [ + { + ...compatibleFallback, + fallbacks: [ProviderFixtures.provider({ name: "third-tier" })], + }, + ], + }, + ], + ), + ); // The shipped registry must satisfy its own rule. TestValidator.equals( diff --git a/tests/test-graph/src/features/test_rust_hir_client_restores_retries_and_fails_closed.ts b/tests/test-graph/src/features/test_rust_hir_client_restores_retries_and_fails_closed.ts new file mode 100644 index 00000000..9bbf3540 --- /dev/null +++ b/tests/test-graph/src/features/test_rust_hir_client_restores_retries_and_fails_closed.ts @@ -0,0 +1,474 @@ +import { TestValidator } from "@nestia/e2e"; +import { + RUST_GRAPH_PRODUCER_COMMIT, + RustGraphClient, + rustGraphProvider, +} from "@samchon/graph"; +import fs from "node:fs"; +import path from "node:path"; + +import { GraphPaths } from "../internal/GraphPaths.js"; + +export const test_rust_hir_client_restores_retries_and_fails_closed = async () => { + const root = GraphPaths.createTempDirectory("samchon-graph-rust-client-"); + const cacheRoot = GraphPaths.createTempDirectory("samchon-graph-rust-checkpoints-"); + fs.mkdirSync(path.join(root, "src")); + fs.writeFileSync(path.join(root, "src/lib.rs"), "pub fn answer() -> u8 { 42 }\n"); + + await assertResidentLifecycle(root, cacheRoot); + await assertCheckpointRejectionRecovers(root, cacheRoot); + await assertRetryBoundaries(root); + await assertCancellationBoundaries(root); + await assertPersistenceAndValidationBoundaries(root); + await assertClientOptionBoundaries(root); + await assertPinnedResolution(root); +}; + +async function assertResidentLifecycle(root: string, cacheRoot: string): Promise { + const marker = path.join(root, "basic-closed.txt"); + const requestLog = path.join(root, "basic-requests.ndjson"); + let validations = 0; + const client = rustClient(root, cacheRoot, [ + `--marker=${marker}`, + `--request-log=${requestLog}`, + ], () => { + validations += 1; + }); + const initial = await client.refresh(); + const unchanged = await client.refresh(); + TestValidator.equals( + "the resident Rust client publishes and reuses one validated generation", + [ + initial.changed, + initial.mode, + initial.generation, + unchanged.changed, + unchanged.mode, + unchanged.snapshot === initial.snapshot, + client.current === initial.snapshot, + client.generation, + initial.snapshot.nodes.map((node) => [node.name, node.language]), + initial.snapshot.sources.has(path.join(root, "src/lib.rs")), + initial.snapshot.provenance.provider, + validations, + ], + [ + true, + "initial", + 1, + false, + "unchanged", + true, + true, + 1, + [ + ["dependency", "rust"], + ["answer", "rust"], + ], + true, + "samchon-rust-analyzer-hir", + 2, + ], + ); + await Promise.all([client.close(), client.close()]); + TestValidator.equals("the Rust LSP process closes through its handshake", fs.readFileSync(marker, "utf8"), "closed"); + await rejected("a closed Rust session refuses refresh", client.refresh(), "session is closed"); + + let restoredValidations = 0; + const restoredLog = path.join(root, "restored-requests.ndjson"); + const restored = rustClient(root, cacheRoot, [`--request-log=${restoredLog}`], () => { + restoredValidations += 1; + }); + TestValidator.predicate( + "a validated immutable checkpoint is resident before the restarted producer initializes", + restored.current !== undefined && restored.generation === 1 && restoredValidations === 1, + ); + const reuse = await restored.refresh(); + const params = readRequests(restoredLog)[0]!; + TestValidator.equals( + "a restart sends both the exact known generation and its producer checkpoint", + [ + reuse.changed, + reuse.mode, + reuse.generation, + params.knownGeneration, + params.checkpoint?.generation, + ], + [false, "unchanged", 1, params.checkpoint?.generation, params.checkpoint?.generation], + ); + await restored.close(); +} + +async function assertCheckpointRejectionRecovers( + root: string, + cacheRoot: string, +): Promise { + const requestLog = path.join(root, "rejected-checkpoint-requests.ndjson"); + const client = rustClient(root, cacheRoot, [ + "--reject-checkpoint", + `--request-log=${requestLog}`, + ]); + const refreshed = await client.refresh(); + const requests = readRequests(requestLog); + TestValidator.equals( + "a producer-rejected persisted checkpoint is discarded and rebuilt once", + [ + refreshed.changed, + refreshed.mode, + requests.length, + requests[0]?.checkpoint !== undefined, + requests[1]?.checkpoint, + requests[1]?.knownGeneration, + ], + [true, "initial", 2, true, undefined, undefined], + ); + await client.close(); +} + +async function assertRetryBoundaries(root: string): Promise { + const retrying = rustClient(root, isolatedCache(), ["--retry=1", "--content-modified=1"], undefined, { + readyTimeoutMs: 1_000, + }); + TestValidator.equals( + "ServerCancelled and ContentModified are retried until the producer is ready", + (await retrying.refresh()).changed, + true, + ); + await retrying.close(); + + const exhausted = rustClient(root, isolatedCache(), ["--retry=100"], undefined, { + readyTimeoutMs: 1, + }); + await rejected( + "the Rust readiness retry loop has a hard deadline", + exhausted.refresh(), + "did not become ready", + ); + await exhausted.close(); + + const internal = rustClient(root, isolatedCache(), ["--internal-error"]); + await rejected( + "an unrelated producer error is not disguised as readiness", + internal.refresh(), + "fixture internal failure", + ); + await internal.close(); + + const malformed = rustClient(root, isolatedCache(), ["--malformed"]); + await rejected( + "a malformed producer identity fails before publication", + malformed.refresh(), + "identity/commit mismatch", + ); + TestValidator.equals("a rejected producer response leaves no resident graph", malformed.current, undefined); + await malformed.close(); +} + +async function assertCancellationBoundaries(root: string): Promise { + const preAborted = rustClient(root, isolatedCache(), []); + const preAbort = new AbortController(); + preAbort.abort("pre-aborted fixture cancellation"); + await rejected( + "a pre-aborted Rust request never enters its session queue", + preAborted.refresh({ signal: preAbort.signal }), + "cancelled", + ); + await preAborted.close(); + + const initializeMarker = path.join(root, "initialize-abort-started.txt"); + const initializing = rustClient(root, isolatedCache(), [ + "--initialize-delay=100", + `--initialize-marker=${initializeMarker}`, + ]); + const initializeAbort = new AbortController(); + const cancelledInitialization = initializing.refresh({ + signal: initializeAbort.signal, + }); + await waitFor(() => fs.existsSync(initializeMarker)); + initializeAbort.abort("initialize fixture cancellation"); + await rejected( + "caller cancellation leaves the shared Rust initialization usable", + cancelledInitialization, + "cancelled", + ); + TestValidator.equals( + "a later refresh reuses and completes the initialization instead of inheriting its caller's cancellation", + (await initializing.refresh()).changed, + true, + ); + await initializing.close(); + + const failedInitialization = rustClient(root, isolatedCache(), [ + "--initialize-error", + ]); + await rejected( + "a producer initialization error crosses the caller-cancellation fence intact", + failedInitialization.refresh({ signal: new AbortController().signal }), + "fixture initialize failure", + ); + await rejected( + "a failed producer initialization remains a fatal session result", + failedInitialization.refresh(), + "fixture initialize failure", + ); + await failedInitialization.close(); + + const retryLog = path.join(root, "retry-abort-requests.ndjson"); + const retrySent = path.join(root, "retry-abort-sent.txt"); + const retryDelay = rustClient(root, isolatedCache(), [ + "--retry=100", + `--request-log=${retryLog}`, + `--retry-sent-marker=${retrySent}`, + ]); + const retryAbort = new AbortController(); + const retryRefresh = retryDelay.refresh({ signal: retryAbort.signal }); + await waitFor(() => fs.existsSync(retrySent)); + await new Promise((resolve) => setTimeout(resolve, 10)); + retryAbort.abort("retry-delay fixture cancellation"); + await rejected( + "Rust readiness backoff remains cancellable", + retryRefresh, + "cancelled", + ); + await retryDelay.close(); + + const activeLog = path.join(root, "active-abort-requests.ndjson"); + const active = rustClient(root, isolatedCache(), ["--hang", `--request-log=${activeLog}`]); + const activeAbort = new AbortController(); + const activeRefresh = active.refresh({ signal: activeAbort.signal }); + await waitFor(() => fs.existsSync(activeLog)); + activeAbort.abort("active fixture cancellation"); + await rejected("an active Rust request observes caller cancellation", activeRefresh, "aborted"); + await active.close(); + + const requestLog = path.join(root, "queued-abort-requests.ndjson"); + const queued = rustClient(root, isolatedCache(), ["--hang", `--request-log=${requestLog}`]); + const first = queued.refresh(); + await waitFor(() => fs.existsSync(requestLog)); + const queuedAbort = new AbortController(); + const second = queued.refresh({ signal: queuedAbort.signal }); + queuedAbort.abort("queued fixture cancellation"); + await rejected("a queued Rust request cancels without entering the producer", second, "cancelled"); + const firstRejected = rejected( + "closing the Rust session cancels its active request", + first, + "aborted", + ); + await queued.close(); + await firstRejected; + TestValidator.equals("the cancelled queued request never reached the producer", readRequests(requestLog).length, 1); +} + +async function assertPersistenceAndValidationBoundaries(root: string): Promise { + const cacheFile = path.join(isolatedCache(), "not-a-directory"); + fs.writeFileSync(cacheFile, "file"); + const nonPersistent = rustClient(root, cacheFile, []); + const published = await nonPersistent.refresh(); + TestValidator.predicate( + "checkpoint persistence failure is disclosed without discarding the validated resident graph", + published.snapshot.warnings.some((warning) => warning.includes("could not be persisted")), + ); + await nonPersistent.close(); + + const refused = rustClient(root, isolatedCache(), [], () => { + throw new Error("fixture consumer contract rejection"); + }); + await rejected( + "the consumer contract runs before cache persistence and publication", + refused.refresh(), + "fixture consumer contract rejection", + ); + TestValidator.equals("consumer rejection is atomic", refused.current, undefined); + await refused.close(); + + const refusedString = rustClient(root, isolatedCache(), [], () => { + throw "fixture consumer string rejection"; + }); + await rejected( + "a non-Error consumer rejection is normalized at the session boundary", + refusedString.refresh(), + "fixture consumer string rejection", + ); + await refusedString.close(); +} + +async function assertClientOptionBoundaries(root: string): Promise { + const options = new RustGraphClient({ + root, + cacheRoot: isolatedCache(), + command: process.execPath, + args: [ + GraphPaths.fakeRustGraphServer, + `--commit=${RUST_GRAPH_PRODUCER_COMMIT}`, + "--configuration-without-items", + "--expect-initialization-options", + ], + producerCommit: RUST_GRAPH_PRODUCER_COMMIT, + initializationOptions: { fixture: true }, + }); + TestValidator.equals( + "the Rust client forwards initialization options and answers configuration requests without items", + (await options.refresh()).changed, + true, + ); + await options.close(); + + const closing = rustClient(root, isolatedCache(), []); + const racedRefresh = closing.refresh(); + const racedRejection = rejected( + "closing before a queued Rust refresh starts fails at the session fence", + racedRefresh, + "session is closed", + ); + await closing.close(); + await racedRejection; +} + +async function assertPinnedResolution(root: string): Promise { + const pinned = nodeShim(root, "pinned-rust-analyzer", RUST_GRAPH_PRODUCER_COMMIT); + const wrong = nodeShim(root, "wrong-rust-analyzer", "0000000000000000000000000000000000000000"); + const failing = nodeShim(root, "failing-rust-analyzer", RUST_GRAPH_PRODUCER_COMMIT, [ + "--fail-version", + ]); + const override = "SAMCHON_GRAPH_RUST_ANALYZER_HIR"; + const resolved = rustGraphProvider.resolve(root, { ...process.env, [override]: pinned }); + const rejected = rustGraphProvider.resolve(root, { ...process.env, [override]: wrong }); + const failed = rustGraphProvider.resolve(root, { ...process.env, [override]: failing }); + TestValidator.equals( + "the HIR provider resolves only the exact disclosed producer commit", + [ + resolved !== undefined, + rejected, + failed, + rustGraphProvider.configuration?.(root, { [override]: pinned }), + ], + [ + true, + undefined, + undefined, + [ + `producer-commit=${RUST_GRAPH_PRODUCER_COMMIT}`, + `${override}=${pinned}`, + ], + ], + ); + TestValidator.equals( + "an absent Rust producer override remains explicit in build configuration", + rustGraphProvider.configuration?.(root, {}), + [ + `producer-commit=${RUST_GRAPH_PRODUCER_COMMIT}`, + `${override}=unconfigured`, + ], + ); + TestValidator.predicate( + "whole-program Rust generations explicitly refuse bounded or caller-owned LSP modes", + rustGraphProvider.refuse({ maxFiles: 1 })?.includes("maxFiles") === true && + rustGraphProvider.refuse({ server: "rust-analyzer" })?.includes("server") === true && + rustGraphProvider.refuse({ lspReferenceLimit: 1 })?.includes("lspReferenceLimit") === true && + rustGraphProvider.refuse({}) === undefined, + ); + const priorCacheRoot = process.env.SAMCHON_GRAPH_CACHE_DIR; + process.env.SAMCHON_GRAPH_CACHE_DIR = isolatedCache(); + try { + const session = rustGraphProvider.open({ + root, + command: resolved!, + languages: ["rust"], + options: {}, + }); + try { + const snapshot = await session.refresh(); + TestValidator.equals( + "the registered Rust provider opens the pinned producer under its declared contract", + [snapshot.changed, snapshot.snapshot.provenance.authority], + [true, "analyzer"], + ); + } finally { + await session.close(); + } + } finally { + if (priorCacheRoot === undefined) delete process.env.SAMCHON_GRAPH_CACHE_DIR; + else process.env.SAMCHON_GRAPH_CACHE_DIR = priorCacheRoot; + } +} + +function rustClient( + root: string, + cacheRoot: string, + args: readonly string[], + validate?: ConstructorParameters[0]["validate"], + timeouts: { requestTimeoutMs?: number; readyTimeoutMs?: number } = {}, +): RustGraphClient { + return new RustGraphClient({ + root, + cacheRoot, + command: process.execPath, + args: [ + GraphPaths.fakeRustGraphServer, + `--commit=${RUST_GRAPH_PRODUCER_COMMIT}`, + ...args, + ], + producerCommit: RUST_GRAPH_PRODUCER_COMMIT, + validate, + ...timeouts, + }); +} + +function isolatedCache(): string { + return GraphPaths.createTempDirectory("samchon-graph-rust-isolated-cache-"); +} + +function readRequests(file: string): Array<{ + knownGeneration?: string; + checkpoint?: { generation?: string }; +}> { + return fs + .readFileSync(file, "utf8") + .trim() + .split(/\r?\n/u) + .filter((line) => line !== "") + .map((line) => JSON.parse(line)); +} + +function nodeShim( + root: string, + name: string, + commit: string, + args: readonly string[] = [], +): string { + const directory = path.join(root, "shims"); + fs.mkdirSync(directory, { recursive: true }); + const file = path.join(directory, process.platform === "win32" ? `${name}.cmd` : name); + const invocation = [ + `"${process.execPath}"`, + `"${GraphPaths.fakeRustGraphServer}"`, + `--commit=${commit}`, + ...args, + ].join(" "); + fs.writeFileSync( + file, + process.platform === "win32" + ? `@echo off\r\n${invocation} %*\r\n` + : `#!/bin/sh\nexec ${invocation} "$@"\n`, + ); + if (process.platform !== "win32") fs.chmodSync(file, 0o755); + return file; +} + +async function rejected(label: string, promise: Promise, message: string): Promise { + let error: Error | undefined; + try { + await promise; + } catch (caught) { + error = caught instanceof Error ? caught : new Error(String(caught)); + } + TestValidator.predicate(label, error !== undefined && error.message.includes(message)); +} + +async function waitFor(predicate: () => boolean): Promise { + const deadline = Date.now() + 5_000; + while (!predicate()) { + if (Date.now() >= deadline) throw new Error("fixture condition timed out"); + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} diff --git a/tests/test-graph/src/features/test_rust_hir_snapshot_adapter_fences_generations.ts b/tests/test-graph/src/features/test_rust_hir_snapshot_adapter_fences_generations.ts new file mode 100644 index 00000000..df5e221a --- /dev/null +++ b/tests/test-graph/src/features/test_rust_hir_snapshot_adapter_fences_generations.ts @@ -0,0 +1,765 @@ +import { TestValidator } from "@nestia/e2e"; +import { + GRAPH_EDGE_KINDS, + RUST_GRAPH_PRODUCER_COMMIT, + RustGraphCache, + RustGraphSnapshotAdapter, + type IRustGraphCacheState, + type IRustGraphShard, + type IRustGraphSnapshot, +} from "@samchon/graph"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +import { GraphPaths } from "../internal/GraphPaths.js"; + +const COMMIT = RUST_GRAPH_PRODUCER_COMMIT; + +export const test_rust_hir_snapshot_adapter_fences_generations = () => { + const root = GraphPaths.createTempDirectory("samchon-graph-rust-adapter-"); + fs.mkdirSync(path.join(root, "src")); + fs.writeFileSync(path.join(root, "src/lib.rs"), "pub fn answer() -> u8 { 42 }\n"); + + const adapter = new RustGraphSnapshotAdapter(root, COMMIT); + const initialRaw = snapshot({ nodeName: "answer" }); + const initial = adapter.prepare(initialRaw); + if (!initial.changed) throw new Error("initial Rust generation did not change"); + const initialSnapshot = adapter.store.apply(initial.frames); + initial.commit(initialSnapshot); + TestValidator.equals( + "a full HIR response becomes one validated graph generation", + [ + initial.mode, + adapter.hasPersistedSnapshot, + adapter.persistedCheckpoint?.generation, + initialSnapshot.nodes.map((node) => [node.name, node.kind, node.external]), + initialSnapshot.edges.map((edge) => edge.kind), + initialSnapshot.diagnostics.map((diagnostic) => diagnostic.severity), + initialSnapshot.coverage?.length, + initialSnapshot.unresolved?.length, + initialSnapshot.provenance.provider, + initialSnapshot.provenance.compilerVersion.startsWith("rustc 1.95.0"), + initialSnapshot.nodes.every((node) => node.id.startsWith("@v2/rust/")), + initialSnapshot.edges.every( + (edge) => + initialSnapshot.nodes.some((node) => node.id === edge.from) && + initialSnapshot.nodes.some((node) => node.id === edge.to), + ), + ], + [ + "initial", + true, + initialRaw.generation, + [ + ["dependency", "external_symbol", true], + ["answer", "function", false], + ], + ["calls"], + ["warning"], + GRAPH_EDGE_KINDS.length, + GRAPH_EDGE_KINDS.length - 1, + "samchon-rust-analyzer-hir", + true, + true, + true, + ], + ); + + const unchangedRaw = snapshot({ + base: initialRaw, + upserts: [], + sequence: 2, + }); + const unchanged = adapter.prepare(unchangedRaw); + if (unchanged.changed) throw new Error("Rust producer no-op changed the graph"); + TestValidator.equals( + "a producer no-op preserves the exact published object", + [unchanged.changed, unchanged.mode, unchanged.snapshot === initialSnapshot], + [false, "unchanged", true], + ); + + const incrementalRaw = snapshot({ + base: initialRaw, + nodeName: "edited_answer", + sequence: 3, + }); + const incremental = adapter.prepare(incrementalRaw); + if (!incremental.changed) throw new Error("incremental Rust generation did not change"); + const incrementalSnapshot = adapter.store.apply(incremental.frames); + incremental.commit(incrementalSnapshot); + TestValidator.equals( + "a producer delta advances only from its exact raw base", + [incremental.mode, incrementalSnapshot.nodes.some((node) => node.name === "edited_answer")], + ["incremental", true], + ); + + const rebuiltRaw = snapshot({ nodeName: "rebuilt", sequence: 4 }); + const rebuilt = adapter.prepare(rebuiltRaw); + if (!rebuilt.changed) throw new Error("rebuilt Rust generation did not change"); + const rebuiltSnapshot = adapter.store.apply(rebuilt.frames); + rebuilt.commit(rebuiltSnapshot); + TestValidator.equals("a full response in one universe is a rebuild", rebuilt.mode, "rebuild"); + + const reloadedRaw = snapshot({ + nodeName: "reloaded", + sequence: 5, + universe: digest("universe-2"), + }); + const reloaded = adapter.prepare(reloadedRaw); + if (!reloaded.changed) throw new Error("reloaded Rust generation did not change"); + const reloadedSnapshot = adapter.store.apply(reloaded.frames); + reloaded.commit(reloadedSnapshot); + TestValidator.equals("a full response in another universe is a reload", reloaded.mode, "reload"); + + const restored = new RustGraphSnapshotAdapter(root, COMMIT, reloaded.state); + TestValidator.equals( + "a validated consumer checkpoint restores raw and graph state", + [ + restored.store.current?.protocol?.generation, + restored.persistedCheckpoint?.generation, + restored.store.current?.nodes.map((node) => node.name), + ], + [reloadedRaw.generation, reloadedRaw.generation, ["dependency", "reloaded"]], + ); + restored.discardPersistedSnapshot(); + restored.discardPersistedSnapshot(); + TestValidator.equals( + "discarding a persisted checkpoint returns to an empty adapter", + [restored.hasPersistedSnapshot, restored.persistedCheckpoint], + [false, undefined], + ); + + assertAdapterRefusals(root, initialRaw, reloaded.state); + assertDeltaDeletionAndCrossShardRefusals(root); + assertOptionalProducerFields(root); + assertCacheFallback(root, reloaded.state); +}; + +function assertOptionalProducerFields(root: string): void { + const optional = snapshot({ nodeName: "optional" }); + optional.universe.configurations = []; + optional.upserts[0]!.edges[0]!.evidence = null; + optional.upserts[0]!.diagnostics[0]!.column = 7; + optional.upserts[0]!.diagnostics[0]!.severity = null; + optional.upserts[0]!.unresolved[0]!.candidates = [ + "rust-hir-v1|dependency", + "rust-hir-v1|unknown", + ]; + refresh(optional); + const adapter = new RustGraphSnapshotAdapter(root, COMMIT); + const prepared = adapter.prepare(optional); + if (!prepared.changed) throw new Error("optional Rust fixture did not change"); + const adapted = adapter.store.apply(prepared.frames); + TestValidator.equals( + "optional producer fields preserve absence, positions, and unresolved native identities", + [ + adapted.edges[0]?.evidence, + adapted.diagnostics[0]?.column, + adapted.diagnostics[0]?.severity, + adapted.unresolved?.[0]?.candidates?.some((candidate) => + candidate.endsWith("rust-hir-v1|unknown"), + ), + adapted.provenance.compilerVersion, + ], + [undefined, 7, undefined, true, "unavailable"], + ); + + const bundledUniverse = digest("bundled-universe"); + const bundledRaw = snapshot({ + universe: bundledUniverse, + upserts: [rawShard("bundled", bundledUniverse, "bundled:///rust/source")], + }); + const bundledAdapter = new RustGraphSnapshotAdapter(root, COMMIT); + const bundled = bundledAdapter.prepare(bundledRaw); + if (!bundled.changed) throw new Error("bundled Rust fixture did not change"); + TestValidator.predicate( + "bundled producer sources retain their URI identity", + bundledAdapter.store.apply(bundled.frames).sources.has("bundled:///rust/source"), + ); + + TestValidator.error("a snapshot without shards cannot establish coverage", () => + new RustGraphSnapshotAdapter(root, COMMIT).prepare(snapshot({ upserts: [] })), + ); +} + +function assertAdapterRefusals( + root: string, + valid: IRustGraphSnapshot, + state: IRustGraphCacheState, +): void { + const rejects = (label: string, mutate: (value: IRustGraphSnapshot) => void): void => { + TestValidator.error(label, () => { + const candidate = structuredClone(valid); + mutate(candidate); + new RustGraphSnapshotAdapter(root, COMMIT).prepare(candidate); + }); + }; + TestValidator.error("a non-object producer response is refused", () => + new RustGraphSnapshotAdapter(root, COMMIT).prepare(null as unknown as IRustGraphSnapshot), + ); + rejects("an unknown producer protocol is refused", (value) => { + value.protocolVersion = 2; + }); + rejects("a producer commit mismatch is refused", (value) => { + value.producer.commit = "wrong"; + }); + rejects("an empty producer version is refused", (value) => { + value.producer.version = ""; + }); + rejects("a malformed universe is refused", (value) => { + value.universe.digest = "wrong"; + }); + rejects("duplicate workspace roots are refused", (value) => { + value.universe.workspaceRoots = ["same", "same"]; + }); + rejects("a malformed generation envelope is refused", (value) => { + value.sequence = 0; + }); + rejects("malformed producer phase telemetry is refused", (value) => { + value.phases.totalMillis = -1; + }); + rejects("a malformed base generation is refused", (value) => { + value.baseGeneration = "wrong"; + }); + rejects("a non-canonical manifest is refused", (value) => { + value.manifest.push({ ...value.manifest[0]! }); + }); + rejects("a non-canonical delete list is refused", (value) => { + value.deletes = ["same", "same"]; + }); + rejects("a stale producer base is refused", (value) => { + value.baseGeneration = digest("stale"); + }); + TestValidator.error("a generation cannot lose its base", () => { + const adapter = new RustGraphSnapshotAdapter(root, COMMIT); + const prepared = adapter.prepare(structuredClone(valid)); + if (!prepared.changed) throw new Error("initial Rust generation did not change"); + prepared.commit(adapter.store.apply(prepared.frames)); + adapter.prepare(structuredClone(valid)); + }); + rejects("a missing delete is refused", (value) => { + value.baseGeneration = null; + value.deletes = ["missing"]; + }); + rejects("a shard delta cannot collide with a delete", (value) => { + value.deletes = [value.upserts[0]!.key]; + }); + rejects("a raw manifest mismatch is refused", (value) => { + value.manifest[0]!.digest = digest("wrong"); + }); + rejects("a raw generation mismatch is refused", (value) => { + value.generation = digest("wrong"); + }); + rejects("a raw shard key must match target and source", (value) => { + value.upserts[0]!.key = `wrong\0${value.upserts[0]!.source}`; + refresh(value); + }); + rejects("a raw shard digest mismatch is refused", (value) => { + value.upserts[0]!.digest = digest("wrong"); + }); + rejects("an empty raw shard key is refused", (value) => { + value.upserts[0]!.key = ""; + refresh(value); + }); + rejects("an empty raw node identity is refused", (value) => { + value.upserts[0]!.nodes[0]!.id = ""; + refresh(value); + }); + rejects("a foreign raw node identity is refused", (value) => { + value.upserts[0]!.nodes[0]!.id = "foreign|node"; + refresh(value); + }); + rejects("malformed raw node flags are refused", (value) => { + (value.upserts[0]!.nodes[0] as unknown as { external: string }).external = + "false"; + refresh(value); + }); + rejects("malformed raw exported flags are refused", (value) => { + (value.upserts[0]!.nodes[0] as unknown as { exported: string }).exported = + "true"; + refresh(value); + }); + rejects("a malformed qualified node name is refused", (value) => { + ( + value.upserts[0]!.nodes[0] as unknown as { qualifiedName: number } + ).qualifiedName = 1; + refresh(value); + }); + rejects("a malformed node signature is refused", (value) => { + (value.upserts[0]!.nodes[0] as unknown as { signature: number }).signature = + 1; + refresh(value); + }); + rejects("malformed raw shard arrays are refused", (value) => { + (value.upserts[0] as unknown as { nodes: null }).nodes = null; + refresh(value); + }); + rejects("an unknown raw node kind is refused", (value) => { + value.upserts[0]!.nodes[0]!.kind = "unknown"; + refresh(value); + }); + rejects("an unsupported raw edge family is refused", (value) => { + value.upserts[0]!.edges[0]!.kind = "renders"; + refresh(value); + }); + rejects("a foreign raw edge identity is refused", (value) => { + value.upserts[0]!.edges[0]!.from = "foreign|node"; + refresh(value); + }); + rejects("an invalid diagnostic line is refused", (value) => { + value.upserts[0]!.diagnostics[0]!.line = 0; + refresh(value); + }); + rejects("an invalid diagnostic column is refused", (value) => { + value.upserts[0]!.diagnostics[0]!.column = 0; + refresh(value); + }); + rejects("an invalid diagnostic severity is refused", (value) => { + value.upserts[0]!.diagnostics[0]!.severity = "fatal"; + refresh(value); + }); + rejects("an invalid diagnostic code is refused", (value) => { + value.upserts[0]!.diagnostics[0]!.code = ""; + refresh(value); + }); + rejects("an invalid diagnostic message is refused", (value) => { + value.upserts[0]!.diagnostics[0]!.message = ""; + refresh(value); + }); + rejects("a duplicate coverage family is refused", (value) => { + value.upserts[0]!.coverage.push({ ...value.upserts[0]!.coverage[0]! }); + refresh(value); + }); + rejects("an incomplete coverage matrix is refused", (value) => { + value.upserts[0]!.coverage.pop(); + refresh(value); + }); + rejects("a malformed unresolved boundary is refused", (value) => { + value.upserts[0]!.unresolved[0]!.candidates = ["same", "same"]; + refresh(value); + }); + rejects("a foreign unresolved candidate identity is refused", (value) => { + value.upserts[0]!.unresolved[0]!.candidates = ["foreign|node"]; + refresh(value); + }); + rejects("an invalid evidence range is refused", (value) => { + value.upserts[0]!.nodes[0]!.evidence!.startLine = 0; + refresh(value); + }); + rejects("a reversed evidence range is refused", (value) => { + value.upserts[0]!.nodes[0]!.evidence!.startColumn = 5; + value.upserts[0]!.nodes[0]!.evidence!.endColumn = 4; + refresh(value); + }); + const corruptStates: Array<[string, (value: IRustGraphCacheState) => void]> = [ + ["persisted producer identity", (value) => (value.producerCommit = "wrong")], + ["persisted raw shard payload", (value) => (value.rawShards[0]!.digest = digest("bad"))], + [ + "persisted raw shard identity", + (value) => { + value.rawShards[0]!.key = "wrong"; + value.rawShards[0]!.digest = rawShardDigest(value.rawShards[0]!); + }, + ], + ["persisted producer checkpoint", (value) => value.checkpoint.manifest.pop()], + [ + "persisted graph checkpoint", + (value) => ((value.frames[1]! as { type: string }).type = "hello"), + ], + [ + "persisted graph generation", + (value) => { + const generation = digest("graph-only-generation"); + (value.frames[1]! as { generation: string }).generation = generation; + (value.frames.at(-1)! as { generation: string }).generation = generation; + }, + ], + ]; + for (const [label, mutate] of corruptStates) { + TestValidator.error(`${label} corruption is refused`, () => { + const candidate = structuredClone(state); + mutate(candidate); + new RustGraphSnapshotAdapter(root, COMMIT, candidate); + }); + } +} + +function assertDeltaDeletionAndCrossShardRefusals(root: string): void { + const universe = digest("multi-shard-universe"); + const first = rawShard("first", universe, "src/first.rs", "-first"); + const second = rawShard("second", universe, "src/second.rs", "-second"); + const full = snapshot({ universe, upserts: [first, second] }); + const adapter = new RustGraphSnapshotAdapter(root, COMMIT); + const prepared = adapter.prepare(full); + if (!prepared.changed) throw new Error("multi-shard Rust fixture did not change"); + prepared.commit(adapter.store.apply(prepared.frames)); + const deleted = snapshot({ + base: full, + universe, + upserts: [], + deletes: [second.key], + sequence: 2, + }); + const deletion = adapter.prepare(deleted); + if (!deletion.changed) throw new Error("Rust shard deletion did not change"); + const afterDeletion = deletion.commit(adapter.store.apply(deletion.frames)); + TestValidator.equals( + "an incremental Rust generation deletes exactly its named shard", + afterDeletion.nodes.map((node) => node.name), + ["dependency", "first"], + ); + + const initialized = (): RustGraphSnapshotAdapter => { + const value = new RustGraphSnapshotAdapter(root, COMMIT); + const initial = value.prepare(structuredClone(full)); + if (!initial.changed) throw new Error("multi-shard Rust fixture did not change"); + initial.commit(value.store.apply(initial.frames)); + return value; + }; + TestValidator.error("one delta cannot delete and upsert the same shard", () => { + initialized().prepare( + snapshot({ + base: full, + universe, + upserts: [structuredClone(first)], + deletes: [first.key], + sequence: 2, + }), + ); + }); + TestValidator.error("an edge endpoint absent from every raw shard is refused", () => { + const bad = structuredClone(full); + bad.upserts[0]!.edges[0]!.to = "rust-hir-v1|absent"; + refresh(bad); + new RustGraphSnapshotAdapter(root, COMMIT).prepare(bad); + }); + TestValidator.error("two raw shards cannot disagree about coverage", () => { + const badSecond = structuredClone(second); + badSecond.coverage[0]!.state = "complete"; + badSecond.digest = rawShardDigest(badSecond); + new RustGraphSnapshotAdapter(root, COMMIT).prepare( + snapshot({ universe, upserts: [structuredClone(first), badSecond] }), + ); + }); + TestValidator.error("one native node identity cannot describe two declarations", () => { + const badSecond = structuredClone(second); + badSecond.nodes[0]!.id = first.nodes[0]!.id; + badSecond.digest = rawShardDigest(badSecond); + new RustGraphSnapshotAdapter(root, COMMIT).prepare( + snapshot({ universe, upserts: [structuredClone(first), badSecond] }), + ); + }); + TestValidator.error("external node facts must agree across raw shards", () => { + const badSecond = structuredClone(second); + badSecond.nodes[1]!.signature = "fn(u8)"; + badSecond.digest = rawShardDigest(badSecond); + new RustGraphSnapshotAdapter(root, COMMIT).prepare( + snapshot({ universe, upserts: [structuredClone(first), badSecond] }), + ); + }); +} + +function assertCacheFallback(root: string, state: IRustGraphCacheState): void { + const cacheRoot = GraphPaths.createTempDirectory("samchon-graph-rust-cache-"); + const props = { root, producerCommit: COMMIT, cacheRoot }; + const sequence = (state.frames.at(-1) as { sequence: number }).sequence; + TestValidator.equals("an absent Rust checkpoint cache is empty", RustGraphCache.load(props), undefined); + TestValidator.error("invalid persisted coordinates are refused", () => + RustGraphCache.save(props, 0, state.checkpoint.generation, state), + ); + RustGraphCache.save(props, sequence, state.checkpoint.generation, state); + const directory = findGenerationDirectory(cacheRoot); + const invalidCacheFiles = [ + `999999999999999999999999-${state.checkpoint.generation}.json`, + `${String(sequence + 102)}-${state.checkpoint.generation}.json`, + `${String(sequence + 101)}-${state.checkpoint.generation}.json`, + ]; + fs.writeFileSync(path.join(directory, invalidCacheFiles[0]!), "{}"); + fs.writeFileSync(path.join(directory, invalidCacheFiles[1]!), ""); + fs.writeFileSync( + path.join(directory, invalidCacheFiles[2]!), + JSON.stringify({ ...state, frames: [null] }), + ); + TestValidator.equals( + "a saved Rust checkpoint skips invalid coordinates, empty files, and malformed commit frames", + [ + RustGraphCache.load(props)?.checkpoint.generation, + RustGraphCache.load(props, () => false), + ], + [state.checkpoint.generation, undefined], + ); + for (const file of invalidCacheFiles) fs.rmSync(path.join(directory, file)); + RustGraphCache.save(props, sequence, state.checkpoint.generation, state); + fs.writeFileSync( + path.join(directory, `${String(sequence + 1)}-${digest("second")}.json`), + "{", + ); + TestValidator.equals( + "a torn newest generation falls back to the prior immutable checkpoint", + RustGraphCache.load(props)?.checkpoint.generation, + state.checkpoint.generation, + ); + fs.rmSync(path.join(directory, `${String(sequence + 1)}-${digest("second")}.json`)); + const second = cacheStateAtSequence(state, sequence + 1); + const third = cacheStateAtSequence(state, sequence + 2); + RustGraphCache.save(props, sequence + 1, state.checkpoint.generation, second); + RustGraphCache.save(props, sequence + 2, state.checkpoint.generation, third); + TestValidator.equals( + "the immutable cache retains only its two newest validated generations", + fs + .readdirSync(directory) + .filter((file) => file.endsWith(".json")) + .sort(), + [ + `${String(sequence + 1)}-${state.checkpoint.generation}.json`, + `${String(sequence + 2)}-${state.checkpoint.generation}.json`, + ], + ); + const mutableFs = fs as typeof fs & { renameSync: typeof fs.renameSync }; + const renameSync = mutableFs.renameSync; + try { + mutableFs.renameSync = (temporary, file) => { + fs.copyFileSync(temporary, file); + throw new Error("fixture concurrent cache winner"); + }; + const winner = cacheStateAtSequence(state, sequence + 3); + RustGraphCache.save(props, sequence + 3, state.checkpoint.generation, winner); + mutableFs.renameSync = () => { + throw new Error("fixture cache rename failure"); + }; + TestValidator.error("a cache rename failure without a winner is surfaced", () => { + const losing = cacheStateAtSequence(state, sequence + 4); + RustGraphCache.save(props, sequence + 4, state.checkpoint.generation, losing); + }); + } finally { + mutableFs.renameSync = renameSync; + } + fs.writeFileSync( + path.join(directory, `.1-${String(sequence)}-${digest("temporary")}.tmp`), + "torn", + ); + fs.writeFileSync(path.join(directory, "unrelated.txt"), "keep"); + RustGraphCache.clear(props); + TestValidator.equals( + "cache cleanup removes owned generations and temporaries only", + fs.readdirSync(directory), + ["unrelated.txt"], + ); + RustGraphCache.clear({ ...props, root: path.join(root, "absent") }); + assertDefaultCacheRootBranches(root); +} + +function cacheStateAtSequence( + state: IRustGraphCacheState, + sequence: number, +): IRustGraphCacheState { + const output = structuredClone(state); + for (const frame of output.frames) { + if (frame.type === "begin" || frame.type === "commit") frame.sequence = sequence; + } + return output; +} + +function assertDefaultCacheRootBranches(root: string): void { + const names = ["SAMCHON_GRAPH_CACHE_DIR", "LOCALAPPDATA", "XDG_CACHE_HOME"] as const; + const prior = new Map(names.map((name) => [name, process.env[name]])); + try { + delete process.env.SAMCHON_GRAPH_CACHE_DIR; + process.env.LOCALAPPDATA = GraphPaths.createTempDirectory("samchon-rust-local-cache-"); + delete process.env.XDG_CACHE_HOME; + RustGraphCache.clear({ root, producerCommit: COMMIT }); + + delete process.env.LOCALAPPDATA; + process.env.XDG_CACHE_HOME = GraphPaths.createTempDirectory("samchon-rust-xdg-cache-"); + RustGraphCache.clear({ root, producerCommit: COMMIT }); + + process.env.XDG_CACHE_HOME = "relative-cache"; + RustGraphCache.clear({ root, producerCommit: COMMIT }); + } finally { + for (const name of names) { + const value = prior.get(name); + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + } +} + +interface ISnapshotOptions { + base?: IRustGraphSnapshot; + nodeName?: string; + sequence?: number; + universe?: string; + upserts?: IRustGraphShard[]; + deletes?: string[]; +} + +function snapshot(options: ISnapshotOptions = {}): IRustGraphSnapshot { + const universe = options.universe ?? options.base?.universe.digest ?? digest("universe-1"); + const shard = rawShard(options.nodeName ?? "answer", universe); + const rawShards = options.upserts ?? [shard]; + const next = new Map(); + if (options.base !== undefined) { + for (const prior of options.base.upserts) next.set(prior.key, structuredClone(prior)); + } + for (const deleted of options.deletes ?? []) next.delete(deleted); + for (const upsert of rawShards) next.set(upsert.key, structuredClone(upsert)); + const manifest = [...next.values()] + .sort((left, right) => compare(left.key, right.key)) + .map((entry) => ({ key: entry.key, digest: entry.digest })); + const generation = digest({ universe, manifest }); + return { + protocolVersion: 1, + schemaVersion: 1, + producer: { + name: "samchon-rust-analyzer", + version: "1.95.0", + commit: COMMIT, + }, + universe: { + digest: universe, + target: "app", + workspaceRoots: ["."], + toolchains: ["stable"], + configurations: [ + "rustc-version=rustc 1.95.0 (fixture)\ncommit-hash: fixture\nhost: fixture", + ], + }, + sequence: options.sequence ?? 1, + generation, + baseGeneration: options.base?.generation ?? null, + upserts: rawShards.map((entry) => structuredClone(entry)), + deletes: [...(options.deletes ?? [])].sort(compare), + manifest, + phases: { + semanticMillis: 1, + shardMillis: 2, + encodeMillis: 3, + totalMillis: 6, + cacheHit: rawShards.length === 0, + }, + }; +} + +function rawShard( + nodeName: string, + _universe: string, + source = "src/lib.rs", + suffix = "", +): IRustGraphShard { + const evidence = { + file: source, + startLine: 1, + startColumn: 1, + endLine: 1, + endColumn: 10, + }; + const shard: IRustGraphShard = { + key: `app\0${source}`, + source, + checkerDigest: digest(`checker-${source}-${nodeName}`), + interfaceFingerprint: digest(`interface-${nodeName}`), + digest: "", + nodes: [ + { + id: `rust-hir-v1|answer${suffix}`, + kind: "function", + name: nodeName, + qualifiedName: `fixture::${nodeName}`, + file: source, + external: false, + exported: true, + signature: "fn() -> u8", + evidence, + }, + { + id: "rust-hir-v1|dependency", + kind: "function", + name: "dependency", + qualifiedName: null, + file: "bundled:///rust/dependencies", + external: true, + exported: false, + signature: null, + evidence: null, + }, + ], + edges: [ + { + from: `rust-hir-v1|answer${suffix}`, + to: "rust-hir-v1|dependency", + kind: "calls", + evidence, + }, + ], + diagnostics: [ + { + file: source, + line: 1, + column: null, + code: "fixture", + message: "fixture warning", + severity: "warning", + }, + ], + coverage: GRAPH_EDGE_KINDS.map((family) => ({ + family, + state: family === "renders" ? "unsupported" : "partial", + })), + unresolved: GRAPH_EDGE_KINDS.filter((family) => family !== "renders").map( + (family) => ({ + family, + evidence, + reason: "provider-gap", + candidates: ["rust-hir-v1|dependency"], + }), + ), + }; + shard.digest = rawShardDigest(shard); + return shard; +} + +function refresh(value: IRustGraphSnapshot): void { + for (const shard of value.upserts) shard.digest = rawShardDigest(shard); + value.manifest = value.upserts + .map((shard) => ({ key: shard.key, digest: shard.digest })) + .sort((left, right) => compare(left.key, right.key)); + value.generation = digest({ universe: value.universe.digest, manifest: value.manifest }); +} + +function rawShardDigest(shard: IRustGraphShard): string { + return digest({ + key: shard.key, + source: shard.source, + checkerDigest: shard.checkerDigest, + interfaceFingerprint: shard.interfaceFingerprint, + nodes: shard.nodes, + edges: shard.edges, + diagnostics: shard.diagnostics, + coverage: shard.coverage, + unresolved: shard.unresolved, + }); +} + +function digest(value: unknown): string { + return createHash("sha256").update(canonical(value)).digest("hex"); +} + +function canonical(value: unknown): string { + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; + const object = value as Record; + return `{${Object.keys(object) + .sort(compare) + .map((key) => `${JSON.stringify(key)}:${canonical(object[key])}`) + .join(",")}}`; +} + +function compare(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function findGenerationDirectory(cacheRoot: string): string { + const rust = path.join(cacheRoot, "rust", COMMIT); + return path.join(rust, fs.readdirSync(rust)[0]!); +} diff --git a/tests/test-graph/src/features/test_standard_providers_execute_their_exact_contracts.ts b/tests/test-graph/src/features/test_standard_providers_execute_their_exact_contracts.ts index 68cee4e3..77510b6c 100644 --- a/tests/test-graph/src/features/test_standard_providers_execute_their_exact_contracts.ts +++ b/tests/test-graph/src/features/test_standard_providers_execute_their_exact_contracts.ts @@ -5,9 +5,10 @@ import { type GraphEdgeKind, type IBulkGraphSession, type IGraphProvider, + RUST_GRAPH_PRODUCER_COMMIT, goGraphProvider, luaGraphProvider, - rustScipProvider, + rustGraphProvider, standardScipProviders, standardSidecarProviders, } from "@samchon/graph"; @@ -923,7 +924,7 @@ function assertFixtureRegistryCoverage(): void { ttscGraphProvider, goGraphProvider, luaGraphProvider, - rustScipProvider, + rustGraphProvider, ...standardScipProviders, ...standardSidecarProviders, ] @@ -1011,6 +1012,7 @@ async function assertHeuristicTwinFails( provider: IGraphProvider, command: IGraphProvider.ICommand, root: string, + relationship: GraphEdgeKind = "references", ): Promise { const prior = process.env.SAMCHON_GRAPH_FIXTURE_MODE; process.env.SAMCHON_GRAPH_FIXTURE_MODE = "heuristic"; @@ -1025,7 +1027,7 @@ async function assertHeuristicTwinFails( const refreshed = await session.refresh(); const failures = Conformance.check( refreshed.snapshot, - expectationsForProvider(root, provider), + expectationsForProvider(root, provider, relationship), ).failures; TestValidator.predicate( `${provider.name} rejects only the common comment-only semantic negative twin`, @@ -1113,8 +1115,9 @@ function expectationsOf( function expectationsForProvider( root: string, provider: IGraphProvider, + relationship: GraphEdgeKind = "references", ): readonly Conformance.IExpectation[] { - return expectationsOf(root, provider.languages).filter( + return expectationsOf(root, provider.languages, relationship).filter( (expectation) => !("edge" in expectation) || provider.facts.includes(expectation.edge.kind), @@ -1238,23 +1241,26 @@ async function assertRemainingRegisteredFixtures(root: string): Promise { }; await assertRegisteredFixture(luaGraphProvider, luaCommand, root); - // The arguments `resolveRustScipCommand` puts in front of the session's own, - // not an invocation that skips them. A synthetic command without them opens - // the same session against a producer that was never asked the way the - // provider asks it, which is how a wrong subcommand would go unnoticed here - // and be found only by a real lane. + // The HIR fixture speaks the same resident snapshot protocol as the pinned + // fork and carries the shared positive/negative semantic corpus. const rustCommand: IGraphProvider.ICommand = { command: process.execPath, args: [ - GraphPaths.fakeStandardProvider, - "--producer=rust-analyzer", - "scip", - ".", - "--exclude-vendored-libraries", + GraphPaths.fakeRustGraphServer, + `--commit=${RUST_GRAPH_PRODUCER_COMMIT}`, + "--conformance", ], }; - await assertRegisteredFixture(rustScipProvider, rustCommand, root); - await assertHeuristicTwinFails(rustScipProvider, rustCommand, root); + await assertRegisteredFixture(rustGraphProvider, rustCommand, root, "calls"); + await assertHeuristicTwinFails( + rustGraphProvider, + { + ...rustCommand, + args: [...rustCommand.args, "--conformance-heuristic"], + }, + root, + "calls", + ); } async function assertRegisteredFixture( diff --git a/tests/test-graph/src/internal/GraphPaths.ts b/tests/test-graph/src/internal/GraphPaths.ts index ca0136da..ed37a546 100644 --- a/tests/test-graph/src/internal/GraphPaths.ts +++ b/tests/test-graph/src/internal/GraphPaths.ts @@ -63,6 +63,7 @@ export const GraphPaths = { createTempDirectory, fakeCmake: path.join(repositoryRoot, "tests", "test-graph", "src", "internal", "fake-cmake.cjs"), fakeLspServer: path.join(repositoryRoot, "tests", "test-graph", "src", "internal", "fake-lsp-server.cjs"), + fakeRustGraphServer: path.join(repositoryRoot, "tests", "test-graph", "src", "internal", "fake-rust-graph-server.cjs"), fakeTtscGraphServer: path.join(repositoryRoot, "tests", "test-graph", "src", "internal", "fake-ttscgraph-server.cjs"), fakePub: path.join(repositoryRoot, "tests", "test-graph", "src", "internal", "fake-pub.cjs"), fakeScipIndexer: path.join(repositoryRoot, "tests", "test-graph", "src", "internal", "fake-scip-indexer.cjs"), diff --git a/tests/test-graph/src/internal/fake-rust-graph-server.cjs b/tests/test-graph/src/internal/fake-rust-graph-server.cjs new file mode 100644 index 00000000..8b198013 --- /dev/null +++ b/tests/test-graph/src/internal/fake-rust-graph-server.cjs @@ -0,0 +1,390 @@ +#!/usr/bin/env node +"use strict"; + +const crypto = require("node:crypto"); +const fs = require("node:fs"); + +const args = process.argv.slice(2); +const valueOf = (prefix) => args.find((arg) => arg.startsWith(prefix))?.slice(prefix.length); +const commit = valueOf("--commit=") ?? "95f4050923a1d80a29147f4b66614c843c26b183"; +const requestLog = valueOf("--request-log="); +const retrySentMarker = valueOf("--retry-sent-marker="); +const initializeMarker = valueOf("--initialize-marker="); +const initializeDelay = Number(valueOf("--initialize-delay=") ?? 0); +const marker = valueOf("--marker="); +let retry = Number(valueOf("--retry=") ?? 0); +let contentModified = Number(valueOf("--content-modified=") ?? 0); +const rejectCheckpoint = args.includes("--reject-checkpoint"); +const hang = args.includes("--hang"); +const internalError = args.includes("--internal-error"); +const malformed = args.includes("--malformed"); +const configurationWithoutItems = args.includes("--configuration-without-items"); +const expectInitializationOptions = args.includes("--expect-initialization-options"); +const initializeError = args.includes("--initialize-error"); +const failVersion = args.includes("--fail-version"); +const conformance = args.includes("--conformance"); +const conformanceHeuristic = args.includes("--conformance-heuristic"); + +if (args.includes("--version")) { + if (failVersion) process.exit(7); + process.stdout.write(`rust-analyzer 1.95.0 (${commit.slice(0, 9)} 2026-08-01)\n`); + process.exit(0); +} + +const EDGE_KINDS = [ + "contains", + "exports", + "imports", + "calls", + "accesses", + "instantiates", + "type_ref", + "extends", + "implements", + "overrides", + "dispatches", + "decorates", + "renders", + "tests", + "references", +]; +const universe = sha256("fixture-rust-universe"); +const evidence = conformance + ? { + file: "src/lib.rs", + startLine: 2, + startColumn: 8, + endLine: 2, + endColumn: 14, + } + : { + file: "src/lib.rs", + startLine: 1, + startColumn: 1, + endLine: 1, + endColumn: 20, + }; +const calleeEvidence = { + file: "src/lib.rs", + startLine: 3, + startColumn: 8, + endLine: 3, + endColumn: 14, +}; +const callEvidence = { + file: "src/lib.rs", + startLine: 2, + startColumn: 19, + endLine: 2, + endColumn: 25, +}; +const nodes = conformance + ? [ + { + id: "rust-hir-v1|fixture-caller", + kind: "function", + name: "caller", + qualifiedName: "fixture::caller", + file: "src/lib.rs", + external: false, + exported: true, + signature: "fn()", + evidence, + }, + { + id: "rust-hir-v1|fixture-callee", + kind: "function", + name: "callee", + qualifiedName: "fixture::callee", + file: "src/lib.rs", + external: false, + exported: true, + signature: "fn()", + evidence: calleeEvidence, + }, + ...(conformanceHeuristic + ? [ + { + id: "rust-hir-v1|fixture-comment", + kind: "function", + name: "mentionedInComment", + qualifiedName: "fixture::mentionedInComment", + file: "src/lib.rs", + external: false, + exported: false, + signature: "fn()", + evidence: { + file: "src/lib.rs", + startLine: 1, + startColumn: 4, + endLine: 1, + endColumn: 22, + }, + }, + ] + : []), + ] + : [ + { + id: "rust-hir-v1|fixture-answer", + kind: "function", + name: "answer", + qualifiedName: "fixture::answer", + file: "src/lib.rs", + external: false, + exported: true, + signature: "fn() -> u8", + evidence, + }, + { + id: "rust-hir-v1|fixture-dependency", + kind: "function", + name: "dependency", + qualifiedName: null, + file: "bundled:///rust/dependencies", + external: true, + exported: false, + signature: null, + evidence: null, + }, + ]; +const edges = conformance + ? [ + { + from: "rust-hir-v1|fixture-caller", + to: "rust-hir-v1|fixture-callee", + kind: "calls", + evidence: callEvidence, + }, + ...(conformanceHeuristic + ? [ + { + from: "rust-hir-v1|fixture-caller", + to: "rust-hir-v1|fixture-comment", + kind: "calls", + evidence: { + file: "src/lib.rs", + startLine: 1, + startColumn: 4, + endLine: 1, + endColumn: 22, + }, + }, + ] + : []), + ] + : [ + { + from: "rust-hir-v1|fixture-answer", + to: "rust-hir-v1|fixture-dependency", + kind: "calls", + evidence, + }, + ]; +const shard = { + key: "app\u0000src/lib.rs", + source: "src/lib.rs", + checkerDigest: sha256(conformanceHeuristic ? "fixture-heuristic" : "fixture-source"), + interfaceFingerprint: sha256( + conformanceHeuristic ? "fixture-heuristic-interface" : "fixture-interface", + ), + digest: "", + nodes, + edges, + diagnostics: [ + { + file: "src/lib.rs", + line: 1, + column: null, + code: "fixture", + message: "fixture diagnostic", + severity: "warning", + }, + ], + coverage: EDGE_KINDS.map((family) => ({ + family, + state: family === "renders" ? "unsupported" : "partial", + })), + unresolved: EDGE_KINDS.filter((family) => family !== "renders").map((family) => ({ + family, + evidence, + reason: "provider-gap", + candidates: [], + })), +}; +shard.digest = sha256({ + key: shard.key, + source: shard.source, + checkerDigest: shard.checkerDigest, + interfaceFingerprint: shard.interfaceFingerprint, + nodes: shard.nodes, + edges: shard.edges, + diagnostics: shard.diagnostics, + coverage: shard.coverage, + unresolved: shard.unresolved, +}); +const manifest = [{ key: shard.key, digest: shard.digest }]; +const generation = sha256({ universe, manifest }); +let sequence = 0; +let buffer = Buffer.alloc(0); +let initializeRequest; +let serverRequestPhase = 0; + +process.stdin.on("data", (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + for (;;) { + const headerEnd = buffer.indexOf("\r\n\r\n"); + if (headerEnd < 0) return; + const header = buffer.slice(0, headerEnd).toString("ascii"); + const length = Number(/Content-Length:\s*(\d+)/i.exec(header)?.[1]); + const bodyStart = headerEnd + 4; + const bodyEnd = bodyStart + length; + if (!Number.isSafeInteger(length) || buffer.length < bodyEnd) return; + const message = JSON.parse(buffer.slice(bodyStart, bodyEnd).toString("utf8")); + buffer = buffer.slice(bodyEnd); + handle(message); + } +}); + +process.stdin.on("end", finish); + +function handle(message) { + if (message.method === "initialize") { + if ( + expectInitializationOptions && + JSON.stringify(message.params?.initializationOptions) !== '{"fixture":true}' + ) { + process.exitCode = 33; + } + initializeRequest = message.id; + if (initializeMarker !== undefined) fs.writeFileSync(initializeMarker, "started"); + if (initializeDelay > 0) { + setTimeout(requestConfiguration, initializeDelay); + return; + } + requestConfiguration(); + return; + } + if (message.id === 9001 && serverRequestPhase === 0) { + const expected = configurationWithoutItems ? "[]" : "[null,null]"; + if (JSON.stringify(message.result) !== expected) process.exitCode = 31; + serverRequestPhase = 1; + send({ jsonrpc: "2.0", id: 9002, method: "fixture/unknown", params: {} }); + return; + } + if (message.id === 9002 && serverRequestPhase === 1) { + if (message.result !== null) process.exitCode = 32; + serverRequestPhase = 2; + if (initializeError) { + sendError(initializeRequest, -32603, "fixture initialize failure"); + } else { + send({ jsonrpc: "2.0", id: initializeRequest, result: { capabilities: {} } }); + } + return; + } + if (message.method === "samchon/graphSnapshot") { + if (requestLog !== undefined) fs.appendFileSync(requestLog, `${JSON.stringify(message.params)}\n`); + if (hang) return; + if (internalError) { + sendError(message.id, -32603, "fixture internal failure"); + return; + } + if (message.params?.checkpoint !== undefined && rejectCheckpoint) { + sendError(message.id, -32802, "persisted checkpoint rejected"); + return; + } + if (retry > 0) { + retry -= 1; + sendError(message.id, -32802, "fixture index is not ready"); + if (retrySentMarker !== undefined) fs.writeFileSync(retrySentMarker, "sent"); + return; + } + if (contentModified > 0) { + contentModified -= 1; + sendError(message.id, -32801, "fixture content changed"); + return; + } + sequence += 1; + const base = message.params?.checkpoint?.generation ?? message.params?.knownGeneration; + const result = snapshot(base === generation ? generation : null); + if (malformed) result.producer.commit = "wrong"; + send({ jsonrpc: "2.0", id: message.id, result }); + return; + } + if (message.method === "shutdown") { + send({ jsonrpc: "2.0", id: message.id, result: null }); + return; + } + if (message.method === "exit") finish(); +} + +function requestConfiguration() { + send({ + jsonrpc: "2.0", + id: 9001, + method: "workspace/configuration", + params: configurationWithoutItems + ? {} + : { items: [{ section: "rust-analyzer" }, { section: "rust-analyzer.cargo" }] }, + }); +} + +function snapshot(baseGeneration) { + return { + protocolVersion: 1, + schemaVersion: 1, + producer: { name: "samchon-rust-analyzer", version: "1.95.0", commit }, + universe: { + digest: universe, + target: "app", + workspaceRoots: ["."], + toolchains: ["stable"], + configurations: ["rustc-version=rustc 1.95.0 (fixture)\nhost: fixture"], + }, + sequence, + generation, + baseGeneration, + upserts: baseGeneration === null ? [shard] : [], + deletes: [], + manifest, + phases: { + semanticMillis: baseGeneration === null ? 1 : 0, + shardMillis: baseGeneration === null ? 1 : 0, + encodeMillis: 1, + totalMillis: baseGeneration === null ? 3 : 1, + cacheHit: baseGeneration !== null, + }, + }; +} + +function sendError(id, code, message) { + send({ jsonrpc: "2.0", id, error: { code, message, data: { fixture: true } } }); +} + +function send(message) { + const body = Buffer.from(JSON.stringify(message), "utf8"); + process.stdout.write(`Content-Length: ${body.length}\r\n\r\n`); + process.stdout.write(body); +} + +function sha256(value) { + return crypto.createHash("sha256").update(canonical(value)).digest("hex"); +} + +function canonical(value) { + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; + return `{${Object.keys(value) + .sort(compare) + .map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`) + .join(",")}}`; +} + +function compare(left, right) { + return left < right ? -1 : left > right ? 1 : 0; +} + +function finish() { + if (marker !== undefined) fs.writeFileSync(marker, "closed"); + process.exit(process.exitCode ?? 0); +} From 6f6ed7cd6fa03403ebbfa8740f18faf8ef6f8dc6 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Sun, 2 Aug 2026 01:10:24 +0900 Subject: [PATCH 26/52] fix: complete resident Rust HIR snapshots Close #72: [Bulk index][Rust] Export rust-analyzer HIR snapshots beyond SCIP --- README.md | 2 +- docs/provider-support.json | 4 +- .../rust/RUST_GRAPH_PRODUCER_COMMIT.ts | 2 +- .../graph/src/provider/rust/RustGraphCache.ts | 3 + .../src/provider/rust/RustGraphClient.ts | 7 +- .../provider/rust/RustGraphSnapshotAdapter.ts | 51 +++++--- tests/experiment/README.md | 2 +- tests/experiment/src/catalog.mjs | 11 +- tests/experiment/src/setup-language.mjs | 87 ++++++++++++-- ...st_experiment_corpora_are_commit_pinned.ts | 16 ++- ...lient_restores_retries_and_fails_closed.ts | 47 +++++++- ...hir_snapshot_adapter_fences_generations.ts | 113 +++++++++++++++++- ...providers_execute_their_exact_contracts.ts | 9 ++ .../src/internal/fake-rust-graph-server.cjs | 6 +- 14 files changed, 316 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 2ea2f6bf..62b1ccee 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ The troubleshooting table names the ordinary language-server/static fallback for | `ttscgraph` | Install a ttsc release that supports graph snapshot protocol v1. `ttsc@0.23.0` provides the ordinary `ttscserver` fallback but predates this strict protocol. | [ttsc 0.23.0 legacy release](https://www.npmjs.com/package/ttsc/v/0.23.0), [native shard producer PR](https://github.com/samchon/ttsc/pull/1056) | `ttscgraph`, `ttscserver` | — | `TTSC_GRAPH_BINARY` | Absolute `TTSC_GRAPH_BINARY`, target-project `ttsc` package/binary, target-project `.bin`, then PATH/global compatibility fallback. | A matching ttsc/TypeScript project and tsconfig/jsconfig/package inputs. | `linux`, `macos`, `windows` | | `samchon-graph-go` | Go 1.25+; the package ships the Go exporter source. Install corroboration with `go install github.com/scip-code/scip-go/cmd/scip-go@v0.2.7`. | [Go downloads](https://go.dev/dl/), [scip-go 0.2.7 source](https://github.com/scip-code/scip-go/tree/v0.2.7) | `samchon-graph-go`, `go`, `scip-go` | — | `SAMCHON_GRAPH_GO`, `SAMCHON_GRAPH_GO_TOOLCHAIN`, `SAMCHON_GRAPH_SCIP_GO` | Project/PATH `samchon-graph-go`, then the shipped source runner through Go; absolute environment overrides take precedence. | Go workspace/module inputs, selected GOOS/GOARCH/cgo environment, embedded files and vendored inputs. | `linux`, `macos`, `windows` | | `samchon-graph-lua` | Install `lua-language-server`; the package ships `sidecars/lua/export.lua`. | [LuaLS releases](https://github.com/LuaLS/lua-language-server/releases) | `lua-language-server` | — | `SAMCHON_GRAPH_LUA`, `SAMCHON_GRAPH_LUA_EXPORTER` | Absolute `SAMCHON_GRAPH_LUA`, then project/PATH LuaLS; the shipped exporter may be replaced by `SAMCHON_GRAPH_LUA_EXPORTER`. | LuaLS workspace configuration and the shipped readable exporter. | `linux`, `macos`, `windows` | -| `samchon-rust-analyzer-hir` | Build the `samchon/rust-analyzer` graph-snapshot fork at commit `3e8db3829e471b6af9acd1f14052e641fb81c4fd`; point `SAMCHON_GRAPH_RUST_ANALYZER_HIR` at that binary or install it as `samchon-rust-analyzer`. | [native HIR graph producer PR](https://github.com/samchon/rust-analyzer/pull/1), [rust-analyzer build instructions](https://rust-analyzer.github.io/book/contributing.html) | `samchon-rust-analyzer`, `rust-analyzer` | — | `SAMCHON_GRAPH_RUST_ANALYZER_HIR` | Absolute `SAMCHON_GRAPH_RUST_ANALYZER_HIR`, then project/PATH `samchon-rust-analyzer`, then a project/PATH `rust-analyzer` only when its version reports the pinned producer commit. | Cargo metadata/config, lock/toolchain inputs, target/features/cfg and build-script/proc-macro universe. | `linux`, `macos`, `windows` | +| `samchon-rust-analyzer-hir` | Build the `samchon/rust-analyzer` graph-snapshot fork at commit `2850ecba80311bebd4cdaa9fedc5321533b5b1e7`; point `SAMCHON_GRAPH_RUST_ANALYZER_HIR` at that binary or install it as `samchon-rust-analyzer`. | [native HIR graph producer PR](https://github.com/samchon/rust-analyzer/pull/1), [rust-analyzer build instructions](https://rust-analyzer.github.io/book/contributing.html) | `samchon-rust-analyzer`, `rust-analyzer` | — | `SAMCHON_GRAPH_RUST_ANALYZER_HIR` | Absolute `SAMCHON_GRAPH_RUST_ANALYZER_HIR`, then project/PATH `samchon-rust-analyzer`, then a project/PATH `rust-analyzer` only when its version reports the pinned producer commit. | Cargo metadata/config, lock/toolchain inputs, target/features/cfg and build-script/proc-macro universe. | `linux`, `macos`, `windows` | | `scip-clang` | Install `scip-clang` 0.4.0 and the `scip` decoder; provide a generated `compile_commands.json`. | [scip-clang 0.4.0 release](https://github.com/sourcegraph/scip-clang/releases/tag/v0.4.0), [SCIP releases](https://github.com/sourcegraph/scip/releases) | `scip-clang`, `scip` | `compile_commands.json`, `build/compile_commands.json` | `SAMCHON_GRAPH_SCIP_CLANG`, `SAMCHON_GRAPH_SCIP` | Project-local producer/decoder precede PATH; `SAMCHON_GRAPH_SCIP_CLANG` and `SAMCHON_GRAPH_SCIP` select absolute tools. Compilers come from the compilation database. | A valid compilation database and every named compiler/working directory/generated build input. | `linux`, `macos`, `windows-when-installed` | | `scip-java` | Install `scip-java` 0.13.1, the `scip` decoder and a compatible JDK; Kotlin experiments pin a compatible source build. | [scip-java 0.13.1 release](https://github.com/scip-code/scip-java/releases/tag/v0.13.1), [SCIP releases](https://github.com/sourcegraph/scip/releases) | `scip-java`, `scip`, `java` | — | `SAMCHON_GRAPH_SCIP_JAVA`, `SAMCHON_GRAPH_SCIP`, `SAMCHON_GRAPH_JAVA_TOOLCHAIN` | Project-local producer/decoder/JDK precede PATH; absolute environment overrides select each tool. | Maven or Gradle project metadata, dependency/classpath state and Java/Kotlin compiler inputs. | `linux`, `macos`, `windows` | | `scip-dotnet` | `dotnet tool install --global scip-dotnet`; install the `scip` decoder and matching .NET SDK. | [scip-dotnet on NuGet](https://www.nuget.org/packages/scip-dotnet), [SCIP releases](https://github.com/sourcegraph/scip/releases) | `scip-dotnet`, `scip`, `dotnet` | — | `SAMCHON_GRAPH_SCIP_DOTNET`, `SAMCHON_GRAPH_SCIP`, `SAMCHON_GRAPH_DOTNET_TOOLCHAIN` | Project-local producer/decoder/toolchain precede PATH; absolute environment overrides select each tool. | Solution/project/TFM/NuGet/MSBuild inputs and a resolvable SDK. | `linux`, `macos`, `windows` | diff --git a/docs/provider-support.json b/docs/provider-support.json index 14c858e6..092dd345 100644 --- a/docs/provider-support.json +++ b/docs/provider-support.json @@ -101,7 +101,7 @@ "facts": ["contains", "exports", "imports", "calls", "accesses", "instantiates", "type_ref", "extends", "implements", "overrides", "dispatches", "decorates", "tests", "references"], "commands": ["samchon-rust-analyzer", "rust-analyzer"], "environmentOverrides": ["SAMCHON_GRAPH_RUST_ANALYZER_HIR"], - "install": "Build the `samchon/rust-analyzer` graph-snapshot fork at commit `3e8db3829e471b6af9acd1f14052e641fb81c4fd`; point `SAMCHON_GRAPH_RUST_ANALYZER_HIR` at that binary or install it as `samchon-rust-analyzer`.", + "install": "Build the `samchon/rust-analyzer` graph-snapshot fork at commit `2850ecba80311bebd4cdaa9fedc5321533b5b1e7`; point `SAMCHON_GRAPH_RUST_ANALYZER_HIR` at that binary or install it as `samchon-rust-analyzer`.", "installSources": [ {"label": "native HIR graph producer PR", "url": "https://github.com/samchon/rust-analyzer/pull/1"}, {"label": "rust-analyzer build instructions", "url": "https://rust-analyzer.github.io/book/contributing.html"} @@ -118,7 +118,7 @@ "fallback": "Stock `rust-analyzer-scip`, then generic rust-analyzer, then `@samchon/graph-sitter`.", "experimentLanguages": ["rust"], "experimentTool": "samchon-rust-analyzer", - "experimentCapabilities": ["coverage", "diagnostics", "incremental", "sourceDigests", "universe", "unresolved", "validatedConsumerCheckpoint"], + "experimentCapabilities": ["coverage", "diagnostics", "diskDigests", "incremental", "sourceDigests", "universe", "unresolved", "validatedConsumerCheckpoint"], "benchmarkProvider": "rust-analyzer-scip", "benchmarks": [{"project": "tokio", "strictMs": 55238.18003, "fallbackMs": 229860.9964}], "upstream": "https://github.com/samchon/rust-analyzer", diff --git a/packages/graph/src/provider/rust/RUST_GRAPH_PRODUCER_COMMIT.ts b/packages/graph/src/provider/rust/RUST_GRAPH_PRODUCER_COMMIT.ts index b1dddbe9..eceecb30 100644 --- a/packages/graph/src/provider/rust/RUST_GRAPH_PRODUCER_COMMIT.ts +++ b/packages/graph/src/provider/rust/RUST_GRAPH_PRODUCER_COMMIT.ts @@ -1,2 +1,2 @@ export const RUST_GRAPH_PRODUCER_COMMIT = - "3e8db3829e471b6af9acd1f14052e641fb81c4fd"; + "2850ecba80311bebd4cdaa9fedc5321533b5b1e7"; diff --git a/packages/graph/src/provider/rust/RustGraphCache.ts b/packages/graph/src/provider/rust/RustGraphCache.ts index f8ba9c37..9667445c 100644 --- a/packages/graph/src/provider/rust/RustGraphCache.ts +++ b/packages/graph/src/provider/rust/RustGraphCache.ts @@ -152,12 +152,15 @@ function projectDirectory(props: RustGraphCache.IProps): string { function defaultCacheRoot(): string { const configured = process.env.SAMCHON_GRAPH_CACHE_DIR; if (configured !== undefined && path.isAbsolute(configured)) return configured; + /* c8 ignore start -- this branch is executable only on Windows; the Windows + * CI lane exercises it while POSIX coverage cannot change process.platform. */ if (process.platform === "win32") { const local = process.env.LOCALAPPDATA; if (local !== undefined && path.isAbsolute(local)) { return path.join(local, "samchon-graph"); } } + /* c8 ignore stop */ const xdg = process.env.XDG_CACHE_HOME; if (xdg !== undefined && path.isAbsolute(xdg)) { return path.join(xdg, "samchon-graph"); diff --git a/packages/graph/src/provider/rust/RustGraphClient.ts b/packages/graph/src/provider/rust/RustGraphClient.ts index 04b81928..ad732880 100644 --- a/packages/graph/src/provider/rust/RustGraphClient.ts +++ b/packages/graph/src/provider/rust/RustGraphClient.ts @@ -56,9 +56,6 @@ export class RustGraphClient implements IBulkGraphSession { options.producerCommit, state, ); - if (candidate.store.current !== undefined) { - this.validate(candidate.store.current); - } restored = candidate; return true; }); @@ -72,7 +69,7 @@ export class RustGraphClient implements IBulkGraphSession { this.adapter = restored; } this.checkpointPending = this.adapter.persistedCheckpoint !== undefined; - this.version = this.adapter.store.current?.protocol?.sequence ?? 0; + this.version = 0; this.initializationOptions = options.initializationOptions; this.requestTimeoutMs = options.requestTimeoutMs; this.readyTimeoutMs = options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS; @@ -120,7 +117,7 @@ export class RustGraphClient implements IBulkGraphSession { snapshot: prepared.snapshot, }; } - new GraphSnapshotProtocol.Store(this.root).apply(prepared.frames, { + new GraphSnapshotProtocol.Store(this.root).apply(prepared.state.frames, { signal, validate: this.validate, }); diff --git a/packages/graph/src/provider/rust/RustGraphSnapshotAdapter.ts b/packages/graph/src/provider/rust/RustGraphSnapshotAdapter.ts index 27db48b4..63f77ad7 100644 --- a/packages/graph/src/provider/rust/RustGraphSnapshotAdapter.ts +++ b/packages/graph/src/provider/rust/RustGraphSnapshotAdapter.ts @@ -62,6 +62,7 @@ const UNRESOLVED_REASONS = new Set([ const CAPABILITIES = [ "coverage", "diagnostics", + "diskDigests", "incremental", "sourceDigests", "universe", @@ -75,6 +76,7 @@ export class RustGraphSnapshotAdapter { private graphShards = new Map(); private rawGeneration: string | undefined; private checkpoint: IRustGraphCheckpoint | undefined; + private restoringCheckpoint = false; public constructor( private readonly root: string, @@ -101,6 +103,7 @@ export class RustGraphSnapshotAdapter { this.graphShards.clear(); this.rawGeneration = undefined; this.checkpoint = undefined; + this.restoringCheckpoint = false; this.store = new GraphSnapshotProtocol.Store(this.root); } @@ -150,6 +153,7 @@ export class RustGraphSnapshotAdapter { throw new Error("rust HIR graph: producer generation digest mismatch"); } if ( + !this.restoringCheckpoint && prior !== undefined && raw.generation === priorRawGeneration && raw.baseGeneration === priorRawGeneration && @@ -167,11 +171,14 @@ export class RustGraphSnapshotAdapter { const hello = helloOf(raw); const nodeIds = nodeIdsOf(raw, nextRaw); const nextGraph = - raw.baseGeneration === null + raw.baseGeneration === null || this.restoringCheckpoint ? new Map() : new Map(this.graphShards); for (const key of raw.deletes) nextGraph.delete(graphKey(key)); - for (const shard of raw.upserts) { + const graphUpserts = this.restoringCheckpoint + ? [...nextRaw.values()] + : raw.upserts; + for (const shard of graphUpserts) { const adapted = adaptShard(this.root, raw, shard, nodeIds); nextGraph.set(adapted.key, adapted); } @@ -259,6 +266,7 @@ export class RustGraphSnapshotAdapter { this.graphShards = nextGraph; this.rawGeneration = raw.generation; this.checkpoint = checkpoint; + this.restoringCheckpoint = false; return snapshot; }, }; @@ -301,26 +309,29 @@ export class RustGraphSnapshotAdapter { ) { throw new Error("rust HIR graph: persisted producer checkpoint is corrupt"); } - const snapshot = this.store.apply(cached.frames); + const snapshot = new GraphSnapshotProtocol.Store(this.root).apply( + cached.frames, + ); if ( snapshot.protocol?.generation !== cached.checkpoint.generation || - snapshot.provenance.universe !== cached.checkpoint.universe + snapshot.provenance.universe !== cached.checkpoint.universe || + snapshot.provenance.provider !== RUST_HIR_PROVIDER || + snapshot.provenance.tool !== cached.checkpoint.producer.name || + snapshot.provenance.toolVersion !== + `${cached.checkpoint.producer.version} (${cached.checkpoint.producer.commit})` ) { throw new Error("rust HIR graph: persisted checkpoint generation mismatch"); } this.rawShards = new Map( cached.rawShards.map((shard) => [shard.key, structuredClone(shard)]), ); - this.graphShards = new Map( - cached.frames - .filter( - (frame): frame is GraphSnapshotProtocol.IUpsertShard => - frame.type === "upsertShard", - ) - .map((frame) => [frame.shard.key, structuredClone(frame.shard)]), - ); this.rawGeneration = cached.checkpoint.generation; this.checkpoint = structuredClone(cached.checkpoint); + // Normalized frames are a local cache artifact, not producer evidence. + // Keep only the raw checkpoint until the restarted producer validates it; + // the next response then reconstructs every public shard from those raw + // HIR facts before anything becomes resident again. + this.restoringCheckpoint = true; } } @@ -401,7 +412,7 @@ function adaptShard( { file: source, checkerDigest: shard.checkerDigest, - diskDigest: "", + diskDigest: shard.checkerDigest, }, ], }; @@ -922,9 +933,17 @@ function assertString(value: unknown, label: string): asserts value is string { } function assertNativeNodeId(value: unknown, label: string): asserts value is string { - assertString(value, label); - if (!value.startsWith("rust-hir-v1|")) { - throw new Error(`rust HIR graph: invalid ${label}`); + if ( + typeof value !== "string" || + value === "" || + value.includes("\0") || + (!value.startsWith("rust-hir-v1|") && + !value.startsWith("rust-file-v1|") && + !value.startsWith("rust-export-v1|")) + ) { + throw new Error( + `rust HIR graph: invalid ${label}: ${JSON.stringify(value)}`, + ); } } diff --git a/tests/experiment/README.md b/tests/experiment/README.md index fb4ea828..87c97b53 100644 --- a/tests/experiment/README.md +++ b/tests/experiment/README.md @@ -2,7 +2,7 @@ This workspace runs real LSP smoke experiments outside the coverage-gated test suite. -Each language job installs the actual language server, clones a representative public project, builds a graph in `mode: "lsp"`, and fails if the result falls back to static indexing or produces no language symbols. +Each language job installs the actual language server, including any pinned producer fork declared by its catalog row, clones a representative public project, builds a graph in `mode: "lsp"`, and fails if the result loses its strict provenance or produces no language symbols. Use the workflow in `.github/workflows/experiment.yml` for the full matrix. diff --git a/tests/experiment/src/catalog.mjs b/tests/experiment/src/catalog.mjs index d91d813a..af1e67d6 100644 --- a/tests/experiment/src/catalog.mjs +++ b/tests/experiment/src/catalog.mjs @@ -86,9 +86,12 @@ export const LANGUAGE_EXPERIMENTS = [ strictProvider: "samchon-rust-analyzer-hir", strictAuthority: "analyzer", strictTool: "samchon-rust-analyzer", + producerRepository: "https://github.com/samchon/rust-analyzer.git", + producerCommit: "2850ecba80311bebd4cdaa9fedc5321533b5b1e7", requiredCapabilities: [ "coverage", "diagnostics", + "diskDigests", "incremental", "sourceDigests", "universe", @@ -106,7 +109,6 @@ export const LANGUAGE_EXPERIMENTS = [ "extends", "implements", "overrides", - "dispatches", "decorates", "tests", "references", @@ -118,8 +120,13 @@ export const LANGUAGE_EXPERIMENTS = [ createFile: "examples/samchon_graph_experiment.rs", renamedFile: "examples/samchon_graph_experiment_renamed.rs", createText: - 'const samchonGraphExperiment: &str = "strict-lifecycle";\n\nfn main() { println!("{samchonGraphExperiment}"); }\n', + 'const samchonGraphExperiment: &str = "strict-lifecycle";\n\ntrait SamchonGraphParent {}\ntrait SamchonGraphChild: SamchonGraphParent {}\n\nfn main() { println!("{samchonGraphExperiment}"); }\n', createdSymbol: "samchonGraphExperiment", + createdEdge: { + kind: "extends", + from: "SamchonGraphChild", + to: "SamchonGraphParent", + }, buildFile: "Cargo.toml", // A malformed Cargo manifest invalidates the producer's build universe, // so the HIR snapshot must reject rather than mix an old database with diff --git a/tests/experiment/src/setup-language.mjs b/tests/experiment/src/setup-language.mjs index 78655f0a..8d2e1b2c 100644 --- a/tests/experiment/src/setup-language.mjs +++ b/tests/experiment/src/setup-language.mjs @@ -547,23 +547,96 @@ switch (experiment.language) { }); break; } - case "rust": + case "rust": { // The installer script comes through the same hardened seam as every // other fetch. Piping curl into `sh` would be the one shape curl's own // manual tells us not to retry: a retried mid-body transfer is not // rewound in a pipe, so `sh` could read the partial prefix twice. await downloadFile("https://sh.rustup.rs", path.join(toolsRoot, "rustup-init.sh")); - shell(`sh "${path.join(toolsRoot, "rustup-init.sh")}" -y --profile minimal`); - appendGithubPath(path.join(os.homedir(), ".cargo", "bin")); - shell(`${path.join(os.homedir(), ".cargo", "bin", "rustup")} component add rust-analyzer`); + shell( + `sh "${path.join(toolsRoot, "rustup-init.sh")}" -y --profile minimal --default-toolchain 1.95.0`, + ); + const cargoBin = path.join(os.homedir(), ".cargo", "bin"); + appendGithubPath(cargoBin); + run( + path.join( + cargoBin, + process.platform === "win32" ? "rustup.exe" : "rustup", + ), + [ + "component", + "add", + "rust-src", + "--toolchain", + "1.95.0", + ], + ); + record({ + tool: "rust-toolchain", + version: "1.95.0", + source: "rustup profile minimal with rust-src", + digest: "rustup:1.95.0", + }); + const producerRoot = path.join(toolsRoot, "samchon-rust-analyzer-source"); + fs.rmSync(producerRoot, { force: true, recursive: true }); + ensureDir(producerRoot); + run("git", ["init"], { cwd: producerRoot }); + run("git", ["remote", "add", "origin", experiment.producerRepository], { + cwd: producerRoot, + }); + run( + "git", + ["fetch", "--depth=1", "origin", experiment.producerCommit], + { cwd: producerRoot }, + ); + run("git", ["checkout", "--detach", "FETCH_HEAD"], { + cwd: producerRoot, + }); + const producerHead = String( + run("git", ["rev-parse", "HEAD"], { + cwd: producerRoot, + stdio: "pipe", + }).stdout, + ).trim(); + if (producerHead !== experiment.producerCommit) { + throw new Error( + `rust producer checkout is ${producerHead}, not ${experiment.producerCommit}`, + ); + } + run( + path.join(cargoBin, process.platform === "win32" ? "cargo.exe" : "cargo"), + ["build", "--locked", "--release", "-p", "rust-analyzer"], + { cwd: producerRoot }, + ); + const producerBinary = path.join( + producerRoot, + "target", + "release", + process.platform === "win32" ? "rust-analyzer.exe" : "rust-analyzer", + ); + for (const command of ["samchon-rust-analyzer", "rust-analyzer"]) { + const link = path.join( + binRoot, + `${command}${process.platform === "win32" ? ".exe" : ""}`, + ); + fs.rmSync(link, { force: true }); + fs.linkSync(producerBinary, link); + } + record({ + tool: "samchon-rust-analyzer", + version: experiment.producerCommit, + source: `${experiment.producerRepository}@${experiment.producerCommit}`, + digest: `git:${experiment.producerCommit}`, + }); record({ tool: "rust-analyzer", - version: "unpinned", - source: "rustup component add rust-analyzer", - digest: "unpinned", + version: experiment.producerCommit, + source: "alias of samchon-rust-analyzer", + digest: `git:${experiment.producerCommit}`, }); await installScip(); break; + } case "cpp": case "c": // `bear` alongside clangd because scip-clang declines without a compilation diff --git a/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts b/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts index d57befb2..59f5b589 100644 --- a/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts +++ b/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts @@ -1,5 +1,5 @@ import { TestValidator } from "@nestia/e2e"; -import { LANGUAGE_SPECS } from "@samchon/graph"; +import { LANGUAGE_SPECS, RUST_GRAPH_PRODUCER_COMMIT } from "@samchon/graph"; import fs from "node:fs"; import path from "node:path"; @@ -34,6 +34,7 @@ export const test_experiment_corpora_are_commit_pinned = () => { const java = region(catalog, 'language: "java"', 'language: "csharp"'); const csharp = region(catalog, 'language: "csharp"', 'language: "kotlin"'); const kotlin = region(catalog, 'language: "kotlin"', 'language: "swift"'); + const rust = region(catalog, 'language: "rust"', 'language: "cpp"'); const swift = region(catalog, 'language: "swift"', 'language: "scala"'); const scala = region(catalog, 'language: "scala"', 'language: "zig"'); const zig = region(catalog, 'language: "zig"', 'language: "python"'); @@ -45,11 +46,24 @@ export const test_experiment_corpora_are_commit_pinned = () => { const dart = region(catalog, 'language: "dart"', "\n];"); const javaSetup = region(setup, 'case "java"', 'case "csharp"'); const kotlinSetup = region(setup, 'case "kotlin"', 'case "swift"'); + const rustSetup = region(setup, 'case "rust"', 'case "cpp"'); TestValidator.equals( "every registered strict-provider language has a lifecycle row", [...catalog.matchAll(/strictProvider:\s*"[^"]+"/g)].length, 13, ); + TestValidator.predicate( + "Rust builds and records the exact native HIR producer declared by the catalog", + rust.includes('producerRepository: "https://github.com/samchon/rust-analyzer.git"') && + rust.includes(`producerCommit: "${RUST_GRAPH_PRODUCER_COMMIT}"`) && + rustSetup.includes("--default-toolchain 1.95.0") && + rustSetup.includes('"rust-src"') && + rustSetup.includes('["fetch", "--depth=1", "origin", experiment.producerCommit]') && + rustSetup.includes('["build", "--locked", "--release", "-p", "rust-analyzer"]') && + rustSetup.includes('for (const command of ["samchon-rust-analyzer", "rust-analyzer"])') && + rustSetup.includes("fs.linkSync(producerBinary, link)") && + !rustSetup.includes("rustup component add rust-analyzer"), + ); TestValidator.predicate( "the remaining SCIP providers use isolated upstream lifecycle projects", [java, kotlin, ruby, php, dart].every( diff --git a/tests/test-graph/src/features/test_rust_hir_client_restores_retries_and_fails_closed.ts b/tests/test-graph/src/features/test_rust_hir_client_restores_retries_and_fails_closed.ts index 9bbf3540..e2f5d462 100644 --- a/tests/test-graph/src/features/test_rust_hir_client_restores_retries_and_fails_closed.ts +++ b/tests/test-graph/src/features/test_rust_hir_client_restores_retries_and_fails_closed.ts @@ -2,6 +2,7 @@ import { TestValidator } from "@nestia/e2e"; import { RUST_GRAPH_PRODUCER_COMMIT, RustGraphClient, + buildLspGraph, rustGraphProvider, } from "@samchon/graph"; import fs from "node:fs"; @@ -21,6 +22,7 @@ export const test_rust_hir_client_restores_retries_and_fails_closed = async () = await assertCancellationBoundaries(root); await assertPersistenceAndValidationBoundaries(root); await assertClientOptionBoundaries(root); + await assertPublicCommitFence(root); await assertPinnedResolution(root); }; @@ -80,8 +82,8 @@ async function assertResidentLifecycle(root: string, cacheRoot: string): Promise restoredValidations += 1; }); TestValidator.predicate( - "a validated immutable checkpoint is resident before the restarted producer initializes", - restored.current !== undefined && restored.generation === 1 && restoredValidations === 1, + "a persisted checkpoint stays unpublished before the restarted producer validates it", + restored.current === undefined && restored.generation === 0 && restoredValidations === 0, ); const reuse = await restored.refresh(); const params = readRequests(restoredLog)[0]!; @@ -94,7 +96,7 @@ async function assertResidentLifecycle(root: string, cacheRoot: string): Promise params.knownGeneration, params.checkpoint?.generation, ], - [false, "unchanged", 1, params.checkpoint?.generation, params.checkpoint?.generation], + [true, "initial", 1, params.checkpoint?.generation, params.checkpoint?.generation], ); await restored.close(); } @@ -324,6 +326,45 @@ async function assertClientOptionBoundaries(root: string): Promise { await racedRejection; } +async function assertPublicCommitFence(root: string): Promise { + const cacheRoot = isolatedCache(); + const priorCacheRoot = process.env.SAMCHON_GRAPH_CACHE_DIR; + process.env.SAMCHON_GRAPH_CACHE_DIR = cacheRoot; + try { + const result = await buildLspGraph( + { cwd: root, languages: ["rust"] }, + { + providers: [ + { + ...rustGraphProvider, + resolve: () => ({ + command: process.execPath, + args: [ + GraphPaths.fakeRustGraphServer, + `--commit=${RUST_GRAPH_PRODUCER_COMMIT}`, + ], + }), + }, + ], + }, + ); + TestValidator.equals( + "a disk-bound Rust HIR generation crosses the public commit fence", + [ + result.dump.provenance?.map((row) => row.provider), + result.dump.nodes.some((node) => node.name === "answer"), + result.dump.warnings.some((warning) => + warning.includes("does not bind the provider snapshot"), + ), + ], + [["samchon-rust-analyzer-hir"], true, false], + ); + } finally { + if (priorCacheRoot === undefined) delete process.env.SAMCHON_GRAPH_CACHE_DIR; + else process.env.SAMCHON_GRAPH_CACHE_DIR = priorCacheRoot; + } +} + async function assertPinnedResolution(root: string): Promise { const pinned = nodeShim(root, "pinned-rust-analyzer", RUST_GRAPH_PRODUCER_COMMIT); const wrong = nodeShim(root, "wrong-rust-analyzer", "0000000000000000000000000000000000000000"); diff --git a/tests/test-graph/src/features/test_rust_hir_snapshot_adapter_fences_generations.ts b/tests/test-graph/src/features/test_rust_hir_snapshot_adapter_fences_generations.ts index df5e221a..5fdbd181 100644 --- a/tests/test-graph/src/features/test_rust_hir_snapshot_adapter_fences_generations.ts +++ b/tests/test-graph/src/features/test_rust_hir_snapshot_adapter_fences_generations.ts @@ -1,6 +1,7 @@ import { TestValidator } from "@nestia/e2e"; import { GRAPH_EDGE_KINDS, + GraphSnapshotProtocol, RUST_GRAPH_PRODUCER_COMMIT, RustGraphCache, RustGraphSnapshotAdapter, @@ -40,6 +41,8 @@ export const test_rust_hir_snapshot_adapter_fences_generations = () => { initialSnapshot.unresolved?.length, initialSnapshot.provenance.provider, initialSnapshot.provenance.compilerVersion.startsWith("rustc 1.95.0"), + initialSnapshot.provenance.capabilities.includes("diskDigests"), + initialSnapshot.sources.get(path.join(root, "src/lib.rs"))?.diskDigest, initialSnapshot.nodes.every((node) => node.id.startsWith("@v2/rust/")), initialSnapshot.edges.every( (edge) => @@ -62,6 +65,8 @@ export const test_rust_hir_snapshot_adapter_fences_generations = () => { "samchon-rust-analyzer-hir", true, true, + sourceDigest("pub fn answer() -> u8 { 42 }\n"), + true, true, ], ); @@ -86,6 +91,17 @@ export const test_rust_hir_snapshot_adapter_fences_generations = () => { }); const incremental = adapter.prepare(incrementalRaw); if (!incremental.changed) throw new Error("incremental Rust generation did not change"); + TestValidator.error( + "producer delta frames cannot validate against an empty store", + () => new GraphSnapshotProtocol.Store(root).apply(incremental.frames), + ); + TestValidator.equals( + "a producer delta carries a complete reconstruction for isolated validation", + new GraphSnapshotProtocol.Store(root).apply(incremental.state.frames).nodes.some( + (node) => node.name === "edited_answer", + ), + true, + ); const incrementalSnapshot = adapter.store.apply(incremental.frames); incremental.commit(incrementalSnapshot); TestValidator.equals( @@ -114,13 +130,30 @@ export const test_rust_hir_snapshot_adapter_fences_generations = () => { const restored = new RustGraphSnapshotAdapter(root, COMMIT, reloaded.state); TestValidator.equals( - "a validated consumer checkpoint restores raw and graph state", + "a consumer checkpoint remains unpublished until the producer validates its raw state", [ restored.store.current?.protocol?.generation, restored.persistedCheckpoint?.generation, - restored.store.current?.nodes.map((node) => node.name), + restored.hasPersistedSnapshot, ], - [reloadedRaw.generation, reloadedRaw.generation, ["dependency", "reloaded"]], + [undefined, reloadedRaw.generation, false], + ); + const restoredRaw = snapshot({ + base: reloadedRaw, + upserts: [], + sequence: 6, + }); + const restoredPrepared = restored.prepare(restoredRaw); + if (!restoredPrepared.changed) { + throw new Error("validated Rust checkpoint did not reconstruct its graph"); + } + const restoredSnapshot = restoredPrepared.commit( + restored.store.apply(restoredPrepared.frames), + ); + TestValidator.equals( + "producer validation reconstructs public facts without trusting cached frames", + [restoredPrepared.mode, restoredSnapshot.nodes.map((node) => node.name)], + ["initial", ["dependency", "reloaded"]], ); restored.discardPersistedSnapshot(); restored.discardPersistedSnapshot(); @@ -132,10 +165,68 @@ export const test_rust_hir_snapshot_adapter_fences_generations = () => { assertAdapterRefusals(root, initialRaw, reloaded.state); assertDeltaDeletionAndCrossShardRefusals(root); + assertNativeSyntheticIdentities(root); assertOptionalProducerFields(root); assertCacheFallback(root, reloaded.state); }; +function assertNativeSyntheticIdentities(root: string): void { + const raw = snapshot({ nodeName: "with_file" }); + const source = raw.upserts[0]!.source; + const fileId = `rust-file-v1|${source.length}:${source}`; + const exportId = "rust-export-v1|fixture-alias"; + raw.upserts[0]!.nodes.push({ + id: fileId, + kind: "file", + name: "lib.rs", + qualifiedName: null, + file: source, + external: false, + exported: false, + signature: null, + evidence: null, + }); + raw.upserts[0]!.nodes.push({ + id: exportId, + kind: "function", + name: "exported_answer", + qualifiedName: "fixture::exported_answer", + file: source, + external: false, + exported: true, + signature: "fn() -> u8", + evidence: null, + }); + raw.upserts[0]!.edges.push({ + from: fileId, + to: "rust-hir-v1|answer", + kind: "contains", + evidence: null, + }); + raw.upserts[0]!.edges.push({ + from: exportId, + to: "rust-hir-v1|answer", + kind: "references", + evidence: null, + }); + refresh(raw); + + const adapter = new RustGraphSnapshotAdapter(root, COMMIT); + const prepared = adapter.prepare(raw); + if (!prepared.changed) throw new Error("native Rust file fixture did not change"); + const adapted = adapter.store.apply(prepared.frames); + TestValidator.equals( + "producer-owned file identities survive native validation and adaptation", + [ + adapted.nodes.some((node) => node.kind === "file" && node.name === "lib.rs"), + adapted.nodes.some((node) => node.name === "exported_answer"), + adapted.edges.some((edge) => edge.kind === "contains"), + adapted.edges.some((edge) => edge.kind === "references"), + ], + [true, true, true, true], + ); +} + function assertOptionalProducerFields(root: string): void { const optional = snapshot({ nodeName: "optional" }); optional.universe.configurations = []; @@ -363,6 +454,13 @@ function assertAdapterRefusals( }, ], ["persisted producer checkpoint", (value) => value.checkpoint.manifest.pop()], + [ + "persisted normalized producer attribution", + (value) => { + const hello = value.frames[0]; + if (hello?.type === "hello") hello.producer = "forged-producer"; + }, + ], [ "persisted graph checkpoint", (value) => ((value.frames[1]! as { type: string }).type = "hello"), @@ -657,7 +755,10 @@ function rawShard( const shard: IRustGraphShard = { key: `app\0${source}`, source, - checkerDigest: digest(`checker-${source}-${nodeName}`), + checkerDigest: + source === "src/lib.rs" + ? sourceDigest("pub fn answer() -> u8 { 42 }\n") + : sourceDigest(`fixture source: ${source}\n`), interfaceFingerprint: digest(`interface-${nodeName}`), digest: "", nodes: [ @@ -745,6 +846,10 @@ function digest(value: unknown): string { return createHash("sha256").update(canonical(value)).digest("hex"); } +function sourceDigest(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + function canonical(value: unknown): string { if (value === null || typeof value !== "object") return JSON.stringify(value); if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; diff --git a/tests/test-graph/src/features/test_standard_providers_execute_their_exact_contracts.ts b/tests/test-graph/src/features/test_standard_providers_execute_their_exact_contracts.ts index 77510b6c..f0b6866f 100644 --- a/tests/test-graph/src/features/test_standard_providers_execute_their_exact_contracts.ts +++ b/tests/test-graph/src/features/test_standard_providers_execute_their_exact_contracts.ts @@ -13,6 +13,7 @@ import { standardSidecarProviders, } from "@samchon/graph"; import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; @@ -1286,6 +1287,14 @@ async function assertRegisteredFixture( "rustc=rustc v1.0.0; cargo=cargo v1.0.0", ); } + if (provider.name === "samchon-rust-analyzer-hir") { + const source = path.join(root, "src/lib.rs"); + TestValidator.equals( + "the Rust HIR source digest binds analyzer facts to the coordinator's disk generation", + refreshed.snapshot.sources.get(source)?.diskDigest, + createHash("sha256").update(fs.readFileSync(source)).digest("hex"), + ); + } // Compared rather than reduced to a predicate: a conformance report names // exactly which invariant a provider broke, and folding it into a boolean // throws that away at the one moment it is worth having. diff --git a/tests/test-graph/src/internal/fake-rust-graph-server.cjs b/tests/test-graph/src/internal/fake-rust-graph-server.cjs index 8b198013..2a6f8305 100644 --- a/tests/test-graph/src/internal/fake-rust-graph-server.cjs +++ b/tests/test-graph/src/internal/fake-rust-graph-server.cjs @@ -3,6 +3,7 @@ const crypto = require("node:crypto"); const fs = require("node:fs"); +const path = require("node:path"); const args = process.argv.slice(2); const valueOf = (prefix) => args.find((arg) => arg.startsWith(prefix))?.slice(prefix.length); @@ -184,7 +185,10 @@ const edges = conformance const shard = { key: "app\u0000src/lib.rs", source: "src/lib.rs", - checkerDigest: sha256(conformanceHeuristic ? "fixture-heuristic" : "fixture-source"), + checkerDigest: crypto + .createHash("sha256") + .update(fs.readFileSync(path.join(process.cwd(), "src/lib.rs"))) + .digest("hex"), interfaceFingerprint: sha256( conformanceHeuristic ? "fixture-heuristic-interface" : "fixture-interface", ), From cc13fa0f49d9906f217552d01190a91c24b997d7 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Sun, 2 Aug 2026 01:28:24 +0900 Subject: [PATCH 27/52] fix: distinguish virtual Rust source digests --- .../src/provider/rust/RustGraphSnapshotAdapter.ts | 4 +++- ...st_rust_hir_snapshot_adapter_fences_generations.ts | 11 ++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/graph/src/provider/rust/RustGraphSnapshotAdapter.ts b/packages/graph/src/provider/rust/RustGraphSnapshotAdapter.ts index 63f77ad7..803dd6e6 100644 --- a/packages/graph/src/provider/rust/RustGraphSnapshotAdapter.ts +++ b/packages/graph/src/provider/rust/RustGraphSnapshotAdapter.ts @@ -412,7 +412,9 @@ function adaptShard( { file: source, checkerDigest: shard.checkerDigest, - diskDigest: shard.checkerDigest, + diskDigest: source.startsWith("bundled:///") + ? "" + : shard.checkerDigest, }, ], }; diff --git a/tests/test-graph/src/features/test_rust_hir_snapshot_adapter_fences_generations.ts b/tests/test-graph/src/features/test_rust_hir_snapshot_adapter_fences_generations.ts index 5fdbd181..7d061b47 100644 --- a/tests/test-graph/src/features/test_rust_hir_snapshot_adapter_fences_generations.ts +++ b/tests/test-graph/src/features/test_rust_hir_snapshot_adapter_fences_generations.ts @@ -264,9 +264,14 @@ function assertOptionalProducerFields(root: string): void { const bundledAdapter = new RustGraphSnapshotAdapter(root, COMMIT); const bundled = bundledAdapter.prepare(bundledRaw); if (!bundled.changed) throw new Error("bundled Rust fixture did not change"); - TestValidator.predicate( - "bundled producer sources retain their URI identity", - bundledAdapter.store.apply(bundled.frames).sources.has("bundled:///rust/source"), + const bundledSource = bundledAdapter + .store + .apply(bundled.frames) + .sources.get("bundled:///rust/source"); + TestValidator.equals( + "bundled producer sources retain their URI but never claim a host disk identity", + [bundledSource !== undefined, bundledSource?.diskDigest], + [true, ""], ); TestValidator.error("a snapshot without shards cannot establish coverage", () => From e973e2b035d4ebc7dd063f5f484a4daf449aabb3 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Mon, 3 Aug 2026 15:44:03 +0900 Subject: [PATCH 28/52] feat(graph): add native clangd snapshot provider Close #73: [Bulk index][C/C++] Share one Clang compilation-universe provider --- README.md | 12 +- docs/provider-support.json | 43 +- .../graph/src/provider/GRAPH_PROVIDERS.ts | 6 +- .../graph/src/provider/cpp/CPP_CLANG_FACTS.ts | 6 + .../provider/cpp/CPP_CLANG_PRODUCER_COMMIT.ts | 3 + .../src/provider/cpp/CPP_CLANG_PROVIDER.ts | 1 + .../graph/src/provider/cpp/CppGraphClient.ts | 517 +++++++ .../provider/cpp/CppGraphSnapshotAdapter.ts | 1378 +++++++++++++++++ .../src/provider/cpp/ICppGraphSnapshot.ts | 180 +++ .../src/provider/cpp/cppGraphProvider.ts | 146 ++ packages/graph/src/provider/cpp/index.ts | 7 + packages/graph/src/provider/index.ts | 1 + tests/experiment/src/catalog.mjs | 139 +- ..._snapshot_adapter_and_client_are_atomic.ts | 967 ++++++++++++ ...st_experiment_corpora_are_commit_pinned.ts | 60 +- ...registry_selects_one_owner_per_language.ts | 2 - ..._manifest_matches_registry_and_evidence.ts | 12 +- ...providers_execute_their_exact_contracts.ts | 16 +- tests/test-graph/src/internal/GraphPaths.ts | 1 + .../src/internal/fake-cpp-graph-server.cjs | 625 ++++++++ 20 files changed, 3993 insertions(+), 129 deletions(-) create mode 100644 packages/graph/src/provider/cpp/CPP_CLANG_FACTS.ts create mode 100644 packages/graph/src/provider/cpp/CPP_CLANG_PRODUCER_COMMIT.ts create mode 100644 packages/graph/src/provider/cpp/CPP_CLANG_PROVIDER.ts create mode 100644 packages/graph/src/provider/cpp/CppGraphClient.ts create mode 100644 packages/graph/src/provider/cpp/CppGraphSnapshotAdapter.ts create mode 100644 packages/graph/src/provider/cpp/ICppGraphSnapshot.ts create mode 100644 packages/graph/src/provider/cpp/cppGraphProvider.ts create mode 100644 packages/graph/src/provider/cpp/index.ts create mode 100644 tests/test-graph/src/features/test_cpp_clang_snapshot_adapter_and_client_are_atomic.ts create mode 100644 tests/test-graph/src/internal/fake-cpp-graph-server.cjs diff --git a/README.md b/README.md index 62b1ccee..529feb0d 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ Strict selection is per registered provider and may decline for missing tools, i | `samchon-graph-go` | `go` | `compiler` | `contains`, `exports`, `imports`, `calls`, `accesses`, `instantiates`, `type_ref`, `implements`, `dispatches`, `tests`, `references` | [upstream](https://github.com/scip-code/scip-go) / [route #63](https://github.com/samchon/compiler-graph/issues/63) | | `samchon-graph-lua` | `lua` | `analyzer` | `references` | [upstream](https://github.com/LuaLS/lua-language-server) / [route #83](https://github.com/samchon/compiler-graph/issues/83) | | `samchon-rust-analyzer-hir` | `rust` | `analyzer` | `contains`, `exports`, `imports`, `calls`, `accesses`, `instantiates`, `type_ref`, `extends`, `implements`, `overrides`, `dispatches`, `decorates`, `tests`, `references` | [upstream](https://github.com/samchon/rust-analyzer) / [route #72](https://github.com/samchon/compiler-graph/issues/72) | -| `scip-clang` | `c`, `cpp` | `semantic-index` | **none** | [upstream](https://github.com/sourcegraph/scip-clang) / [route #73](https://github.com/samchon/compiler-graph/issues/73) | +| `clangd-snapshot` | `c`, `cpp` | `compiler` | `contains`, `exports`, `imports`, `calls`, `accesses`, `instantiates`, `type_ref`, `extends`, `implements`, `overrides`, `dispatches`, `references` | [upstream](https://github.com/samchon/llvm-project) / [route #73](https://github.com/samchon/compiler-graph/issues/73) | | `scip-java` | `java`, `kotlin` | `semantic-index` | `contains`, `references` | [upstream](https://github.com/scip-code/scip-java) / [route #74](https://github.com/samchon/compiler-graph/issues/74) / [route #76](https://github.com/samchon/compiler-graph/issues/76) | | `scip-dotnet` | `csharp` | `semantic-index` | **none** | [upstream](https://github.com/sourcegraph/scip-dotnet) / [route #75](https://github.com/samchon/compiler-graph/issues/75) | | `scip-python` | `python` | `semantic-index` | `references` | [upstream](https://github.com/sourcegraph/scip-python) / [route #80](https://github.com/samchon/compiler-graph/issues/80) | @@ -92,7 +92,7 @@ These are current implementation modes, not future route claims. Preparation and | `samchon-graph-go` | `unchanged-snapshot-reuse; full-rebuild-on-change` | Go workspace/module inputs, selected GOOS/GOARCH/cgo environment, embedded files and vendored inputs. | The shipped exporter runs one compiler-owned go/packages batch against the selected build universe. | A changed-input batch emits and validates one whole-workspace graph before snapshot publication. | Unchanged inputs reuse the validated snapshot; no resident go/packages checker survives changed builds. | | `samchon-graph-lua` | `unchanged-snapshot-reuse; full-rebuild-on-change` | LuaLS workspace configuration and the shipped readable exporter. | LuaLS analyzes the workspace and the shipped exporter asks its semantic VM for declaration references. | A changed-input run publishes one references-only whole-workspace graph. | Unchanged inputs reuse the validated snapshot; the current exporter is not a resident incremental session. | | `samchon-rust-analyzer-hir` | `resident-no-op-reuse; invalidated-closure shard deltas; validated restart checkpoints` | Cargo metadata/config, lock/toolchain inputs, target/features/cfg and build-script/proc-macro universe. | The pinned rust-analyzer fork owns one resident HIR database and exports declarations, semantic relationships, diagnostics, coverage and unresolved boundaries from that exact analysis revision. | Content-addressed source shards cross a versioned LSP transaction; the client verifies producer identity, universe, complete manifests, shard digests and graph invariants before atomic publication. | No-op requests reuse the resident snapshot; interface changes invalidate dependent shards, while a complete consumer checkpoint restores the same generation after process restart. | -| `scip-clang` | `unchanged-snapshot-reuse; full-rebuild-on-change` | A valid compilation database and every named compiler/working directory/generated build input. | scip-clang runs one batch over the exact compilation database and its per-unit compiler commands. | The complete decoded artifact publishes declarations but no currently defensible edge family; producer scheduling can move header selection. | Unchanged inputs reuse the validated snapshot; every changed build reruns the batch producer. | +| `clangd-snapshot` | `resident-no-op-reuse; complete changed-TU/configuration replacement; content-addressed deltas` | A valid compilation database plus every source, header, generated input, command, target and working-directory identity used by its translation units. | The pinned clangd fork runs every registered command, keeps headers scoped by translation unit and configuration, and captures complete Clang roles, relations, macros, includes, diagnostics and source digests from the same compiler pass. | Versioned native shards cross one optimistic atomic snapshot; the client verifies producer identity, complete manifests, native and common shard digests, coverage, unresolved boundaries and source identities before publication. | No-op requests reuse the exact resident graph; changed sources or compilation-database commands reindex their owning translation units while a failed batch preserves the last complete generation without publishing it as current. | | `scip-java` | `unchanged-snapshot-reuse; full-rebuild-on-change` | Maven or Gradle project metadata, dependency/classpath state and Java/Kotlin compiler inputs. | scip-java drives the selected Maven or Gradle build and its Java/Kotlin producers as one batch. | The complete decoded artifact is merged as a contains/references graph before atomic publication. | Unchanged inputs reuse the validated snapshot; no javac, kotlinc or build session remains resident. | | `scip-dotnet` | `unchanged-snapshot-reuse; full-rebuild-on-change` | Solution/project/TFM/NuGet/MSBuild inputs and a resolvable SDK. | scip-dotnet loads and analyzes the selected solution through one batch producer run. | The complete decoded artifact publishes declarations but no currently defensible edge family. | Unchanged inputs reuse the validated snapshot; no Roslyn workspace remains resident. | | `scip-python` | `unchanged-snapshot-reuse; full-rebuild-on-change` | Python project/config/environment/import/stub inputs. | scip-python runs its bundled Pyright-based analysis once for the selected project environment. | The complete decoded artifact publishes a references-only project graph. | Unchanged inputs reuse the validated snapshot; no Pyright analysis session remains resident. | @@ -110,7 +110,7 @@ The troubleshooting table names the ordinary language-server/static fallback for | `samchon-graph-go` | Go 1.25+; the package ships the Go exporter source. Install corroboration with `go install github.com/scip-code/scip-go/cmd/scip-go@v0.2.7`. | [Go downloads](https://go.dev/dl/), [scip-go 0.2.7 source](https://github.com/scip-code/scip-go/tree/v0.2.7) | `samchon-graph-go`, `go`, `scip-go` | — | `SAMCHON_GRAPH_GO`, `SAMCHON_GRAPH_GO_TOOLCHAIN`, `SAMCHON_GRAPH_SCIP_GO` | Project/PATH `samchon-graph-go`, then the shipped source runner through Go; absolute environment overrides take precedence. | Go workspace/module inputs, selected GOOS/GOARCH/cgo environment, embedded files and vendored inputs. | `linux`, `macos`, `windows` | | `samchon-graph-lua` | Install `lua-language-server`; the package ships `sidecars/lua/export.lua`. | [LuaLS releases](https://github.com/LuaLS/lua-language-server/releases) | `lua-language-server` | — | `SAMCHON_GRAPH_LUA`, `SAMCHON_GRAPH_LUA_EXPORTER` | Absolute `SAMCHON_GRAPH_LUA`, then project/PATH LuaLS; the shipped exporter may be replaced by `SAMCHON_GRAPH_LUA_EXPORTER`. | LuaLS workspace configuration and the shipped readable exporter. | `linux`, `macos`, `windows` | | `samchon-rust-analyzer-hir` | Build the `samchon/rust-analyzer` graph-snapshot fork at commit `2850ecba80311bebd4cdaa9fedc5321533b5b1e7`; point `SAMCHON_GRAPH_RUST_ANALYZER_HIR` at that binary or install it as `samchon-rust-analyzer`. | [native HIR graph producer PR](https://github.com/samchon/rust-analyzer/pull/1), [rust-analyzer build instructions](https://rust-analyzer.github.io/book/contributing.html) | `samchon-rust-analyzer`, `rust-analyzer` | — | `SAMCHON_GRAPH_RUST_ANALYZER_HIR` | Absolute `SAMCHON_GRAPH_RUST_ANALYZER_HIR`, then project/PATH `samchon-rust-analyzer`, then a project/PATH `rust-analyzer` only when its version reports the pinned producer commit. | Cargo metadata/config, lock/toolchain inputs, target/features/cfg and build-script/proc-macro universe. | `linux`, `macos`, `windows` | -| `scip-clang` | Install `scip-clang` 0.4.0 and the `scip` decoder; provide a generated `compile_commands.json`. | [scip-clang 0.4.0 release](https://github.com/sourcegraph/scip-clang/releases/tag/v0.4.0), [SCIP releases](https://github.com/sourcegraph/scip/releases) | `scip-clang`, `scip` | `compile_commands.json`, `build/compile_commands.json` | `SAMCHON_GRAPH_SCIP_CLANG`, `SAMCHON_GRAPH_SCIP` | Project-local producer/decoder precede PATH; `SAMCHON_GRAPH_SCIP_CLANG` and `SAMCHON_GRAPH_SCIP` select absolute tools. Compilers come from the compilation database. | A valid compilation database and every named compiler/working directory/generated build input. | `linux`, `macos`, `windows-when-installed` | +| `clangd-snapshot` | Build the `samchon/llvm-project` graph-snapshot fork at commit `8ca950e1bd50a895145e6a5447f5d3253eabcc8c`; point `SAMCHON_GRAPH_CLANGD_SNAPSHOT` at `clangd` or install it as `samchon-clangd`, and provide a compilation database. | [native Clang graph producer PR](https://github.com/samchon/llvm-project/pull/1), [LLVM build instructions](https://llvm.org/docs/CMake.html) | `samchon-clangd`, `clangd` | `compile_commands.json`, `build/compile_commands.json` | `SAMCHON_GRAPH_CLANGD_SNAPSHOT` | Absolute `SAMCHON_GRAPH_CLANGD_SNAPSHOT`, then project/PATH `samchon-clangd`, then a project/PATH `clangd` only when its version reports the pinned producer commit. | A valid compilation database plus every source, header, generated input, command, target and working-directory identity used by its translation units. | `linux`, `macos`, `windows` | | `scip-java` | Install `scip-java` 0.13.1, the `scip` decoder and a compatible JDK; Kotlin experiments pin a compatible source build. | [scip-java 0.13.1 release](https://github.com/scip-code/scip-java/releases/tag/v0.13.1), [SCIP releases](https://github.com/sourcegraph/scip/releases) | `scip-java`, `scip`, `java` | — | `SAMCHON_GRAPH_SCIP_JAVA`, `SAMCHON_GRAPH_SCIP`, `SAMCHON_GRAPH_JAVA_TOOLCHAIN` | Project-local producer/decoder/JDK precede PATH; absolute environment overrides select each tool. | Maven or Gradle project metadata, dependency/classpath state and Java/Kotlin compiler inputs. | `linux`, `macos`, `windows` | | `scip-dotnet` | `dotnet tool install --global scip-dotnet`; install the `scip` decoder and matching .NET SDK. | [scip-dotnet on NuGet](https://www.nuget.org/packages/scip-dotnet), [SCIP releases](https://github.com/sourcegraph/scip/releases) | `scip-dotnet`, `scip`, `dotnet` | — | `SAMCHON_GRAPH_SCIP_DOTNET`, `SAMCHON_GRAPH_SCIP`, `SAMCHON_GRAPH_DOTNET_TOOLCHAIN` | Project-local producer/decoder/toolchain precede PATH; absolute environment overrides select each tool. | Solution/project/TFM/NuGet/MSBuild inputs and a resolvable SDK. | `linux`, `macos`, `windows` | | `scip-python` | `npm install -g @sourcegraph/scip-python@0.6.6`; install the `scip` decoder and select Python. | [scip-python 0.6.6 on npm](https://www.npmjs.com/package/@sourcegraph/scip-python/v/0.6.6), [SCIP releases](https://github.com/sourcegraph/scip/releases) | `scip-python`, `scip`, `python3`, `python`, `py` | — | `SAMCHON_GRAPH_SCIP_PYTHON`, `SAMCHON_GRAPH_SCIP`, `SAMCHON_GRAPH_PYTHON_TOOLCHAIN` | Project-local producer/decoder/interpreter precede PATH; Python aliases are tried in order and absolute overrides select each tool. | Python project/config/environment/import/stub inputs. | `linux`, `macos`, `windows` | @@ -128,8 +128,8 @@ These are exact same-run cold end-to-end strict/strict-disabled pairs from [`tes | `gin` | `samchon-graph-go` | 38,097.048 ms | 687.107 ms | | `lualine` | `samchon-graph-lua` | 18,889.245 ms | 27,848.007 ms | | `tokio` | `rust-analyzer-scip` (prior fallback evidence; `samchon-rust-analyzer-hir` not yet measured) | 55,238.180 ms | 229,860.996 ms | -| `redis` | `scip-clang` | 22,794.688 ms | 262,905.796 ms | -| `leveldb` | `scip-clang` | 8,352.928 ms | 26,451.952 ms | +| `redis` | `scip-clang` (prior fallback evidence; `clangd-snapshot` not yet measured) | 22,794.688 ms | 262,905.796 ms | +| `leveldb` | `scip-clang` (prior fallback evidence; `clangd-snapshot` not yet measured) | 8,352.928 ms | 26,451.952 ms | | `gson` | `scip-java` | 88,653.499 ms | 231,398.489 ms | | `koin` | `scip-java` | 211,263.800 ms | 967,711.761 ms | | `serilog` | `scip-dotnet` | 20,498.324 ms | 25,085.071 ms | @@ -148,7 +148,7 @@ A strict result's provenance name must equal the provider below. If it is absent | `go` | `samchon-graph-go` | Current changed-input export is a full rebuild and does not retain a resident `go/packages` checker session. | A missing Go 1.25+ toolchain, missing pinned scip-go corroborator or invalid workspace/module load declines the strict provider. | `gopls`, then `@samchon/graph-sitter`. | | `lua` | `samchon-graph-lua` | The current exporter calls `vm.getRefs` per declaration and proves references only; #83 replaces it with an occurrence-oriented resident traversal. | A missing LuaLS binary/exporter, invalid workspace result or bounded request declines the strict provider. | Generic LuaLS, then `@samchon/graph-sitter`. | | `rust` | `samchon-rust-analyzer-hir` | The producer is currently available from the draft fork PR rather than a rust-analyzer release, and Rust has no `renders` relationship family. | A missing pinned producer, incompatible commit/schema, malformed transaction, invalid checkpoint, unsupported bounded option or failed Cargo workspace load declines this route. | Stock `rust-analyzer-scip`, then generic rust-analyzer, then `@samchon/graph-sitter`. | -| `c`, `cpp` | `scip-clang` | The current artifact proves declarations but no graph edge family because enclosing attribution and type-definition relationships are absent. | A missing producer/decoder, missing or invalid compilation database, or an unresolved per-unit compiler declines the strict provider. | `clangd`, then `@samchon/graph-sitter`. | +| `c`, `cpp` | `clangd-snapshot` | Calls, instantiation, exports, implements and dispatch are explicitly partial; C/C++ have no decorates, renders or tests family, and the producer is currently a draft fork rather than an LLVM release. | A missing pinned producer, missing or invalid compilation database, incompatible schema/commit, incomplete indexing batch, source/configuration movement or compiler error declines this route. | `scip-clang`, then stock `clangd`, then `@samchon/graph-sitter`. | | `java`, `kotlin` | `scip-java` | The released Gradle path disables configuration cache and runs `clean scipCompileAll`; it is navigation fallback for #74/#76, not compiler-owned calls or accesses. | A missing producer/decoder/JDK, unsupported Maven or Gradle project, or invalid dependency/build configuration declines the strict provider. | `jdtls` or `kotlin-language-server`, then `@samchon/graph-sitter`. | | `csharp` | `scip-dotnet` | The artifact proves declarations but no graph edge family and can log MSBuildWorkspace failures after publishing. | A missing producer/decoder/.NET SDK, absent solution/project input or invalid MSBuild load declines the strict provider. | `csharp-ls`, then `@samchon/graph-sitter`. | | `python` | `scip-python` | The bundled historical Pyright core proves references only and can recover from malformed pyproject configuration with defaults. | A missing producer/decoder/Python interpreter, absent project input or unusable Python environment declines the strict provider. | `pyright-langserver`, then `@samchon/graph-sitter`. | diff --git a/docs/provider-support.json b/docs/provider-support.json index 092dd345..57ae952f 100644 --- a/docs/provider-support.json +++ b/docs/provider-support.json @@ -125,37 +125,38 @@ "childIssues": ["https://github.com/samchon/compiler-graph/issues/72"] }, { - "provider": "scip-clang", + "provider": "clangd-snapshot", "languages": ["c", "cpp"], "status": "registered", - "authority": "semantic-index", - "facts": [], - "commands": ["scip-clang", "scip"], + "authority": "compiler", + "facts": ["contains", "exports", "imports", "calls", "accesses", "instantiates", "type_ref", "extends", "implements", "overrides", "dispatches", "references"], + "commands": ["samchon-clangd", "clangd"], "projectCommandSources": ["compile_commands.json", "build/compile_commands.json"], - "environmentOverrides": ["SAMCHON_GRAPH_SCIP_CLANG", "SAMCHON_GRAPH_SCIP"], - "install": "Install `scip-clang` 0.4.0 and the `scip` decoder; provide a generated `compile_commands.json`.", + "environmentOverrides": ["SAMCHON_GRAPH_CLANGD_SNAPSHOT"], + "install": "Build the `samchon/llvm-project` graph-snapshot fork at commit `8ca950e1bd50a895145e6a5447f5d3253eabcc8c`; point `SAMCHON_GRAPH_CLANGD_SNAPSHOT` at `clangd` or install it as `samchon-clangd`, and provide a compilation database.", "installSources": [ - {"label": "scip-clang 0.4.0 release", "url": "https://github.com/sourcegraph/scip-clang/releases/tag/v0.4.0"}, - {"label": "SCIP releases", "url": "https://github.com/sourcegraph/scip/releases"} + {"label": "native Clang graph producer PR", "url": "https://github.com/samchon/llvm-project/pull/1"}, + {"label": "LLVM build instructions", "url": "https://llvm.org/docs/CMake.html"} ], - "resolution": "Project-local producer/decoder precede PATH; `SAMCHON_GRAPH_SCIP_CLANG` and `SAMCHON_GRAPH_SCIP` select absolute tools. Compilers come from the compilation database.", - "requirements": "A valid compilation database and every named compiler/working directory/generated build input.", - "platforms": ["linux", "macos", "windows-when-installed"], - "mode": "unchanged-snapshot-reuse; full-rebuild-on-change", - "nativeAnalysis": "scip-clang runs one batch over the exact compilation database and its per-unit compiler commands.", - "exportMerge": "The complete decoded artifact publishes declarations but no currently defensible edge family; producer scheduling can move header selection.", - "reuseResident": "Unchanged inputs reuse the validated snapshot; every changed build reruns the batch producer.", - "limitations": "The current artifact proves declarations but no graph edge family because enclosing attribution and type-definition relationships are absent.", - "decline": "A missing producer/decoder, missing or invalid compilation database, or an unresolved per-unit compiler declines the strict provider.", - "fallback": "`clangd`, then `@samchon/graph-sitter`.", + "resolution": "Absolute `SAMCHON_GRAPH_CLANGD_SNAPSHOT`, then project/PATH `samchon-clangd`, then a project/PATH `clangd` only when its version reports the pinned producer commit.", + "requirements": "A valid compilation database plus every source, header, generated input, command, target and working-directory identity used by its translation units.", + "platforms": ["linux", "macos", "windows"], + "mode": "resident-no-op-reuse; complete changed-TU/configuration replacement; content-addressed deltas", + "nativeAnalysis": "The pinned clangd fork runs every registered command, keeps headers scoped by translation unit and configuration, and captures complete Clang roles, relations, macros, includes, diagnostics and source digests from the same compiler pass.", + "exportMerge": "Versioned native shards cross one optimistic atomic snapshot; the client verifies producer identity, complete manifests, native and common shard digests, coverage, unresolved boundaries and source identities before publication.", + "reuseResident": "No-op requests reuse the exact resident graph; changed sources or compilation-database commands reindex their owning translation units while a failed batch preserves the last complete generation without publishing it as current.", + "limitations": "Calls, instantiation, exports, implements and dispatch are explicitly partial; C/C++ have no decorates, renders or tests family, and the producer is currently a draft fork rather than an LLVM release.", + "decline": "A missing pinned producer, missing or invalid compilation database, incompatible schema/commit, incomplete indexing batch, source/configuration movement or compiler error declines this route.", + "fallback": "`scip-clang`, then stock `clangd`, then `@samchon/graph-sitter`.", "experimentLanguages": ["c", "cpp"], - "experimentTool": "scip-clang", - "experimentCapabilities": ["universe", "diskDigests"], + "experimentTool": "samchon-clangd", + "experimentCapabilities": ["coverage", "diagnostics", "diskDigests", "incremental", "sourceDigests", "universe", "unresolved"], + "benchmarkProvider": "scip-clang", "benchmarks": [ {"project": "redis", "strictMs": 22794.688115, "fallbackMs": 262905.79583}, {"project": "leveldb", "strictMs": 8352.928418, "fallbackMs": 26451.951757} ], - "upstream": "https://github.com/sourcegraph/scip-clang", + "upstream": "https://github.com/samchon/llvm-project", "childIssues": ["https://github.com/samchon/compiler-graph/issues/73"] }, { diff --git a/packages/graph/src/provider/GRAPH_PROVIDERS.ts b/packages/graph/src/provider/GRAPH_PROVIDERS.ts index 534a8081..ac805a36 100644 --- a/packages/graph/src/provider/GRAPH_PROVIDERS.ts +++ b/packages/graph/src/provider/GRAPH_PROVIDERS.ts @@ -1,4 +1,5 @@ import { IGraphProvider } from "./IGraphProvider"; +import { cppGraphProvider } from "./cpp/cppGraphProvider"; import { goGraphProvider } from "./go/goGraphProvider"; import { luaGraphProvider } from "./lua/luaGraphProvider"; import { rustGraphProvider } from "./rust/rustGraphProvider"; @@ -26,6 +27,9 @@ export const GRAPH_PROVIDERS: readonly IGraphProvider[] = [ goGraphProvider, luaGraphProvider, rustGraphProvider, - ...standardScipProviders, + cppGraphProvider, + ...standardScipProviders.filter( + (provider) => provider.name !== "scip-clang", + ), ...standardSidecarProviders, ]; diff --git a/packages/graph/src/provider/cpp/CPP_CLANG_FACTS.ts b/packages/graph/src/provider/cpp/CPP_CLANG_FACTS.ts new file mode 100644 index 00000000..4eaf2971 --- /dev/null +++ b/packages/graph/src/provider/cpp/CPP_CLANG_FACTS.ts @@ -0,0 +1,6 @@ +import { GRAPH_EDGE_KINDS, GraphEdgeKind } from "../../typings"; + +export const CPP_CLANG_FACTS: readonly GraphEdgeKind[] = + GRAPH_EDGE_KINDS.filter( + (kind) => !["decorates", "renders", "tests"].includes(kind), + ); diff --git a/packages/graph/src/provider/cpp/CPP_CLANG_PRODUCER_COMMIT.ts b/packages/graph/src/provider/cpp/CPP_CLANG_PRODUCER_COMMIT.ts new file mode 100644 index 00000000..5b6e1268 --- /dev/null +++ b/packages/graph/src/provider/cpp/CPP_CLANG_PRODUCER_COMMIT.ts @@ -0,0 +1,3 @@ +/** Exact samchon/llvm-project producer revision required by this adapter. */ +export const CPP_CLANG_PRODUCER_COMMIT = + "8ca950e1bd50a895145e6a5447f5d3253eabcc8c"; diff --git a/packages/graph/src/provider/cpp/CPP_CLANG_PROVIDER.ts b/packages/graph/src/provider/cpp/CPP_CLANG_PROVIDER.ts new file mode 100644 index 00000000..be810738 --- /dev/null +++ b/packages/graph/src/provider/cpp/CPP_CLANG_PROVIDER.ts @@ -0,0 +1 @@ +export const CPP_CLANG_PROVIDER = "clangd-snapshot"; diff --git a/packages/graph/src/provider/cpp/CppGraphClient.ts b/packages/graph/src/provider/cpp/CppGraphClient.ts new file mode 100644 index 00000000..7b9df08b --- /dev/null +++ b/packages/graph/src/provider/cpp/CppGraphClient.ts @@ -0,0 +1,517 @@ +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { createHash } from "node:crypto"; + +import { compareOrdinal as compareText } from "@samchon/graph-sitter"; + +import { LspClient } from "../../lsp/LspClient"; +import { LspResponseError } from "../../lsp/LspResponseError"; +import { GraphLanguage } from "../../typings"; +import { IBulkGraphSession } from "../IBulkGraphSession"; +import { CppGraphSnapshotAdapter } from "./CppGraphSnapshotAdapter"; +import { ICppGraphSnapshot } from "./ICppGraphSnapshot"; + +const GRAPH_METHOD = "samchon/graphSnapshot"; +const SERVER_CANCELLED = -32802; +const CONTENT_MODIFIED = -32801; +const DEFAULT_READY_TIMEOUT_MS = 300_000; +const RETRY_DELAY_MS = 50; +const PAGE_SHARDS = 32; + +/** Resident LSP client for the pinned clangd graph-snapshot producer. */ +export class CppGraphClient implements IBulkGraphSession { + public readonly kind = "bulk" as const; + public readonly languages: readonly GraphLanguage[]; + public readonly root: string; + + private readonly lsp: LspClient; + private readonly adapter: CppGraphSnapshotAdapter; + private readonly validate: ( + snapshot: IBulkGraphSession.ISnapshot, + ) => void; + private readonly initializationOptions: unknown; + private readonly requestTimeoutMs: number | undefined; + private readonly readyTimeoutMs: number; + private readonly lifecycleAbort = new AbortController(); + private queue: Promise = Promise.resolve(); + private initialized: Promise | undefined; + private watchedInputs = new Map(); + private version = 0; + private closed = false; + private closing: Promise | undefined; + + public constructor(options: CppGraphClient.IOptions) { + this.root = options.root; + this.languages = [...options.languages]; + this.adapter = new CppGraphSnapshotAdapter( + options.root, + options.producerCommit, + ); + this.validate = options.validate ?? (() => undefined); + this.initializationOptions = options.initializationOptions; + this.requestTimeoutMs = options.requestTimeoutMs; + this.readyTimeoutMs = options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS; + this.lsp = new LspClient( + options.command, + options.args ?? [], + options.requestTimeoutMs, + options.root, + options.maxMessageBytes, + options.windowsVerbatimArguments, + undefined, + serverRequest, + ); + } + + public get generation(): number { + return this.version; + } + + public get current(): IBulkGraphSession.ISnapshot | undefined { + return this.adapter.store.current; + } + + public refresh( + options: { signal?: AbortSignal } = {}, + ): Promise { + if (this.closed) { + return Promise.reject(new Error("C/C++ clang graph: session is closed")); + } + return this.enqueue(async () => { + const signal = combineSignals(options.signal, this.lifecycleAbort.signal); + await this.initialize(signal); + this.notifyInputChanges(); + const raw = await this.requestSnapshot(signal); + const result = this.adapter.apply(raw, this.validate); + this.commitSnapshotInputs(result.snapshot); + if (!result.changed) { + return { + changed: false, + generation: this.version, + mode: result.mode, + snapshot: result.snapshot, + }; + } + this.version += 1; + return { + changed: true, + generation: this.version, + mode: result.mode, + snapshot: result.snapshot, + }; + }, options.signal); + } + + public close(): Promise { + if (this.closing !== undefined) return this.closing; + this.closed = true; + this.lifecycleAbort.abort(new Error("C/C++ clang graph: session is closed")); + this.closing = this.lsp.close(); + return this.closing; + } + + private initialize(signal: AbortSignal): Promise { + this.initialized ??= this.initializeOnce(this.lifecycleAbort.signal); + return signal === this.lifecycleAbort.signal + ? this.initialized + : raceWithAbort(this.initialized, signal); + } + + private async initializeOnce(signal: AbortSignal): Promise { + await this.lsp.request( + "initialize", + { + processId: process.pid, + rootUri: pathToFileURL(this.root).href, + capabilities: { workspace: { configuration: true } }, + ...(this.initializationOptions === undefined + ? {} + : { initializationOptions: this.initializationOptions }), + workspaceFolders: [ + { + uri: pathToFileURL(this.root).href, + name: "samchon-graph-cpp", + }, + ], + }, + this.requestTimeoutMs, + signal, + ); + this.lsp.notify("initialized", {}); + const inputs = inputDigests(this.root); + const changes = [...inputs] + .filter(([, digest]) => digest !== null) + .map(([file]) => ({ uri: pathToFileURL(file).href, type: 1 })); + if (changes.length !== 0) { + this.lsp.notify("workspace/didChangeWatchedFiles", { changes }); + } + this.watchedInputs = inputs; + } + + private notifyInputChanges(): void { + const current = inputDigests(this.root, this.current); + const files = new Set([...this.watchedInputs.keys(), ...current.keys()]); + const changes: Array<{ uri: string; type: 1 | 2 | 3 }> = []; + for (const file of [...files].sort(compareText)) { + const before = this.watchedInputs.get(file); + const after = current.get(file); + if (before === after) continue; + const type = before === undefined || before === null ? 1 : after === null || after === undefined ? 3 : 2; + changes.push({ uri: pathToFileURL(file).href, type }); + } + if (changes.length !== 0) { + this.lsp.notify("workspace/didChangeWatchedFiles", { changes }); + } + this.watchedInputs = current; + } + + private commitSnapshotInputs(snapshot: IBulkGraphSession.ISnapshot): void { + for (const [file, source] of snapshot.sources) { + if (!path.isAbsolute(file)) continue; + this.watchedInputs.set( + file, + source.diskDigest === "" ? null : source.diskDigest, + ); + } + } + + private async requestSnapshot( + signal: AbortSignal, + ): Promise { + const deadline = performance.now() + this.readyTimeoutMs; + for (;;) { + throwIfAborted(signal); + try { + return await this.requestSnapshotPages(signal); + } catch (error) { + if ( + !(error instanceof LspResponseError) || + (error.code !== SERVER_CANCELLED && error.code !== CONTENT_MODIFIED) + ) { + throw error; + } + if (error.code === CONTENT_MODIFIED) this.notifyInputChanges(); + if (performance.now() >= deadline) { + throw new Error( + `C/C++ clang graph: producer did not become ready within ${String(this.readyTimeoutMs)} ms: ${error.message}`, + ); + } + await delay(RETRY_DELAY_MS, signal); + } + } + } + + private async requestSnapshotPages( + signal: AbortSignal, + ): Promise { + const knownGeneration = this.adapter.generation; + let cursor: string | undefined; + let expectedOffset = 0; + let expectedTotal: number | undefined; + let combined: ICppGraphSnapshot | undefined; + const cursors = new Set(); + for (;;) { + const value = await this.lsp.request( + GRAPH_METHOD, + { + ...(knownGeneration === undefined ? {} : { knownGeneration }), + ...(cursor === undefined ? {} : { cursor }), + maxShards: PAGE_SHARDS, + }, + this.requestTimeoutMs, + signal, + ); + assertSnapshotPage(value, expectedOffset, expectedTotal); + const page = value; + expectedTotal ??= page.page.total; + if (combined === undefined) { + combined = structuredClone(page); + } else { + assertSameGeneration(combined, page); + if (page.manifest.length !== 0 || page.deletes.length !== 0) { + throw new Error( + "C/C++ clang graph: continuation repeated generation metadata", + ); + } + combined.upserts.push(...structuredClone(page.upserts)); + combined.phases.validationMillis += page.phases.validationMillis; + combined.phases.semanticMillis += page.phases.semanticMillis; + combined.phases.shardMillis += page.phases.shardMillis; + combined.phases.encodeMillis += page.phases.encodeMillis; + combined.phases.totalMillis += page.phases.totalMillis; + } + expectedOffset += page.page.count; + if (page.page.nextCursor === null) { + if (expectedOffset !== expectedTotal) { + throw new Error("C/C++ clang graph: paged generation ended early"); + } + combined.page = { + offset: 0, + count: combined.upserts.length, + total: combined.upserts.length, + nextCursor: null, + }; + return combined; + } + if ( + expectedOffset >= expectedTotal || + cursors.has(page.page.nextCursor) + ) { + throw new Error("C/C++ clang graph: invalid continuation cursor"); + } + cursors.add(page.page.nextCursor); + cursor = page.page.nextCursor; + } + } + + private enqueue( + task: () => Promise, + signal?: AbortSignal, + ): Promise { + let resolveResult!: (value: T) => void; + let rejectResult!: (error: Error) => void; + let started = false; + let settled = false; + const result = new Promise((resolve, reject) => { + resolveResult = (value) => { + settled = true; + resolve(value); + }; + rejectResult = (error) => { + settled = true; + reject(error); + }; + }); + const cancelQueued = (): void => { + if (!started) rejectResult(cancelledError(signal)); + }; + if (signal?.aborted) { + rejectResult(cancelledError(signal)); + return result; + } + signal?.addEventListener("abort", cancelQueued, { once: true }); + this.queue = this.queue + .catch(() => undefined) + .then(async () => { + started = true; + signal?.removeEventListener("abort", cancelQueued); + if (settled) return; + try { + resolveResult(await task()); + } catch (error) { + rejectResult(asError(error)); + } + }); + return result; + } +} + +export namespace CppGraphClient { + export interface IOptions { + root: string; + languages: readonly GraphLanguage[]; + command: string; + args?: readonly string[]; + producerCommit: string; + initializationOptions?: unknown; + requestTimeoutMs?: number; + readyTimeoutMs?: number; + maxMessageBytes?: number; + windowsVerbatimArguments?: boolean; + validate?: (snapshot: IBulkGraphSession.ISnapshot) => void; + } +} + +interface ICompileCommand { + directory?: unknown; + file?: unknown; +} + +function compilationDatabaseFiles(root: string): string[] { + for (const candidate of [ + path.join(root, "compile_commands.json"), + path.join(root, "build", "compile_commands.json"), + ]) { + try { + const parsed = JSON.parse(fs.readFileSync(candidate, "utf8")) as unknown; + if (!Array.isArray(parsed)) continue; + const files = new Set(); + for (const row of parsed as ICompileCommand[]) { + if (typeof row.file !== "string" || row.file === "") continue; + const directory = + typeof row.directory === "string" && row.directory !== "" + ? row.directory + : path.dirname(candidate); + files.add( + path.resolve( + path.isAbsolute(row.file) ? root : directory, + row.file, + ), + ); + } + return [...files].sort(compareText); + } catch { + continue; + } + } + return []; +} + +function inputDigests( + root: string, + snapshot?: IBulkGraphSession.ISnapshot, +): Map { + const files = new Set([ + path.join(root, ".clangd"), + path.join(root, "compile_flags.txt"), + path.join(root, "compile_commands.json"), + path.join(root, "build", "compile_commands.json"), + ...compilationDatabaseFiles(root), + ]); + for (const file of snapshot?.sources.keys() ?? []) { + if (path.isAbsolute(file)) files.add(file); + } + return new Map( + [...files] + .sort(compareText) + .map((file) => [file, fileDigest(file)] as const), + ); +} + +function fileDigest(file: string): string | null { + try { + return createHash("sha256").update(fs.readFileSync(file)).digest("hex"); + } catch { + return null; + } +} + +function serverRequest(method: string, params: unknown): unknown { + if (method !== "workspace/configuration") return null; + const items = (params as { items?: unknown })?.items; + return Array.isArray(items) ? items.map(() => null) : []; +} + +function delay(milliseconds: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal.removeEventListener("abort", abort); + resolve(undefined); + }, milliseconds); + timer.unref?.(); + const abort = (): void => { + clearTimeout(timer); + signal.removeEventListener("abort", abort); + reject(cancelledError(signal)); + }; + signal.addEventListener("abort", abort, { once: true }); + }); +} + +function combineSignals( + caller: AbortSignal | undefined, + lifecycle: AbortSignal, +): AbortSignal { + return caller === undefined ? lifecycle : AbortSignal.any([caller, lifecycle]); +} + +function raceWithAbort(task: Promise, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(cancelledError(signal)); + return new Promise((resolve, reject) => { + const abort = (): void => reject(cancelledError(signal)); + signal.addEventListener("abort", abort, { once: true }); + void task + .then((value) => { + signal.removeEventListener("abort", abort); + resolve(value); + }) + .catch((error: unknown) => { + signal.removeEventListener("abort", abort); + reject(error); + }); + }); +} + +function throwIfAborted(signal: AbortSignal): void { + if (signal.aborted) throw cancelledError(signal); +} + +function cancelledError(signal?: AbortSignal): Error { + const reason = signal?.reason === undefined ? "" : `: ${String(signal.reason)}`; + const error = new Error(`C/C++ clang graph: snapshot request cancelled${reason}`); + error.name = "AbortError"; + return error; +} + +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + +function assertSnapshotPage( + value: unknown, + expectedOffset: number, + expectedTotal: number | undefined, +): asserts value is ICppGraphSnapshot { + if ( + value === null || + typeof value !== "object" || + !Array.isArray((value as ICppGraphSnapshot).upserts) || + !Array.isArray((value as ICppGraphSnapshot).deletes) || + !Array.isArray((value as ICppGraphSnapshot).manifest) + ) { + throw new Error("C/C++ clang graph: malformed paged generation"); + } + const snapshot = value as ICppGraphSnapshot; + const page = snapshot.page; + if ( + page === null || + typeof page !== "object" || + !Number.isSafeInteger(page.offset) || + !Number.isSafeInteger(page.count) || + !Number.isSafeInteger(page.total) || + page.offset !== expectedOffset || + page.count !== snapshot.upserts.length || + page.count < 0 || + page.total < page.offset + page.count || + (expectedTotal !== undefined && page.total !== expectedTotal) || + (page.nextCursor !== null && + (typeof page.nextCursor !== "string" || page.nextCursor === "")) || + snapshot.phases === null || + typeof snapshot.phases !== "object" + ) { + throw new Error("C/C++ clang graph: malformed snapshot page envelope"); + } + for (const value of [ + snapshot.phases.validationMillis, + snapshot.phases.semanticMillis, + snapshot.phases.shardMillis, + snapshot.phases.encodeMillis, + snapshot.phases.totalMillis, + ]) { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error("C/C++ clang graph: malformed page telemetry"); + } + } + if (typeof snapshot.phases.cacheHit !== "boolean") { + throw new Error("C/C++ clang graph: malformed page cache state"); + } +} + +function assertSameGeneration( + first: ICppGraphSnapshot, + next: ICppGraphSnapshot, +): void { + if ( + first.protocolVersion !== next.protocolVersion || + first.schemaVersion !== next.schemaVersion || + first.sequence !== next.sequence || + first.generation !== next.generation || + first.baseGeneration !== next.baseGeneration || + first.phases.cacheHit !== next.phases.cacheHit || + JSON.stringify(first.producer) !== JSON.stringify(next.producer) || + JSON.stringify(first.universe) !== JSON.stringify(next.universe) + ) { + throw new Error("C/C++ clang graph: continuation crossed generations"); + } +} diff --git a/packages/graph/src/provider/cpp/CppGraphSnapshotAdapter.ts b/packages/graph/src/provider/cpp/CppGraphSnapshotAdapter.ts new file mode 100644 index 00000000..5e64b002 --- /dev/null +++ b/packages/graph/src/provider/cpp/CppGraphSnapshotAdapter.ts @@ -0,0 +1,1378 @@ +import { createHash } from "node:crypto"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { compareOrdinal as compareText } from "@samchon/graph-sitter"; + +import { + ISamchonGraphCoverage, + ISamchonGraphDiagnostic, + ISamchonGraphEdge, + ISamchonGraphEvidence, + ISamchonGraphNode, + ISamchonGraphUnresolved, +} from "../../structures"; +import { + GRAPH_EDGE_KINDS, + GraphEdgeKind, + GraphLanguage, + GraphNodeKind, +} from "../../typings"; +import { GraphSnapshotProtocol } from "../GraphSnapshotProtocol"; +import { IBulkGraphSession } from "../IBulkGraphSession"; +import { semanticGraphNodeId } from "../semanticIdentity"; +import { CPP_CLANG_FACTS } from "./CPP_CLANG_FACTS"; +import { CPP_CLANG_PROVIDER } from "./CPP_CLANG_PROVIDER"; +import { ICppGraphSnapshot } from "./ICppGraphSnapshot"; + +const SHA256 = /^[a-f0-9]{64}$/u; +const ROLE = { + declaration: 1 << 0, + definition: 1 << 1, + reference: 1 << 2, + read: 1 << 3, + write: 1 << 4, + call: 1 << 5, + dynamic: 1 << 6, + childOf: 1 << 10, + baseOf: 1 << 11, + overrideOf: 1 << 12, + calledBy: 1 << 14, + extendedBy: 1 << 15, + accessorOf: 1 << 16, + containedBy: 1 << 17, + specializationOf: 1 << 19, + nameReference: 1 << 20, +} as const; +const TYPE_KINDS = new Set([6, 7, 8, 9, 10, 11, 12, 28, 29, 31]); +const CAPABILITIES = [ + "coverage", + "diagnostics", + "diskDigests", + "incremental", + "sourceDigests", + "universe", + "unresolved", +]; + +/** Converts one validated native clangd generation into the common protocol. */ +export class CppGraphSnapshotAdapter { + public readonly store: GraphSnapshotProtocol.Store; + private rawShards = new Map(); + private graphShards = new Map(); + private rawGeneration: string | undefined; + + public constructor( + private readonly root: string, + private readonly producerCommit: string, + ) { + this.store = new GraphSnapshotProtocol.Store(root); + } + + public get generation(): string | undefined { + return this.rawGeneration; + } + + public apply( + raw: ICppGraphSnapshot, + validate: (snapshot: IBulkGraphSession.ISnapshot) => void, + ): CppGraphSnapshotAdapter.IResult { + assertSnapshot(raw, this.producerCommit); + if ( + raw.baseGeneration !== null && + raw.baseGeneration !== this.rawGeneration + ) { + throw new Error("C/C++ clang graph: stale producer base generation"); + } + const prior = this.store.current; + if ( + prior !== undefined && + raw.generation === this.rawGeneration && + raw.baseGeneration === this.rawGeneration && + raw.upserts.length === 0 && + raw.deletes.length === 0 && + raw.manifest.length === 0 && + raw.page.total === 0 && + raw.phases.cacheHit && + raw.universe.digest === prior.provenance.universe + ) { + return { changed: false, mode: "unchanged", snapshot: prior }; + } + const nextRaw = + raw.baseGeneration === null + ? new Map() + : new Map(this.rawShards); + const touched = new Set(); + for (const key of raw.deletes) { + if (touched.has(key) || !nextRaw.delete(key)) { + throw new Error(`C/C++ clang graph: invalid delete ${key}`); + } + touched.add(key); + } + for (const shard of raw.upserts) { + assertShard(shard, producerFingerprint(raw.producer)); + if (touched.has(shard.key)) { + throw new Error(`C/C++ clang graph: duplicate delta ${shard.key}`); + } + touched.add(shard.key); + nextRaw.set(shard.key, structuredClone(shard)); + } + const expectedManifest = [...nextRaw.values()] + .sort((left, right) => compareText(left.key, right.key)) + .map((shard) => ({ key: shard.key, digest: shard.digest })); + if ( + expectedManifest.length !== raw.manifest.length || + expectedManifest.some( + (entry, index) => + entry.key !== raw.manifest[index]?.key || + entry.digest !== raw.manifest[index]?.digest, + ) + ) { + throw new Error("C/C++ clang graph: producer manifest mismatch"); + } + assertNativeGeneration(raw, expectedManifest, nextRaw); + + const hello = helloOf(raw, nextRaw); + const languagesChanged = + prior !== undefined && + JSON.stringify(prior.languages) !== JSON.stringify(hello.languages); + const universeChanged = + prior !== undefined && raw.universe.digest !== prior.provenance.universe; + const requiresReload = languagesChanged || universeChanged; + const nextGraph = + raw.baseGeneration === null || requiresReload + ? new Map() + : new Map(this.graphShards); + const graphUpserts = requiresReload ? [...nextRaw.values()] : raw.upserts; + for (const shard of graphUpserts) { + nextGraph.set( + graphKey(shard.key), + adaptShard(this.root, raw, shard, hello.languages), + ); + } + const sequence = (prior?.protocol?.sequence ?? 0) + 1; + const manifest = [...nextGraph] + .sort(([left], [right]) => compareText(left, right)) + .map(([key, shard]) => ({ + key, + digest: GraphSnapshotProtocol.shardDigest(shard), + })); + const ordered = manifest.map((entry) => nextGraph.get(entry.key)!); + const targets = [...new Set(ordered.map((shard) => shard.target))].sort( + compareText, + ); + const begin: GraphSnapshotProtocol.IBegin = { + type: "begin", + sequence, + generation: raw.generation, + ...(raw.baseGeneration !== null && prior !== undefined && !requiresReload + ? { + baseSequence: prior.protocol!.sequence, + baseGeneration: prior.protocol!.generation, + } + : {}), + universe: raw.universe.digest, + manifest: GraphSnapshotProtocol.manifestDigest( + ordered.flatMap((shard) => shard.sources), + ), + targets, + }; + const facts = factsOf(hello, begin, ordered); + const commit: GraphSnapshotProtocol.ICommit = { + type: "commit", + sequence, + generation: raw.generation, + shards: manifest, + factDigest: GraphSnapshotProtocol.factDigest(facts), + }; + const frames = framesOf(hello, begin, commit, manifest, nextGraph, prior); + const fullBegin: GraphSnapshotProtocol.IBegin = { + ...begin, + baseSequence: undefined, + baseGeneration: undefined, + }; + const fullFrames: GraphSnapshotProtocol.Frame[] = [hello, fullBegin]; + for (const entry of manifest) { + fullFrames.push({ + type: "upsertShard", + digest: entry.digest, + shard: structuredClone(nextGraph.get(entry.key)!), + }); + } + fullFrames.push(commit); + new GraphSnapshotProtocol.Store(this.root).apply(fullFrames, { validate }); + const snapshot = this.store.apply(frames, { validate }); + this.rawShards = nextRaw; + this.graphShards = nextGraph; + this.rawGeneration = raw.generation; + return { + changed: true, + mode: + prior === undefined + ? "initial" + : begin.baseGeneration === undefined + ? "reload" + : "incremental", + snapshot, + }; + } +} + +export namespace CppGraphSnapshotAdapter { + export interface IResult { + changed: boolean; + mode: IBulkGraphSession.Mode; + snapshot: IBulkGraphSession.ISnapshot; + } +} + +function framesOf( + hello: GraphSnapshotProtocol.IHello, + begin: GraphSnapshotProtocol.IBegin, + commit: GraphSnapshotProtocol.ICommit, + manifest: readonly IBulkGraphSession.IShard[], + shards: ReadonlyMap, + prior: IBulkGraphSession.ISnapshot | undefined, +): GraphSnapshotProtocol.Frame[] { + const frames: GraphSnapshotProtocol.Frame[] = [hello, begin]; + if (begin.baseGeneration === undefined) { + for (const entry of manifest) { + frames.push({ + type: "upsertShard", + digest: entry.digest, + shard: structuredClone(shards.get(entry.key)!), + }); + } + } else { + const old = new Map( + prior!.protocol!.shards.map((entry) => [entry.key, entry.digest]), + ); + for (const entry of manifest) { + if (old.get(entry.key) === entry.digest) continue; + frames.push({ + type: "upsertShard", + digest: entry.digest, + shard: structuredClone(shards.get(entry.key)!), + }); + } + } + frames.push(commit); + return frames; +} + +interface IContext { + root: string; + raw: ICppGraphSnapshot; + shard: ICppGraphSnapshot.IShard; + graph: ICppGraphSnapshot.ITU; + language: GraphLanguage; + target: string; + nodes: Map; + ids: Map; + files: Map; + edges: Map; + unresolved: ISamchonGraphUnresolved[]; +} + +function adaptShard( + root: string, + raw: ICppGraphSnapshot, + shard: ICppGraphSnapshot.IShard, + snapshotLanguages: readonly GraphLanguage[], +): GraphSnapshotProtocol.IShard { + const graph = shard.graph; + const language = graph.language as GraphLanguage; + const context: IContext = { + root, + raw, + shard, + graph, + language, + target: `${graph.targetTriple}#${graph.commandDigest}`, + nodes: new Map(), + ids: new Map(), + files: new Map(), + edges: new Map(), + unresolved: [], + }; + for (const source of graph.sources) fileNode(context, source.uri); + for (const symbol of graph.symbols) symbolNode(context, symbol); + for (const macro of graph.macros) macroNode(context, macro); + for (const module of graph.modules) { + moduleNode(context, module.name, module.evidence); + } + + for (const symbol of graph.symbols) { + const id = endpoint(context, symbol.id); + const owner = + symbol.ownerUsr === "" ? undefined : endpoint(context, symbol.ownerUsr); + const location = preferredRange(symbol); + addEdge( + context, + owner ?? fileNode(context, location.file), + id, + "contains", + location, + ); + if (symbol.exported) { + addEdge( + context, + fileNode(context, location.file), + id, + "exports", + location, + ); + } + } + for (const include of graph.includes) { + addEdge( + context, + fileNode(context, include.source), + fileNode(context, include.target), + "imports", + include.evidence, + ); + } + for (const module of graph.modules) { + addEdge( + context, + fileNode(context, module.evidence.file || graph.mainFileUri), + moduleNode(context, module.name, module.evidence), + "imports", + module.evidence, + ); + } + for (const occurrence of graph.occurrences) { + adaptOccurrence(context, occurrence); + } + // Occurrences carry the exact use-site span. Add them before the coarser + // semantic relation lane so endpoint-pair deduplication retains that span. + for (const relation of graph.relations) adaptRelation(context, relation); + for (const macro of graph.macros) adaptMacro(context, macro); + + const coverageByFamily = new Map( + shard.coverage.map((row) => [row.family, row.state]), + ); + const advertised = new Set(CPP_CLANG_FACTS); + const coverage: ISamchonGraphCoverage[] = snapshotLanguages.flatMap( + (coverageLanguage) => + GRAPH_EDGE_KINDS.map((family) => ({ + provider: CPP_CLANG_PROVIDER, + language: coverageLanguage, + target: context.target, + family, + state: + coverageLanguage === language && advertised.has(family) + ? (coverageByFamily.get( + family, + )! as ISamchonGraphCoverage["state"]) + : "unsupported", + })), + ); + const fallbackEvidence = evidenceOf(root, { + file: graph.mainFileUri, + startLine: 0, + startColumn: 0, + endLine: 0, + endColumn: 0, + }); + for (const row of coverage) { + if ( + row.language !== language || + row.state !== "partial" || + context.unresolved.some((site) => site.family === row.family) + ) { + continue; + } + context.unresolved.push({ + provider: CPP_CLANG_PROVIDER, + language, + target: context.target, + universe: raw.universe.digest, + family: row.family, + evidence: fallbackEvidence, + reason: "provider-gap", + }); + } + const diagnostics: ISamchonGraphDiagnostic[] = graph.diagnostics.map( + (row) => ({ + file: graphFile(root, row.range.file), + line: row.range.file === "" ? 0 : row.range.startLine + 1, + column: row.range.file === "" ? 0 : row.range.startColumn + 1, + code: row.code, + message: row.message, + severity: row.severity as ISamchonGraphDiagnostic["severity"], + }), + ); + return { + key: graphKey(shard.key), + target: context.target, + languages: [language], + nodes: [...context.nodes.values()].sort((left, right) => + compareText(left.id, right.id), + ), + edges: [...context.edges.values()].sort(compareEdge), + diagnostics, + coverage, + unresolved: context.unresolved, + sources: graph.sources.map((source) => ({ + file: sourceFile(root, source.uri), + checkerDigest: source.digest, + diskDigest: source.diskDigest, + })), + }; +} + +function symbolNode( + context: IContext, + symbol: ICppGraphSnapshot.ISymbol, +): string { + const range = preferredRange(symbol); + const file = graphFile( + context.root, + range.file || context.graph.mainFileUri, + ); + const kind = nodeKind(symbol.kind); + const display = symbol.qualifiedName || symbol.name; + const id = semanticGraphNodeId( + { + version: 2, + language: context.language, + symbol: symbol.id, + role: kind, + native: { key: symbol.id, stability: "semantic" }, + scope: { + target: context.shard.configuration, + translationUnit: graphFile( + context.root, + context.graph.mainFileUri, + ), + document: file, + }, + stability: "persistent", + }, + display, + ); + const node: ISamchonGraphNode = { + id, + kind, + language: context.language, + name: symbol.name, + ...(symbol.qualifiedName !== "" && symbol.qualifiedName !== symbol.name + ? { qualifiedName: symbol.qualifiedName } + : {}), + file, + external: isExternal(file), + exported: symbol.exported, + ...(symbol.signature === "" ? {} : { signature: symbol.signature }), + ...(validRange(range) + ? { evidence: evidenceOf(context.root, range) } + : {}), + ...(symbol.attributes.length === 0 + ? {} + : { + decorators: symbol.attributes.map((attribute) => ({ + name: attribute.name, + arguments: [], + })), + }), + }; + context.ids.set(symbol.id, id); + context.nodes.set(id, node); + return id; +} + +function macroNode(context: IContext, macro: ICppGraphSnapshot.IMacro): string { + const found = context.ids.get(macro.id); + if (found !== undefined) return found; + const file = graphFile( + context.root, + macro.definition.file || + macro.spelling.file || + macro.expansion.file || + context.graph.mainFileUri, + ); + const id = semanticGraphNodeId( + { + version: 2, + language: context.language, + symbol: macro.id, + role: "variable", + native: { key: macro.id, stability: "semantic" }, + scope: { + target: context.shard.configuration, + translationUnit: graphFile( + context.root, + context.graph.mainFileUri, + ), + document: file, + }, + stability: "persistent", + }, + macro.name, + ); + context.ids.set(macro.id, id); + context.nodes.set(id, { + id, + kind: "variable", + language: context.language, + name: macro.name, + file, + external: isExternal(file), + ...(validRange(macro.definition) + ? { evidence: evidenceOf(context.root, macro.definition) } + : {}), + }); + return id; +} + +function moduleNode( + context: IContext, + name: string, + range: ICppGraphSnapshot.IRange, +): string { + const raw = `module:${name}`; + const found = context.ids.get(raw); + if (found !== undefined) return found; + const file = graphFile( + context.root, + range.file || context.graph.mainFileUri, + ); + const id = semanticGraphNodeId( + { + version: 2, + language: context.language, + symbol: raw, + role: "module", + native: { key: raw, stability: "semantic" }, + scope: { + target: context.shard.configuration, + translationUnit: graphFile( + context.root, + context.graph.mainFileUri, + ), + }, + stability: "persistent", + }, + name, + ); + context.ids.set(raw, id); + context.nodes.set(id, { + id, + kind: "module", + language: context.language, + name, + file, + external: isExternal(file), + }); + return id; +} + +function fileNode(context: IContext, uri: string): string { + const key = uri || context.graph.mainFileUri; + const found = context.files.get(key); + if (found !== undefined) return found; + const file = graphFile(context.root, key); + const id = semanticGraphNodeId( + { + version: 2, + language: context.language, + symbol: `file:${key}`, + role: "file", + native: { key, stability: "semantic" }, + scope: { + target: context.shard.configuration, + translationUnit: graphFile( + context.root, + context.graph.mainFileUri, + ), + document: file, + }, + stability: "persistent", + }, + file, + ); + context.files.set(key, id); + context.nodes.set(id, { + id, + kind: "file", + language: context.language, + name: path.posix.basename(file), + qualifiedName: file, + file, + external: isExternal(file), + }); + return id; +} + +function endpoint(context: IContext, raw: string): string { + const found = context.ids.get(raw); + if (found !== undefined) return found; + const name = raw; + const id = semanticGraphNodeId( + { + version: 2, + language: context.language, + symbol: name, + role: "external_symbol", + native: { key: name, stability: "semantic" }, + scope: { + target: context.shard.configuration, + translationUnit: graphFile( + context.root, + context.graph.mainFileUri, + ), + }, + stability: "persistent", + }, + name, + ); + context.ids.set(raw, id); + context.nodes.set(id, { + id, + kind: "external_symbol", + language: context.language, + name, + file: "bundled:///clang/external", + external: true, + }); + return id; +} + +function adaptRelation( + context: IContext, + relation: ICppGraphSnapshot.IRelation, +): void { + const subject = endpoint(context, relation.subjectId); + const object = endpoint(context, relation.objectId); + if (relation.roles & (ROLE.childOf | ROLE.containedBy)) { + addEdge(context, object, subject, "contains", relation.evidence); + } + if (relation.roles & ROLE.baseOf) { + addEdge(context, object, subject, "extends", relation.evidence); + } + if (relation.roles & ROLE.extendedBy) { + addEdge(context, object, subject, "extends", relation.evidence); + } + if (relation.roles & ROLE.overrideOf) { + addEdge(context, subject, object, "overrides", relation.evidence); + } + if (relation.roles & ROLE.calledBy) { + addEdge(context, object, subject, "calls", relation.evidence); + } + if (relation.roles & ROLE.accessorOf) { + addEdge(context, subject, object, "accesses", relation.evidence); + } + if (relation.roles & ROLE.specializationOf) { + addEdge(context, subject, object, "instantiates", relation.evidence); + } +} + +function adaptOccurrence( + context: IContext, + occurrence: ICppGraphSnapshot.IOccurrence, +): void { + const target = endpoint(context, occurrence.id); + const range = validRange(occurrence.expansion) + ? occurrence.expansion + : occurrence.spelling; + const owner = + occurrence.containerId === "" + ? fileNode(context, range.file) + : endpoint(context, occurrence.containerId); + if (occurrence.roles & ROLE.call) { + addEdge(context, owner, target, "calls", range); + } + if ((occurrence.roles & ROLE.call) && occurrence.targetKind === 23) { + addEdge(context, owner, target, "instantiates", range); + } + if (occurrence.roles & (ROLE.read | ROLE.write)) { + addEdge(context, owner, target, "accesses", range); + } + if (occurrence.roles & ROLE.reference) { + addEdge(context, owner, target, "references", range); + } + if ( + (occurrence.roles & (ROLE.reference | ROLE.nameReference)) && + TYPE_KINDS.has(occurrence.targetKind) + ) { + addEdge(context, owner, target, "type_ref", range); + } + if ((occurrence.roles & ROLE.dynamic) && validRange(range)) { + context.unresolved.push({ + provider: CPP_CLANG_PROVIDER, + language: context.language, + target: context.target, + universe: context.raw.universe.digest, + family: "dispatches", + evidence: evidenceOf(context.root, range), + reason: "dynamic", + candidates: [target], + }); + } +} + +function adaptMacro(context: IContext, macro: ICppGraphSnapshot.IMacro): void { + const target = macroNode(context, macro); + const range = validRange(macro.expansion) + ? macro.expansion + : macro.spelling; + const definitionFile = + macro.definition.file || context.graph.mainFileUri; + if (macro.roles & ROLE.reference) { + addEdge( + context, + fileNode(context, range.file || definitionFile), + target, + "references", + range, + ); + } + if (macro.roles & (ROLE.declaration | ROLE.definition)) { + addEdge( + context, + fileNode(context, definitionFile), + target, + "contains", + macro.definition, + ); + } +} + +function addEdge( + context: IContext, + from: string, + to: string, + kind: GraphEdgeKind, + range: ICppGraphSnapshot.IRange, +): void { + if (from === to) return; + const key = `${from}\0${to}\0${kind}`; + if (context.edges.has(key)) return; + context.edges.set(key, { + from, + to, + kind, + ...(validRange(range) + ? { evidence: evidenceOf(context.root, range) } + : {}), + }); +} + +function preferredRange( + symbol: ICppGraphSnapshot.ISymbol, +): ICppGraphSnapshot.IRange { + return validRange(symbol.definition) ? symbol.definition : symbol.declaration; +} + +function evidenceOf( + root: string, + range: ICppGraphSnapshot.IRange, +): ISamchonGraphEvidence { + return { + file: graphFile(root, range.file), + startLine: range.startLine + 1, + startCol: range.startColumn + 1, + endLine: range.endLine + 1, + endCol: range.endColumn + 1, + }; +} + +function graphFile(root: string, source: string): string { + if (source === "") return ""; + assertSupportedSource(source); + let absolute = source; + if (source.startsWith("file:")) { + absolute = fileURLToPath(source); + } + if (!path.isAbsolute(absolute)) return absolute.replaceAll("\\", "/"); + const relative = path.relative(root, absolute).replaceAll("\\", "/"); + return relative; +} + +function sourceFile(root: string, source: string): string { + assertSupportedSource(source); + if (source.startsWith("bundled:///")) return source; + if (source.startsWith("file:")) { + return path.normalize(fileURLToPath(source)); + } + return path.normalize( + path.isAbsolute(source) ? source : path.resolve(root, source), + ); +} + +function assertSupportedSource(source: string): void { + if ( + !source.startsWith("file:") && + !source.startsWith("bundled:///") && + !path.isAbsolute(source) && + /^[A-Za-z][A-Za-z0-9+.-]*:/u.test(source) + ) { + throw new Error(`unsupported C/C++ graph source URI: ${source}`); + } +} + +function graphKey(raw: string): string { + return `cpp-shard:${sha256(raw)}`; +} + +function isExternal(file: string): boolean { + return file.startsWith("../") || file.startsWith("bundled:///"); +} + +function validRange(range: ICppGraphSnapshot.IRange): boolean { + return range.file !== ""; +} + +function nodeKind(kind: number): GraphNodeKind { + return NODE_KINDS[kind] ?? "external_symbol"; +} + +const NODE_KINDS: Record = { + 1: "module", + 2: "namespace", + 3: "namespace", + 4: "variable", + 5: "file", + 6: "enum", + 7: "class", + 8: "class", + 9: "interface", + 10: "type", + 11: "type", + 12: "type", + 13: "function", + 14: "variable", + 15: "field", + 16: "field", + 17: "method", + 18: "method", + 19: "method", + 20: "property", + 21: "property", + 22: "property", + 23: "constructor", + 24: "method", + 25: "method", + 26: "parameter", + 27: "type", + 28: "type", + 29: "type", + 30: "parameter", + 31: "interface", +}; + +function helloOf( + raw: ICppGraphSnapshot, + shards: ReadonlyMap, +): GraphSnapshotProtocol.IHello { + const languages = new Set(); + for (const shard of shards.values()) { + if (shard.graph.language === "c" || shard.graph.language === "cpp") { + languages.add(shard.graph.language); + } + } + return { + type: "hello", + protocolVersion: 1, + schemaVersion: 1, + producerSchemaVersion: raw.schemaVersion, + provider: CPP_CLANG_PROVIDER, + producer: raw.producer.name, + producerVersion: `${raw.producer.version} (${raw.producer.commit})`, + compilerVersion: raw.producer.version, + languages: [...languages].sort(compareText), + authority: "compiler", + supportedFacts: [...CPP_CLANG_FACTS], + capabilities: [...CAPABILITIES], + }; +} + +function factsOf( + hello: GraphSnapshotProtocol.IHello, + begin: GraphSnapshotProtocol.IBegin, + shards: readonly GraphSnapshotProtocol.IShard[], +): Pick< + IBulkGraphSession.ISnapshot, + | "languages" + | "nodes" + | "edges" + | "diagnostics" + | "coverage" + | "unresolved" + | "provenance" +> { + return { + languages: [...hello.languages], + nodes: shards.flatMap((shard) => shard.nodes), + edges: shards.flatMap((shard) => shard.edges), + diagnostics: shards.flatMap((shard) => shard.diagnostics), + coverage: shards.flatMap((shard) => shard.coverage), + unresolved: shards.flatMap((shard) => shard.unresolved), + provenance: { + provider: hello.provider, + authority: hello.authority, + facts: [...hello.supportedFacts], + schemaVersion: hello.producerSchemaVersion, + tool: hello.producer, + toolVersion: hello.producerVersion, + compilerVersion: hello.compilerVersion, + protocolVersion: hello.protocolVersion, + universe: begin.universe, + capabilities: [...hello.capabilities], + }, + }; +} + +function assertSnapshot(raw: ICppGraphSnapshot, commit: string): void { + if ( + raw === null || + typeof raw !== "object" || + raw.protocolVersion !== 1 || + raw.schemaVersion !== 1 + ) { + throw new Error("C/C++ clang graph: unsupported producer protocol/schema"); + } + if ( + raw.producer?.name !== "samchon-clangd" || + typeof raw.producer.version !== "string" || + typeof raw.producer.commit !== "string" || + !raw.producer.commit.includes(commit) || + raw.producer.version === "" + ) { + throw new Error("C/C++ clang graph: producer identity/commit mismatch"); + } + if ( + !SHA256.test(raw.universe?.digest) || + !SHA256.test(raw.generation) || + !Number.isSafeInteger(raw.sequence) || + raw.sequence < 1 || + !Array.isArray(raw.upserts) || + !Array.isArray(raw.deletes) || + !Array.isArray(raw.manifest) || + !isRecord(raw.page) || + !isRecord(raw.phases) + ) { + throw new Error("C/C++ clang graph: malformed generation envelope"); + } + if (raw.baseGeneration !== null && !SHA256.test(raw.baseGeneration)) { + throw new Error("C/C++ clang graph: malformed base generation"); + } + if ( + !isRecord(raw.universe) || + !canonicalStrings(raw.universe.targets, false) || + raw.universe.targets.length === 0 || + !canonicalStrings(raw.universe.workspaceRoots, true) || + !canonicalStrings(raw.universe.toolchains, false) || + !canonicalStrings(raw.universe.configurations, false) + ) { + throw new Error("C/C++ clang graph: malformed universe"); + } + if (!canonicalStrings(raw.deletes, false)) { + throw new Error("C/C++ clang graph: malformed delete set"); + } + if ( + !nonnegativeInteger(raw.page.offset) || + !nonnegativeInteger(raw.page.count) || + !nonnegativeInteger(raw.page.total) || + raw.page.offset !== 0 || + raw.page.count !== raw.upserts.length || + raw.page.total !== raw.upserts.length || + raw.page.nextCursor !== null + ) { + throw new Error("C/C++ clang graph: malformed assembled page"); + } + let lastManifest = ""; + for (const entry of raw.manifest) { + if ( + !isRecord(entry) || + typeof entry.key !== "string" || + entry.key === "" || + entry.key <= lastManifest || + typeof entry.digest !== "string" || + !SHA256.test(entry.digest) + ) { + throw new Error("C/C++ clang graph: malformed native manifest"); + } + lastManifest = entry.key; + } + if ( + !nonnegativeInteger(raw.phases.validationMillis) || + !nonnegativeInteger(raw.phases.semanticMillis) || + !nonnegativeInteger(raw.phases.shardMillis) || + !nonnegativeInteger(raw.phases.encodeMillis) || + !nonnegativeInteger(raw.phases.totalMillis) || + typeof raw.phases.cacheHit !== "boolean" || + raw.phases.totalMillis !== + raw.phases.validationMillis + + raw.phases.semanticMillis + + raw.phases.shardMillis + + raw.phases.encodeMillis + ) { + throw new Error("C/C++ clang graph: malformed phase telemetry"); + } +} + +function assertShard( + shard: ICppGraphSnapshot.IShard, + expectedProducerFingerprint: string, +): void { + if ( + !isRecord(shard) || + typeof shard.key !== "string" || + shard.key === "" || + typeof shard.source !== "string" || + shard.source === "" || + typeof shard.configuration !== "string" || + !SHA256.test(shard.digest) || + !SHA256.test(shard.checkerDigest) || + !SHA256.test(shard.interfaceFingerprint) || + !Array.isArray(shard.coverage) || + !isRecord(shard.graph) || + shard.configuration !== shard.graph.commandDigest || + shard.graph.hadErrors + ) { + throw new Error(`C/C++ clang graph: malformed shard ${shard.key}`); + } + assertGraph(shard.graph, shard.key); + if (shard.graph.producerFingerprint !== expectedProducerFingerprint) { + throw new Error( + `C/C++ clang graph: compiler fingerprint mismatch ${shard.key}`, + ); + } + const source = shard.graph.sources.find( + (entry) => entry.uri === shard.graph.mainFileUri, + ); + if ( + source?.digest !== shard.checkerDigest || + shard.source !== shard.graph.mainFile + ) { + throw new Error(`C/C++ clang graph: mismatched main source ${shard.key}`); + } + const expected = sha256( + `${shard.key}\n${shard.checkerDigest}\n${shard.interfaceFingerprint}\n${JSON.stringify(shard.graph)}`, + ); + if (expected !== shard.digest) { + throw new Error(`C/C++ clang graph: shard digest mismatch ${shard.key}`); + } + const families = new Set(); + for (const row of shard.coverage) { + if ( + families.has(row.family) || + !GRAPH_EDGE_KINDS.includes(row.family as GraphEdgeKind) || + !["complete", "partial", "unsupported"].includes(row.state) + ) { + throw new Error(`C/C++ clang graph: invalid coverage ${shard.key}`); + } + families.add(row.family); + } + if (families.size !== GRAPH_EDGE_KINDS.length) { + throw new Error(`C/C++ clang graph: incomplete coverage ${shard.key}`); + } +} + +function assertGraph(graph: ICppGraphSnapshot.ITU, key: string): void { + if ( + typeof graph.producerFingerprint !== "string" || + !SHA256.test(graph.producerFingerprint) || + typeof graph.mainFileUri !== "string" || + graph.mainFileUri === "" || + typeof graph.mainFile !== "string" || + graph.mainFile === "" || + typeof graph.directory !== "string" || + !canonicalStrings(graph.commandLine, true, false) || + typeof graph.output !== "string" || + typeof graph.commandDigest !== "string" || + !SHA256.test(graph.commandDigest) || + typeof graph.toolchainFingerprint !== "string" || + !SHA256.test(graph.toolchainFingerprint) || + typeof graph.targetTriple !== "string" || + graph.targetTriple === "" || + (graph.language !== "c" && graph.language !== "cpp") || + typeof graph.hadErrors !== "boolean" || + !Array.isArray(graph.sources) || + !Array.isArray(graph.symbols) || + !Array.isArray(graph.occurrences) || + !Array.isArray(graph.relations) || + !Array.isArray(graph.macros) || + !Array.isArray(graph.includes) || + !Array.isArray(graph.missingIncludes) || + graph.missingIncludes.length !== 0 || + !Array.isArray(graph.modules) || + !Array.isArray(graph.diagnostics) + ) { + throw new Error(`C/C++ clang graph: malformed graph ${key}`); + } + const sources = new Set(); + for (const source of graph.sources) { + if ( + !isRecord(source) || + typeof source.uri !== "string" || + source.uri === "" || + sources.has(source.uri) || + typeof source.digest !== "string" || + !SHA256.test(source.digest) || + typeof source.diskDigest !== "string" || + (source.diskDigest !== "" && !SHA256.test(source.diskDigest)) || + !nonnegativeInteger(source.flags) + ) { + throw new Error(`C/C++ clang graph: malformed source ${key}`); + } + sources.add(source.uri); + } + const symbols = new Set(); + for (const symbol of graph.symbols) { + if ( + !isRecord(symbol) || + typeof symbol.usr !== "string" || + symbol.usr === "" || + typeof symbol.id !== "string" || + symbol.id === "" || + symbols.has(symbol.id) || + typeof symbol.name !== "string" || + symbol.name === "" || + typeof symbol.qualifiedName !== "string" || + typeof symbol.ownerUsr !== "string" || + typeof symbol.signature !== "string" || + !nonnegativeInteger(symbol.kind) || + !nonnegativeInteger(symbol.subKind) || + !nonnegativeInteger(symbol.properties) || + typeof symbol.local !== "boolean" || + typeof symbol.internal !== "boolean" || + typeof symbol.anonymous !== "boolean" || + typeof symbol.exported !== "boolean" || + !validNativeRange(symbol.declaration) || + !validNativeRange(symbol.definition) || + !Array.isArray(symbol.attributes) || + symbol.attributes.some( + (attribute) => + !isRecord(attribute) || + typeof attribute.name !== "string" || + attribute.name === "" || + !validNativeRange(attribute.range), + ) + ) { + throw new Error(`C/C++ clang graph: malformed symbol ${key}`); + } + symbols.add(symbol.id); + } + for (const occurrence of graph.occurrences) { + if ( + !isRecord(occurrence) || + typeof occurrence.usr !== "string" || + occurrence.usr === "" || + typeof occurrence.id !== "string" || + occurrence.id === "" || + typeof occurrence.containerId !== "string" || + !nonnegativeInteger(occurrence.roles) || + !nonnegativeInteger(occurrence.targetKind) || + !validNativeRange(occurrence.spelling) || + !validNativeRange(occurrence.expansion) + ) { + throw new Error(`C/C++ clang graph: malformed occurrence ${key}`); + } + } + for (const relation of graph.relations) { + if ( + !isRecord(relation) || + typeof relation.subjectId !== "string" || + relation.subjectId === "" || + typeof relation.objectId !== "string" || + relation.objectId === "" || + !nonnegativeInteger(relation.roles) || + !validNativeRange(relation.evidence) + ) { + throw new Error(`C/C++ clang graph: malformed relation ${key}`); + } + } + // Macro rows are occurrences. Definition and reference rows intentionally + // share the stable macro endpoint ID. + for (const macro of graph.macros) { + if ( + !isRecord(macro) || + typeof macro.usr !== "string" || + macro.usr === "" || + typeof macro.id !== "string" || + macro.id === "" || + typeof macro.name !== "string" || + macro.name === "" || + !nonnegativeInteger(macro.roles) || + !validNativeRange(macro.definition) || + !validNativeRange(macro.spelling) || + !validNativeRange(macro.expansion) + ) { + throw new Error(`C/C++ clang graph: malformed macro ${key}`); + } + } + for (const include of graph.includes) { + if ( + !isRecord(include) || + typeof include.source !== "string" || + include.source === "" || + typeof include.target !== "string" || + include.target === "" || + typeof include.spelling !== "string" || + typeof include.angled !== "boolean" || + typeof include.moduleImported !== "boolean" || + !validNativeRange(include.evidence) + ) { + throw new Error(`C/C++ clang graph: malformed include ${key}`); + } + } + for (const module of graph.modules) { + if ( + !isRecord(module) || + typeof module.name !== "string" || + module.name === "" || + !nonnegativeInteger(module.roles) || + !validNativeRange(module.evidence) + ) { + throw new Error(`C/C++ clang graph: malformed module ${key}`); + } + } + for (const diagnostic of graph.diagnostics) { + if ( + !isRecord(diagnostic) || + typeof diagnostic.message !== "string" || + diagnostic.message === "" || + typeof diagnostic.code !== "string" || + diagnostic.code === "" || + !["error", "warning", "info", "hint"].includes(diagnostic.severity) || + !validNativeRange(diagnostic.range) + ) { + throw new Error(`C/C++ clang graph: malformed diagnostic ${key}`); + } + } +} + +function assertNativeGeneration( + raw: ICppGraphSnapshot, + manifest: Array<{ key: string; digest: string }>, + shards: ReadonlyMap, +): void { + const configurations = [ + ...new Set([...shards.values()].map((shard) => shard.configuration)), + ].sort(compareText); + const targets = [ + ...new Set([...shards.values()].map((shard) => shard.graph.targetTriple)), + ].sort(compareText); + const toolchains = [ + ...new Set( + [...shards.values()].map( + (shard) => shard.graph.toolchainFingerprint, + ), + ), + ].sort(compareText); + if ( + JSON.stringify(configurations) !== + JSON.stringify(raw.universe.configurations) || + JSON.stringify(targets) !== JSON.stringify(raw.universe.targets) || + JSON.stringify(toolchains) !== JSON.stringify(raw.universe.toolchains) + ) { + throw new Error("C/C++ clang graph: universe does not describe its shards"); + } + const generationMaterial = manifest + .map( + (entry) => + `${Buffer.byteLength(entry.key, "utf8")}:${entry.key}${entry.digest}`, + ) + .join(""); + let universeMaterial = coordinate( + "producer", + producerFingerprint(raw.producer), + ); + for (const target of raw.universe.targets) + universeMaterial += coordinate("target", target); + for (const root of raw.universe.workspaceRoots) + universeMaterial += coordinate("root", root); + for (const toolchain of raw.universe.toolchains) + universeMaterial += coordinate("toolchain", toolchain); + for (const configuration of raw.universe.configurations) + universeMaterial += coordinate("configuration", configuration); + const universe = sha256(universeMaterial); + if ( + universe !== raw.universe.digest || + sha256(universe + generationMaterial) !== raw.generation + ) { + throw new Error("C/C++ clang graph: generation digest mismatch"); + } +} + +function coordinate(label: string, value: string): string { + return `${label}:${Buffer.byteLength(value, "utf8")}:${value}`; +} + +function producerFingerprint( + producer: ICppGraphSnapshot.IProducer, +): string { + return sha256( + `samchon-graph-schema:1\nversion:${producer.version}\nrepository:${producer.commit}`, + ); +} + +function validNativeRange( + value: unknown, +): value is ICppGraphSnapshot.IRange { + if ( + !isRecord(value) || + typeof value.file !== "string" || + !nonnegativeInteger(value.startLine) || + !nonnegativeInteger(value.startColumn) || + !nonnegativeInteger(value.endLine) || + !nonnegativeInteger(value.endColumn) + ) { + return false; + } + if (value.file === "") { + return ( + value.startLine === 0 && + value.startColumn === 0 && + value.endLine === 0 && + value.endColumn === 0 + ); + } + return ( + value.endLine > value.startLine || + (value.endLine === value.startLine && + value.endColumn >= value.startColumn) + ); +} + +function canonicalStrings( + value: unknown, + allowEmpty: boolean, + canonical = true, +): value is string[] { + if (!Array.isArray(value)) return false; + let prior: string | undefined; + for (const entry of value) { + if (typeof entry !== "string" || (!allowEmpty && entry === "")) { + return false; + } + if (canonical && prior !== undefined && entry <= prior) return false; + prior = entry; + } + return true; +} + +function nonnegativeInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) >= 0; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function sha256(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function compareEdge( + left: ISamchonGraphEdge, + right: ISamchonGraphEdge, +): number { + return compareText( + `${left.from}\0${left.to}\0${left.kind}`, + `${right.from}\0${right.to}\0${right.kind}`, + ); +} diff --git a/packages/graph/src/provider/cpp/ICppGraphSnapshot.ts b/packages/graph/src/provider/cpp/ICppGraphSnapshot.ts new file mode 100644 index 00000000..27c85386 --- /dev/null +++ b/packages/graph/src/provider/cpp/ICppGraphSnapshot.ts @@ -0,0 +1,180 @@ +export interface ICppGraphSnapshot { + protocolVersion: number; + schemaVersion: number; + producer: ICppGraphSnapshot.IProducer; + universe: ICppGraphSnapshot.IUniverse; + sequence: number; + generation: string; + baseGeneration: string | null; + upserts: ICppGraphSnapshot.IShard[]; + deletes: string[]; + manifest: ICppGraphSnapshot.IManifestEntry[]; + page: ICppGraphSnapshot.IPage; + phases: ICppGraphSnapshot.IPhases; +} + +export namespace ICppGraphSnapshot { + export interface IProducer { + name: string; + version: string; + commit: string; + } + + export interface IUniverse { + digest: string; + targets: string[]; + workspaceRoots: string[]; + toolchains: string[]; + configurations: string[]; + } + + export interface IManifestEntry { + key: string; + digest: string; + } + + export interface IPage { + offset: number; + count: number; + total: number; + nextCursor: string | null; + } + + export interface IPhases { + validationMillis: number; + semanticMillis: number; + shardMillis: number; + encodeMillis: number; + totalMillis: number; + cacheHit: boolean; + } + + export interface IShard { + key: string; + source: string; + configuration: string; + checkerDigest: string; + interfaceFingerprint: string; + digest: string; + graph: ITU; + coverage: Array<{ + family: string; + state: string; + }>; + } + + export interface ITU { + producerFingerprint: string; + mainFileUri: string; + mainFile: string; + directory: string; + commandLine: string[]; + output: string; + commandDigest: string; + toolchainFingerprint: string; + targetTriple: string; + language: string; + hadErrors: boolean; + sources: ISource[]; + symbols: ISymbol[]; + occurrences: IOccurrence[]; + relations: IRelation[]; + macros: IMacro[]; + includes: IInclude[]; + missingIncludes: IMissingInclude[]; + modules: IModule[]; + diagnostics: IDiagnostic[]; + } + + export interface IRange { + file: string; + startLine: number; + startColumn: number; + endLine: number; + endColumn: number; + } + + export interface ISource { + uri: string; + digest: string; + diskDigest: string; + flags: number; + } + + export interface ISymbol { + usr: string; + id: string; + name: string; + qualifiedName: string; + ownerUsr: string; + signature: string; + kind: number; + subKind: number; + properties: number; + local: boolean; + internal: boolean; + anonymous: boolean; + exported: boolean; + declaration: IRange; + definition: IRange; + attributes: Array<{ + name: string; + range: IRange; + }>; + } + + export interface IOccurrence { + usr: string; + id: string; + containerId: string; + roles: number; + targetKind: number; + spelling: IRange; + expansion: IRange; + } + + export interface IRelation { + subjectId: string; + objectId: string; + roles: number; + evidence: IRange; + } + + export interface IMacro { + usr: string; + id: string; + name: string; + roles: number; + definition: IRange; + spelling: IRange; + expansion: IRange; + } + + export interface IInclude { + source: string; + target: string; + spelling: string; + angled: boolean; + moduleImported: boolean; + evidence: IRange; + } + + export interface IMissingInclude { + source: string; + spelling: string; + angled: boolean; + } + + export interface IModule { + name: string; + roles: number; + evidence: IRange; + } + + export interface IDiagnostic { + message: string; + code: string; + severity: string; + range: IRange; + } +} diff --git a/packages/graph/src/provider/cpp/cppGraphProvider.ts b/packages/graph/src/provider/cpp/cppGraphProvider.ts new file mode 100644 index 00000000..6ccaf4e1 --- /dev/null +++ b/packages/graph/src/provider/cpp/cppGraphProvider.ts @@ -0,0 +1,146 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +import { spawnableCommand } from "../../utils/spawnableCommand"; +import { assertGraphSnapshotContract } from "../assertGraphSnapshotContract"; +import { IGraphProvider } from "../IGraphProvider"; +import { resolveProviderCommand } from "../resolveProviderCommand"; +import { standardScipProviders } from "../scip/standardScipProviders"; +import { CPP_CLANG_FACTS } from "./CPP_CLANG_FACTS"; +import { CPP_CLANG_PRODUCER_COMMIT } from "./CPP_CLANG_PRODUCER_COMMIT"; +import { CPP_CLANG_PROVIDER } from "./CPP_CLANG_PROVIDER"; +import { CppGraphClient } from "./CppGraphClient"; + +const OVERRIDE = "SAMCHON_GRAPH_CLANGD_SNAPSHOT"; +const clangScipProvider = standardScipProviders.find( + (provider) => provider.name === "scip-clang", +); +/* c8 ignore next 4 -- the static standard-provider registry always contains + * the scip-clang descriptor; startup must still fail closed if it is edited. */ +if (clangScipProvider === undefined) { + throw new Error("clangd-snapshot: the scip-clang fallback is not registered"); +} + +export const cppGraphProvider: IGraphProvider = { + name: CPP_CLANG_PROVIDER, + languages: ["c", "cpp"], + authority: "compiler", + facts: CPP_CLANG_FACTS, + resolution: { + commands: ["samchon-clangd", "clangd"], + projectCommandSources: [ + "compile_commands.json", + "build/compile_commands.json", + ], + environmentOverrides: [OVERRIDE], + }, + fallbacks: [clangScipProvider], + buildInputs: clangScipProvider.buildInputs, + configuration: (_root, env) => [ + `producer-commit=${CPP_CLANG_PRODUCER_COMMIT}`, + `${OVERRIDE}=${env[OVERRIDE] ?? "unconfigured"}`, + ], + refuse: (options) => { + const refused = [ + options.server === undefined ? undefined : "server", + options.maxFiles === undefined ? undefined : "maxFiles", + options.lspReferenceLimit === undefined + ? undefined + : "lspReferenceLimit", + ].filter((value): value is string => value !== undefined); + return refused.length === 0 + ? undefined + : `c, cpp: ${CPP_CLANG_PROVIDER} publishes whole-compilation-database generations and cannot honor ${refused.join(", ")}`; + }, + resolve: (root, env) => resolvePinned(root, env), + prepare: (root) => { + if (compilationDatabase(root) === undefined) { + throw new Error( + "clangd-snapshot: compile_commands.json or build/compile_commands.json must contain at least one command", + ); + } + }, + open: (props) => { + const command = spawnableCommand.append( + { ...props.command, args: [...props.command.args] }, + ["--background-index"], + ); + return new CppGraphClient({ + root: props.root, + languages: props.languages, + command: command.command, + args: command.args, + producerCommit: CPP_CLANG_PRODUCER_COMMIT, + initializationOptions: props.options.initializationOptions, + requestTimeoutMs: props.options.lspTimeoutMs, + readyTimeoutMs: props.options.lspReadyTimeoutMs, + maxMessageBytes: props.options.lspMaxMessageBytes, + windowsVerbatimArguments: command.windowsVerbatimArguments, + validate: (snapshot) => + assertGraphSnapshotContract( + snapshot, + cppGraphProvider, + props.languages, + props.root, + ), + }); + }, +}; + +function resolvePinned( + root: string, + env: NodeJS.ProcessEnv, +): IGraphProvider.ICommand | undefined { + for (const command of ["samchon-clangd", "clangd"]) { + const candidate = resolveProviderCommand(root, env, { + command, + override: OVERRIDE, + }); + if (candidate !== undefined && hasPinnedVersion(root, env, candidate)) { + return candidate; + } + } + return undefined; +} + +function hasPinnedVersion( + root: string, + env: NodeJS.ProcessEnv, + command: IGraphProvider.ICommand, +): boolean { + const invocation = spawnableCommand.append( + { ...command, args: [...command.args] }, + ["--version"], + ); + const result = spawnSync(invocation.command, invocation.args, { + cwd: root, + encoding: "utf8", + env, + shell: false, + timeout: 10_000, + windowsHide: true, + windowsVerbatimArguments: invocation.windowsVerbatimArguments, + }); + return ( + result.status === 0 && + result.error === undefined && + result.stdout.includes(CPP_CLANG_PRODUCER_COMMIT) + ); +} + +function compilationDatabase(root: string): string | undefined { + for (const relative of [ + "compile_commands.json", + path.join("build", "compile_commands.json"), + ]) { + const candidate = path.join(root, relative); + try { + const parsed = JSON.parse(fs.readFileSync(candidate, "utf8")) as unknown; + if (Array.isArray(parsed) && parsed.length !== 0) return candidate; + } catch { + continue; + } + } + return undefined; +} diff --git a/packages/graph/src/provider/cpp/index.ts b/packages/graph/src/provider/cpp/index.ts new file mode 100644 index 00000000..37ec6e5b --- /dev/null +++ b/packages/graph/src/provider/cpp/index.ts @@ -0,0 +1,7 @@ +export * from "./CPP_CLANG_FACTS"; +export * from "./CPP_CLANG_PRODUCER_COMMIT"; +export * from "./CPP_CLANG_PROVIDER"; +export * from "./CppGraphClient"; +export * from "./CppGraphSnapshotAdapter"; +export * from "./ICppGraphSnapshot"; +export * from "./cppGraphProvider"; diff --git a/packages/graph/src/provider/index.ts b/packages/graph/src/provider/index.ts index b892fe99..6a1470c0 100644 --- a/packages/graph/src/provider/index.ts +++ b/packages/graph/src/provider/index.ts @@ -7,6 +7,7 @@ export * from "./fallbackCoverage"; export * from "./graphCoverageOf"; export * from "./graphUnresolvedOf"; export * from "./GraphSnapshotProtocol"; +export * from "./cpp"; export * from "./go"; export * from "./IBulkGraphSession"; export * from "./IGraphProvider"; diff --git a/tests/experiment/src/catalog.mjs b/tests/experiment/src/catalog.mjs index af1e67d6..54153d72 100644 --- a/tests/experiment/src/catalog.mjs +++ b/tests/experiment/src/catalog.mjs @@ -140,39 +140,47 @@ export const LANGUAGE_EXPERIMENTS = [ language: "cpp", repository: "https://github.com/fmtlib/fmt.git", commit: "bcaa44d05579c75a83571821faee7acf6a9a0d55", - // Uncapped: scip-clang publishes a whole-workspace artifact and refuses a - // file cap, so a capped row is one it declines to serve. + // Uncapped: the native snapshot publishes a whole-compilation-database + // generation and refuses a file cap. // - // The compilation database is what scip-clang consumes and what a CMake - // project has to be configured to produce; nothing is compiled by this. + // The compilation database enumerates every native clangd graph view and + // is what a CMake project has to be configured to produce; preparation + // itself compiles nothing. prepare: "cmake -S . -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON", - strictProvider: "scip-clang", - strictAuthority: "semantic-index", - strictTool: "scip-clang", - requiredCapabilities: ["universe", "diskDigests"], - // Declarations only. scip-clang 0.4.0 writes range/symbol/roles on - // occurrences, no enclosing_range or enclosing_symbol, and no - // is_type_definition relationship. The common SCIP adapter therefore has - // no grounded origin or typed relationship for an edge. - semanticEdges: [], + strictProvider: "clangd-snapshot", + strictAuthority: "compiler", + strictTool: "samchon-clangd", + requiredCapabilities: [ + "coverage", + "diagnostics", + "diskDigests", + "incremental", + "sourceDigests", + "universe", + "unresolved", + ], + // Native Clang occurrences and relations retain their enclosing symbols, + // exact ranges and TU/configuration identity in one compiler pass. + semanticEdges: [ + "contains", + "exports", + "imports", + "calls", + "accesses", + "instantiates", + "type_ref", + "extends", + "implements", + "overrides", + "dispatches", + "references", + ], + crossFileEdge: "references", semanticLimitation: - "scip-clang 0.4.0 emits no occurrence enclosing_range, SymbolInformation.enclosing_symbol, or type-definition relationship, so its semantic declarations carry no provable graph edge family", - // scip-clang 0.4.0's own CLI states both halves of this: `--deterministic` - // is documented as "Does not support deterministic work scheduling yet", - // and `--print-statistics-path` warns that "non-determinism may affect the - // number of files skipped by individual indexing jobs". The driver gives - // each well-behaved header to one translation unit, and which one wins - // depends on the schedule — so the file set moves, and the manifest with it. - // - // Two ways of buying it back were tried and withdrawn. `--jobs=1` removed - // the variance by serializing the compiler and cost 39x on the redis - // corpus, which is not a trade a strict provider can make: the point of the - // lane is to be faster than the fallback. `--deterministic` alone then held - // this lane above forty-three minutes where it had run in under eleven, and - // its generations still did not reproduce. The limitation is declared - // instead. - regenerationLimitation: - "scip-clang 0.4.0 does not schedule its indexing jobs deterministically, so regenerating an unchanged project can skip a different set of headers; both the source manifest and the fact set can therefore move, because the manifest lists the files the producer reported", + "The native Clang lane retains exact TU/configuration facts, while calls, instantiation, exports, implements and dispatch stay explicitly partial and C/C++ have no decorates, renders or tests family.", + // Background jobs may finish in any order, but the native shard set, + // manifest and generation digest are canonical and publish only after all + // registered configurations agree on one complete source state. lifecycle: { sourceFile: "src/format.cc", editSuffix: "\n// samchon-graph lifecycle edit\n", @@ -188,39 +196,56 @@ export const LANGUAGE_EXPERIMENTS = [ compilationDatabase: "build/compile_commands.json", failureFile: "build/compile_commands.json", failureSuffix: "\n[ not json", - // A compilation database that will not parse makes scip-clang decline - // before publication. The resident records that reason and serves its - // documented fallback until the database is repaired. - failurePolicy: "fallback", - failureLimitation: - "a malformed compilation database makes scip-clang decline without provenance, so the resident publishes an explicitly warned generic/static fallback until the project input is repaired", + // A malformed compilation database invalidates the native universe, so + // the strict resident rejects publication until it is repaired. + failurePolicy: "reject", }, minNodes: 1, - minEdges: 0, + minEdges: 1, }, { language: "c", repository: "https://github.com/libuv/libuv.git", commit: "9d51562c10be60bc1126a3d71803b1038f4fbb7e", - // Uncapped: scip-clang publishes a whole-workspace artifact and refuses a - // file cap, so a capped row is one it declines to serve. + // Uncapped: the native snapshot publishes a whole-compilation-database + // generation and refuses a file cap. // - // The compilation database is what scip-clang consumes and what a CMake - // project has to be configured to produce; nothing is compiled by this. + // The compilation database enumerates every native clangd graph view and + // is what a CMake project has to be configured to produce; preparation + // itself compiles nothing. prepare: "cmake -S . -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON", - strictProvider: "scip-clang", - strictAuthority: "semantic-index", - strictTool: "scip-clang", - requiredCapabilities: ["universe", "diskDigests"], - // The same pinned producer contract as the C++ row: semantic declarations - // are real, but none of the common adapter's edge-grounding fields exists. - semanticEdges: [], + strictProvider: "clangd-snapshot", + strictAuthority: "compiler", + strictTool: "samchon-clangd", + requiredCapabilities: [ + "coverage", + "diagnostics", + "diskDigests", + "incremental", + "sourceDigests", + "universe", + "unresolved", + ], + // The same pinned producer contract as the C++ row retains semantic + // enclosing symbols, exact ranges and TU/configuration identity. + semanticEdges: [ + "contains", + "exports", + "imports", + "calls", + "accesses", + "instantiates", + "type_ref", + "extends", + "implements", + "overrides", + "dispatches", + "references", + ], + crossFileEdge: "references", semanticLimitation: - "scip-clang 0.4.0 emits no occurrence enclosing_range, SymbolInformation.enclosing_symbol, or type-definition relationship, so its semantic declarations carry no provable graph edge family", - // The C and C++ slices share one producer, so they share its scheduling - // boundary as well; see the C++ row for the upstream wording. - regenerationLimitation: - "scip-clang 0.4.0 does not schedule its indexing jobs deterministically, so regenerating an unchanged project can skip a different set of headers; both the source manifest and the fact set can therefore move, because the manifest lists the files the producer reported", + "The native Clang lane retains exact TU/configuration facts, while calls, instantiation, exports, implements and dispatch stay explicitly partial and C/C++ have no decorates, renders or tests family.", + // C and C++ share the same atomic, canonical generation boundary. lifecycle: { sourceFile: "src/uv-common.c", editSuffix: "\n// samchon-graph lifecycle edit\n", @@ -236,13 +261,11 @@ export const LANGUAGE_EXPERIMENTS = [ compilationDatabase: "build/compile_commands.json", failureFile: "build/compile_commands.json", failureSuffix: "\n[ not json", - // The C and C++ slices share the same strict selection boundary. - failurePolicy: "fallback", - failureLimitation: - "a malformed compilation database makes scip-clang decline without provenance, so the resident publishes an explicitly warned generic/static fallback until the project input is repaired", + // The C and C++ slices share the same strict rejection boundary. + failurePolicy: "reject", }, minNodes: 1, - minEdges: 0, + minEdges: 1, }, { language: "java", diff --git a/tests/test-graph/src/features/test_cpp_clang_snapshot_adapter_and_client_are_atomic.ts b/tests/test-graph/src/features/test_cpp_clang_snapshot_adapter_and_client_are_atomic.ts new file mode 100644 index 00000000..20c7a6ba --- /dev/null +++ b/tests/test-graph/src/features/test_cpp_clang_snapshot_adapter_and_client_are_atomic.ts @@ -0,0 +1,967 @@ +import { TestValidator } from "@nestia/e2e"; +import { + CPP_CLANG_PROVIDER, + CPP_CLANG_PRODUCER_COMMIT, + CppGraphClient, + CppGraphSnapshotAdapter, + GRAPH_EDGE_KINDS, + cppGraphProvider, + type ICppGraphSnapshot, +} from "@samchon/graph"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +import { GraphPaths } from "../internal/GraphPaths.js"; + +const COMMIT = CPP_CLANG_PRODUCER_COMMIT; + +export const test_cpp_clang_snapshot_adapter_and_client_are_atomic = async () => { + const root = fixtureRoot(); + const raw = nativeSnapshot(root); + const adapter = new CppGraphSnapshotAdapter(root, COMMIT); + const initial = adapter.apply(raw, () => undefined); + TestValidator.equals( + "the Clang adapter preserves both compilation configurations and semantic facts", + [ + initial.mode, + initial.snapshot.languages, + new Set(initial.snapshot.protocol?.targets).size, + [...new Set(initial.snapshot.edges.map((edge) => edge.kind))].sort(), + initial.snapshot.coverage?.length, + initial.snapshot.unresolved?.every( + (site) => site.reason === "provider-gap" || site.reason === "dynamic", + ), + initial.snapshot.provenance.provider, + initial.snapshot.sources.size, + [...initial.snapshot.sources.keys()].every( + (file) => file.startsWith("bundled:///") || path.isAbsolute(file), + ), + ], + [ + "initial", + ["c", "cpp"], + 2, + [ + "accesses", + "calls", + "contains", + "exports", + "extends", + "imports", + "instantiates", + "overrides", + "references", + "type_ref", + ], + GRAPH_EDGE_KINDS.length * 4, + true, + CPP_CLANG_PROVIDER, + 2, + true, + ], + ); + const nodes = new Map(initial.snapshot.nodes.map((node) => [node.id, node])); + TestValidator.predicate( + "Clang RelationBaseOf becomes derived-to-base inheritance", + initial.snapshot.edges.some( + (edge) => + edge.kind === "extends" && + nodes.get(edge.from)?.name === "Derived" && + nodes.get(edge.to)?.name === "Base", + ), + ); + const overlaid = new CppGraphSnapshotAdapter(root, COMMIT).apply( + nativeSnapshot(root, ["--checker-overlay"]), + () => undefined, + ); + const overlaidSource = overlaid.snapshot.sources.get( + path.resolve(root, "main.cpp"), + ); + TestValidator.predicate( + "checker overlays preserve a distinct native disk digest", + overlaidSource !== undefined && + overlaidSource.checkerDigest !== overlaidSource.diskDigest && + overlaidSource.diskDigest === + sha256(fs.readFileSync(path.resolve(root, "main.cpp"), "utf8")), + ); + const edgeCases = new CppGraphSnapshotAdapter(root, COMMIT).apply( + nativeSnapshot(root, ["--edge-cases"]), + () => undefined, + ); + TestValidator.predicate( + "the adapter preserves valid empty locations, URI forms, and unknown native kinds", + edgeCases.snapshot.nodes.some( + (node) => + node.name === "caller" && node.qualifiedName === "fixture::caller", + ) && + edgeCases.snapshot.nodes.some( + (node) => node.name === "Derived" && node.kind === "external_symbol", + ) && + edgeCases.snapshot.nodes.some( + (node) => node.name === "c:@F@external#" && node.external, + ) && + edgeCases.snapshot.diagnostics.some( + (diagnostic) => diagnostic.line === 0 && diagnostic.column === 0, + ) && + edgeCases.snapshot.edges.some( + (edge) => edge.kind === "overrides" && edge.evidence === undefined, + ) && + edgeCases.snapshot.sources.has("bundled:///fixture/system.h") && + edgeCases.snapshot.sources.has(path.resolve(root, "relative.cpp")), + ); + TestValidator.error( + "a source URI that cannot canonicalize fails the common protocol closed", + () => + new CppGraphSnapshotAdapter(root, COMMIT).apply( + nativeSnapshot(root, ["--invalid-source-uri"]), + () => undefined, + ), + ); + TestValidator.error( + "an unsupported source URI cannot impersonate a project-local file", + () => + new CppGraphSnapshotAdapter(root, COMMIT).apply( + nativeSnapshot(root, ["--unsupported-source-uri"]), + () => undefined, + ), + ); + assertNativeRefusals(root, raw); + await assertProvider(root); + await assertClientLifecycle(root); + await assertClientInputShapes(); + await assertClientPagination(); + await assertClientFailures(fixtureRoot()); +}; + +function fixtureRoot(): string { + const root = GraphPaths.createTempDirectory("samchon-graph-cpp-native-"); + fs.mkdirSync(path.join(root, "include")); + fs.writeFileSync(path.join(root, "main.cpp"), "void caller() {}\n"); + fs.writeFileSync(path.join(root, "absolute.cpp"), "void absolute() {}\n"); + fs.writeFileSync(path.join(root, "include", "fixture.h"), "void callee();\n"); + fs.writeFileSync( + path.join(root, "compile_commands.json"), + JSON.stringify([ + { + directory: root, + file: "main.cpp", + arguments: ["clang", "-x", "c", "-c", "main.cpp"], + }, + { + directory: root, + file: "main.cpp", + arguments: ["clang++", "-x", "c++", "-c", "main.cpp"], + }, + ]), + ); + return root; +} + +async function assertProvider(root: string): Promise { + const override = "SAMCHON_GRAPH_CLANGD_SNAPSHOT"; + const command = nodeShim(root, "samchon-clangd", COMMIT); + const wrong = nodeShim(root, "wrong-clangd", "f".repeat(40)); + const prefixCollision = nodeShim( + root, + "prefix-collision-clangd", + `${COMMIT.slice(0, 9)}${"f".repeat(31)}`, + ); + const resolved = cppGraphProvider.resolve(root, { + ...process.env, + [override]: command, + }); + TestValidator.equals( + "the C/C++ provider resolves only its pinned compiler producer", + [ + resolved !== undefined, + cppGraphProvider.resolve(root, { + ...process.env, + [override]: wrong, + }), + cppGraphProvider.resolve(root, { + ...process.env, + [override]: prefixCollision, + }), + cppGraphProvider.fallbacks?.map((provider) => provider.name), + cppGraphProvider.configuration?.(root, { [override]: command }), + cppGraphProvider.configuration?.(root, {}), + ], + [ + true, + undefined, + undefined, + ["scip-clang"], + [`producer-commit=${COMMIT}`, `${override}=${command}`], + [`producer-commit=${COMMIT}`, `${override}=unconfigured`], + ], + ); + TestValidator.predicate( + "whole-database Clang generations refuse bounded and caller-owned modes", + cppGraphProvider.refuse({ maxFiles: 1 })?.includes("maxFiles") === true && + cppGraphProvider.refuse({ server: "clangd" })?.includes("server") === true && + cppGraphProvider + .refuse({ lspReferenceLimit: 1 }) + ?.includes("lspReferenceLimit") === true && + cppGraphProvider.refuse({}) === undefined, + ); + const session = cppGraphProvider.open({ + root, + command: resolved!, + languages: ["c", "cpp"], + options: {}, + }); + try { + TestValidator.equals( + "the registered C/C++ provider opens its pinned producer contract", + (await session.refresh()).snapshot.provenance.authority, + "compiler", + ); + } finally { + await session.close(); + } + const absent = GraphPaths.createTempDirectory("samchon-graph-cpp-no-cdb-"); + fs.writeFileSync(path.join(absent, "compile_commands.json"), "[]"); + fs.mkdirSync(path.join(absent, "build")); + fs.writeFileSync(path.join(absent, "build", "compile_commands.json"), "bad"); + TestValidator.error( + "the C/C++ provider refuses a project without a compilation database", + () => cppGraphProvider.prepare?.(absent, {}), + ); + fs.writeFileSync(path.join(absent, "compile_commands.json"), "bad"); + fs.writeFileSync( + path.join(absent, "build", "compile_commands.json"), + JSON.stringify([{ directory: absent, file: "main.cpp" }]), + ); + cppGraphProvider.prepare?.(absent, {}); +} + +function nativeSnapshot( + root: string, + args: readonly string[] = [], +): ICppGraphSnapshot { + const result = spawnSync( + process.execPath, + [ + GraphPaths.fakeCppGraphServer, + "--snapshot", + `--commit=${COMMIT}`, + ...args, + ], + { cwd: root, encoding: "utf8", shell: false }, + ); + if (result.status !== 0 || result.error !== undefined) { + throw result.error ?? new Error(result.stderr); + } + return JSON.parse(result.stdout) as ICppGraphSnapshot; +} + +function assertNativeRefusals( + root: string, + valid: ICppGraphSnapshot, +): void { + const rejects = ( + label: string, + mutate: (value: ICppGraphSnapshot) => void, + ): void => { + TestValidator.error(label, () => { + const candidate = structuredClone(valid); + mutate(candidate); + new CppGraphSnapshotAdapter(root, COMMIT).apply(candidate, () => undefined); + }); + }; + rejects("a foreign Clang producer is refused", (value) => { + value.producer.commit = "wrong"; + }); + rejects("an unsupported native protocol is refused", (value) => { + value.protocolVersion = 2; + }); + rejects("a malformed native envelope is refused", (value) => { + value.sequence = 0; + }); + rejects("a malformed native base is refused", (value) => { + value.baseGeneration = "bad"; + }); + rejects("a malformed Clang universe is refused", (value) => { + value.universe.targets.reverse(); + value.universe.targets.push("duplicate"); + }); + rejects("a malformed native phase is refused", (value) => { + value.phases.totalMillis = -1; + }); + rejects("a malformed assembled native page is refused", (value) => { + value.page.offset = 1; + }); + rejects("a malformed native delete set is refused", (value) => { + value.deletes = ["z", "a"]; + }); + rejects("a non-canonical native manifest is refused", (value) => { + value.manifest.push({ ...value.manifest[0]! }); + }); + rejects("a malformed native source is refused", (value) => { + value.upserts[0]!.graph.sources[0]!.digest = "wrong"; + }); + rejects("a malformed native disk digest is refused", (value) => { + value.upserts[0]!.graph.sources[0]!.diskDigest = "wrong"; + }); + rejects("a malformed native shard is refused", (value) => { + value.upserts[0]!.source = ""; + }); + rejects("a foreign compiler fingerprint is refused", (value) => { + value.upserts[0]!.graph.producerFingerprint = "0".repeat(64); + }); + rejects("a malformed native symbol is refused", (value) => { + value.upserts[0]!.graph.symbols[0]!.name = ""; + }); + rejects("a malformed native occurrence is refused", (value) => { + value.upserts[0]!.graph.occurrences[0]!.usr = ""; + }); + rejects("a malformed native relation is refused", (value) => { + value.upserts[0]!.graph.relations[0]!.subjectId = ""; + }); + rejects("a malformed native macro is refused", (value) => { + value.upserts[0]!.graph.macros[0]!.name = ""; + }); + rejects("a malformed native include is refused", (value) => { + value.upserts[0]!.graph.includes[0]!.source = ""; + }); + rejects("a malformed native module is refused", (value) => { + value.upserts[0]!.graph.modules[0]!.name = ""; + }); + rejects("a malformed native diagnostic is refused", (value) => { + value.upserts[0]!.graph.diagnostics[0]!.message = ""; + }); + rejects("a reversed native range is refused", (value) => { + const range = value.upserts[0]!.graph.occurrences[0]!.spelling; + range.startColumn = range.endColumn + 1; + }); + rejects("a negative native range is refused", (value) => { + value.upserts[0]!.graph.occurrences[0]!.spelling.startLine = -1; + }); + rejects("a non-array native universe coordinate is refused", (value) => { + (value.universe as unknown as { targets: null }).targets = null; + }); + rejects("a non-string native universe coordinate is refused", (value) => { + (value.universe.targets as unknown[])[0] = 1; + }); + rejects("an empty required native universe coordinate is refused", (value) => { + value.universe.targets[0] = ""; + }); + rejects("an incomplete native coverage matrix is refused", (value) => { + value.upserts[0]!.coverage.pop(); + }); + rejects("an invalid native coverage row is refused", (value) => { + value.upserts[0]!.coverage[0]!.state = "wrong" as "complete"; + }); + rejects("a mismatched native main source is refused", (value) => { + value.upserts[0]!.source += ".other"; + }); + rejects("a mismatched native shard digest is refused", (value) => { + value.upserts[0]!.digest = "0".repeat(64); + }); + rejects("a mismatched native generation is refused", (value) => { + value.generation = "0".repeat(64); + }); + rejects("a shard-extraneous native universe is refused", (value) => { + value.universe.targets = ["other-target"]; + }); + rejects("a malformed native graph is refused", (value) => { + value.upserts[0]!.graph.targetTriple = ""; + }); + const stale = new CppGraphSnapshotAdapter(root, COMMIT); + stale.apply(structuredClone(valid), () => undefined); + const delta = structuredClone(valid); + delta.baseGeneration = "0".repeat(64); + TestValidator.error("a Clang delta must name the exact resident base", () => + stale.apply(delta, () => undefined), + ); + const invalidDelete = structuredClone(valid); + invalidDelete.baseGeneration = valid.generation; + invalidDelete.deletes = ["missing-shard"]; + TestValidator.error("a Clang delta cannot delete an absent shard", () => + stale.apply(invalidDelete, () => undefined), + ); + const duplicateDelta = structuredClone(valid); + duplicateDelta.baseGeneration = valid.generation; + duplicateDelta.deletes = [duplicateDelta.upserts[0]!.key]; + TestValidator.error("a Clang delta cannot delete and replace one shard", () => + stale.apply(duplicateDelta, () => undefined), + ); + const manifestMismatch = structuredClone(valid); + manifestMismatch.manifest[0]!.digest = "0".repeat(64); + TestValidator.error("native shards must exactly match their manifest", () => + new CppGraphSnapshotAdapter(root, COMMIT).apply( + manifestMismatch, + () => undefined, + ), + ); + + const partial = structuredClone(valid); + partial.baseGeneration = valid.generation; + partial.sequence += 1; + const changed = partial.upserts[0]!; + changed.graph.diagnostics[0]!.message += " (changed)"; + changed.digest = nativeShardDigest(changed); + partial.upserts = [changed]; + partial.manifest = partial.manifest.map((entry) => + entry.key === changed.key ? { key: entry.key, digest: changed.digest } : entry, + ); + partial.generation = nativeGeneration( + partial.universe.digest, + partial.manifest, + ); + partial.page = { offset: 0, count: 1, total: 1, nextCursor: null }; + const partialAdapter = new CppGraphSnapshotAdapter(root, COMMIT); + partialAdapter.apply(structuredClone(valid), () => undefined); + TestValidator.equals( + "a same-universe native delta retains unchanged graph shards", + partialAdapter.apply(partial, () => undefined).mode, + "incremental", + ); + + const database = path.join(root, "compile_commands.json"); + const commands = JSON.parse(fs.readFileSync(database, "utf8")) as unknown[]; + fs.writeFileSync(database, JSON.stringify(commands.slice(1))); + const reloaded = stale.apply(nativeSnapshot(root), () => undefined); + TestValidator.equals( + "a full native generation with a changed language universe reloads atomically", + [reloaded.mode, reloaded.snapshot.languages], + ["reload", ["cpp"]], + ); + fs.writeFileSync(database, JSON.stringify(commands)); + + const cppCommands = commands.map((row, index) => ({ + ...(row as Record), + arguments: [ + "clang++", + "-x", + "c++", + `-DGRAPH_CONFIGURATION=${String(index)}`, + "-c", + "main.cpp", + ], + })); + fs.writeFileSync(database, JSON.stringify(cppCommands)); + const sameLanguage = new CppGraphSnapshotAdapter(root, COMMIT); + const both = nativeSnapshot(root); + sameLanguage.apply(both, () => undefined); + fs.writeFileSync(database, JSON.stringify(cppCommands.slice(1))); + const one = nativeSnapshot(root); + const deletionDelta = structuredClone(one); + deletionDelta.baseGeneration = both.generation; + deletionDelta.deletes = both.manifest + .filter( + (entry) => !one.manifest.some((candidate) => candidate.key === entry.key), + ) + .map((entry) => entry.key) + .sort(); + deletionDelta.upserts = []; + deletionDelta.page = { offset: 0, count: 0, total: 0, nextCursor: null }; + const universeReload = sameLanguage.apply( + deletionDelta, + () => undefined, + ); + TestValidator.equals( + "a configuration-universe deletion reloads every surviving shard", + [universeReload.mode, universeReload.snapshot.languages], + ["reload", ["cpp"]], + ); + fs.writeFileSync(database, JSON.stringify(commands)); +} + +async function assertClientLifecycle(root: string): Promise { + const requestLog = path.join(root, "requests.ndjson"); + const watchLog = path.join(root, "watches.ndjson"); + const client = cppClient(root, [ + `--request-log=${requestLog}`, + `--watch-log=${watchLog}`, + ]); + const initial = await client.refresh(); + const unchanged = await client.refresh(); + fs.writeFileSync(path.join(root, "main.cpp"), "void edited() {}\n"); + const edited = await client.refresh(); + const database = path.join(root, "compile_commands.json"); + const commands = JSON.parse(fs.readFileSync(database, "utf8")) as unknown[]; + fs.writeFileSync(database, JSON.stringify(commands.slice(1))); + const deleted = await client.refresh(); + TestValidator.equals( + "the resident Clang client reuses no-ops and commits one edited delta", + [ + [initial.changed, initial.mode, initial.generation], + [unchanged.changed, unchanged.mode, unchanged.generation], + [edited.changed, edited.mode, edited.generation], + [deleted.changed, deleted.mode, deleted.generation], + client.generation, + edited.snapshot.nodes.some((node) => node.name === "editedCaller"), + readLines(requestLog).map((row) => row.knownGeneration !== undefined), + readLines(watchLog).map((row) => row.changes[0]?.type), + ], + [ + [true, "initial", 1], + [false, "unchanged", 1], + [true, "incremental", 2], + [true, "reload", 3], + 3, + true, + [false, true, true, true], + [1, 2, 2], + ], + ); + await client.close(); + await client.close(); + await rejected( + "a closed Clang graph session rejects refresh", + client.refresh(), + "session is closed", + ); +} + +async function assertClientPagination(): Promise { + const root = GraphPaths.createTempDirectory("samchon-graph-cpp-pages-"); + const commands: Array> = []; + for (let index = 0; index < 35; ++index) { + const file = `page-${String(index).padStart(2, "0")}.cpp`; + fs.writeFileSync(path.join(root, file), "void caller() {}\n"); + commands.push({ + directory: root, + file, + arguments: ["clang++", "-x", "c++", "-c", file], + }); + } + fs.writeFileSync( + path.join(root, "compile_commands.json"), + JSON.stringify(commands), + ); + const requestLog = path.join(root, "requests.ndjson"); + const client = cppClient(root, [`--request-log=${requestLog}`]); + try { + const refreshed = await client.refresh(); + const requests = readLines(requestLog); + TestValidator.equals( + "the resident client assembles bounded native pages before one atomic commit", + [ + refreshed.snapshot.sources.size, + refreshed.snapshot.protocol?.shards.length, + requests.length, + requests.map((request) => request.maxShards), + requests.map((request) => typeof request.cursor), + ], + [35, 35, 2, [32, 32], ["undefined", "string"]], + ); + for (const [corruption, message] of [ + ["generation", "malformed paged generation"], + ["envelope", "malformed snapshot page envelope"], + ["telemetry", "malformed page telemetry"], + ["cache", "malformed page cache state"], + ["early", "paged generation ended early"], + ["cursor", "invalid continuation cursor"], + ["metadata", "continuation repeated generation metadata"], + ["cross-generation", "continuation crossed generations"], + ] as const) { + const broken = cppClient(root, [`--page-corruption=${corruption}`]); + await rejected( + `the client rejects ${corruption} pagination corruption`, + broken.refresh(), + message, + ); + await broken.close(); + } + } finally { + await client.close(); + } + const defaultArgs = new CppGraphClient({ + root, + languages: ["cpp"], + command: process.execPath, + producerCommit: COMMIT, + requestTimeoutMs: 10, + }); + await defaultArgs.close(); +} + +async function assertClientInputShapes(): Promise { + const root = GraphPaths.createTempDirectory("samchon-graph-cpp-inputs-"); + const build = path.join(root, "build"); + fs.mkdirSync(build); + fs.writeFileSync(path.join(root, "compile_commands.json"), "{}"); + for (const file of ["fallback.cpp", "direct.cpp", "absolute.cpp"]) { + fs.writeFileSync(path.join(root, file), "void caller() {}\n"); + } + fs.writeFileSync(path.join(build, "fallback.cpp"), "void caller() {}\n"); + const absolute = path.join(root, "absolute.cpp"); + fs.writeFileSync( + path.join(build, "compile_commands.json"), + JSON.stringify([ + {}, + { file: "" }, + { file: "fallback.cpp" }, + { directory: root, file: "direct.cpp" }, + { directory: "", file: absolute }, + ]), + ); + const watchLog = path.join(root, "input-watches.ndjson"); + const client = new CppGraphClient({ + root, + languages: ["c", "cpp"], + command: process.execPath, + args: [ + GraphPaths.fakeCppGraphServer, + `--commit=${COMMIT}`, + `--watch-log=${watchLog}`, + ], + producerCommit: COMMIT, + requestTimeoutMs: 5_000, + readyTimeoutMs: 10_000, + }); + try { + await client.refresh(); + fs.writeFileSync(path.join(root, ".clangd"), "Diagnostics: {}\n"); + await client.refresh(); + fs.unlinkSync(path.join(root, ".clangd")); + await client.refresh(); + TestValidator.equals( + "CDB input discovery accepts fallback directories and tracks null-to-file transitions", + readLines(watchLog) + .flatMap((row) => row.changes) + .filter((change) => String(change.uri).endsWith("/.clangd")) + .map((change) => change.type), + [1, 3], + ); + } finally { + await client.close(); + } +} + +async function assertClientFailures(root: string): Promise { + const retry = cppClient(root, ["--retry=1", "--content-modified=1"]); + TestValidator.equals( + "retryable Clang readiness and movement errors are polled to success", + (await retry.refresh()).changed, + true, + ); + await retry.close(); + + const movementRoot = fixtureRoot(); + const movementWatchLog = path.join(movementRoot, "movement-watches.ndjson"); + const movement = cppClient(movementRoot, [ + "--content-modified=1", + "--move-input-on-content-modified", + `--watch-log=${movementWatchLog}`, + ]); + await movement.refresh(); + await movement.close(); + TestValidator.equals( + "input movement discovered during a snapshot retry is notified", + readLines(movementWatchLog).map((row) => row.changes[0]?.type), + [1, 2], + ); + + const postSnapshotRoot = fixtureRoot(); + const postSnapshotWatchLog = path.join( + postSnapshotRoot, + "post-snapshot-watches.ndjson", + ); + let moveAfterSnapshot = true; + const postSnapshot = cppClient( + postSnapshotRoot, + [`--watch-log=${postSnapshotWatchLog}`], + { + validate: () => { + if (!moveAfterSnapshot) return; + moveAfterSnapshot = false; + fs.writeFileSync( + path.join(postSnapshotRoot, "main.cpp"), + "void movedAfterSnapshot() {}\n", + ); + }, + }, + ); + await postSnapshot.refresh(); + await postSnapshot.refresh(); + await postSnapshot.close(); + TestValidator.equals( + "input movement after a frozen snapshot remains visible to the next refresh", + readLines(postSnapshotWatchLog).map((row) => row.changes[0]?.type), + [1, 2], + ); + + const unknownDiskRoot = fixtureRoot(); + const unknownDiskWatchLog = path.join( + unknownDiskRoot, + "unknown-disk-watches.ndjson", + ); + const unknownDisk = cppClient(unknownDiskRoot, [ + "--edge-cases", + "--empty-disk-digest", + `--watch-log=${unknownDiskWatchLog}`, + ]); + await unknownDisk.refresh(); + await unknownDisk.refresh(); + await unknownDisk.close(); + TestValidator.equals( + "a source without a producer disk identity is conservatively re-notified", + readLines(unknownDiskWatchLog) + .flatMap((row) => row.changes) + .filter((change) => String(change.uri).endsWith("/main.cpp")) + .map((change) => change.type), + [1, 1], + ); + + const timedOut = cppClient(root, ["--content-modified=1"], { + readyTimeoutMs: 0, + }); + await rejected( + "retryable movement still obeys the readiness deadline", + timedOut.refresh(), + "did not become ready", + ); + await timedOut.close(); + + const configured = cppClient( + root, + [ + "--request-configuration", + "--request-empty-configuration", + "--request-unknown", + ], + { initializationOptions: { graph: true } }, + ); + await configured.refresh({ signal: new AbortController().signal }); + await configured.close(); + + const initializeError = cppClient(root, ["--initialize-error"]); + await rejected( + "initialization failures reject the resident session", + initializeError.refresh({ signal: new AbortController().signal }), + "fixture initialize failure", + ); + await initializeError.close(); + + const initializing = cppClient(root, ["--hang-initialize"]); + const initializationAbort = new AbortController(); + const initialization = initializing.refresh({ signal: initializationAbort.signal }); + initializationAbort.abort("initialize cancellation"); + await rejected( + "an initializing Clang session remains cancellable", + initialization, + "cancel", + ); + await initializing.close(); + + const malformed = cppClient(root, ["--malformed"]); + await rejected( + "a malformed Clang response fails closed", + malformed.refresh(), + "identity/commit mismatch", + ); + await malformed.close(); + + const internal = cppClient(root, ["--internal-error"]); + await rejected( + "a non-retryable Clang producer error is surfaced", + internal.refresh(), + "fixture internal failure", + ); + await internal.close(); + + const hanging = cppClient(root, ["--hang"]); + const abort = new AbortController(); + const refresh = hanging.refresh({ signal: abort.signal }); + setTimeout(() => abort.abort("fixture cancellation"), 20).unref?.(); + await rejected( + "an active Clang snapshot request remains cancellable", + refresh, + "abort|cancel", + ); + await hanging.close(); + + const delaying = cppClient(root, ["--retry=100"]); + await ( + delaying as unknown as { + initialize(signal: AbortSignal): Promise; + } + ).initialize(new AbortController().signal); + const delayAbort = new AbortController(); + const delayed = delaying.refresh({ signal: delayAbort.signal }); + setTimeout(() => delayAbort.abort("delay cancellation"), 20).unref?.(); + await rejected( + "retry delay remains cancellable", + delayed, + "cancel", + ); + await delaying.close(); + + const queued = cppClient(root, ["--hang"]); + const activeAbort = new AbortController(); + const active = queued.refresh({ signal: activeAbort.signal }); + const queuedAbort = new AbortController(); + const waiting = queued.refresh({ signal: queuedAbort.signal }); + queuedAbort.abort("queued cancellation"); + await rejected("a queued Clang refresh is cancellable", waiting, "cancel"); + activeAbort.abort("active cancellation"); + await rejected("the active refresh is also cancelled", active, "cancel"); + await queued.close(); + + const alreadyAborted = cppClient(root, []); + const aborted = new AbortController(); + aborted.abort("preflight cancellation"); + await rejected( + "an already-cancelled refresh never enters the queue", + alreadyAborted.refresh({ signal: aborted.signal }), + "cancel", + ); + await alreadyAborted.close(); + + const preinitializing = cppClient(root, []); + await ( + preinitializing as unknown as { + initialize(signal: AbortSignal): Promise; + } + ).initialize(new AbortController().signal); + const preinitializedAbort = new AbortController(); + preinitializedAbort.abort("preinitialized cancellation"); + await rejected( + "the initialization race rejects an already-aborted caller signal", + ( + preinitializing as unknown as { + initialize(signal: AbortSignal): Promise; + } + ).initialize(preinitializedAbort.signal), + "cancel", + ); + await preinitializing.close(); + + const directCancellation = cppClient(root, []); + await rejected( + "the snapshot loop checks cancellation before requesting a page", + ( + directCancellation as unknown as { + requestSnapshot(signal: AbortSignal): Promise; + } + ).requestSnapshot({ aborted: true, reason: undefined } as AbortSignal), + "cancel", + ); + await directCancellation.close(); + + const stringFailure = cppClient(root, [], { + validate: () => { + throw "fixture validation string"; + }, + }); + await rejected( + "non-Error validation failures are normalized", + stringFailure.refresh(), + "fixture validation string", + ); + await stringFailure.close(); + + const absent = GraphPaths.createTempDirectory("samchon-graph-cpp-empty-client-"); + const noDatabase = cppClient(absent, []); + await rejected( + "an empty compilation database fails closed", + noDatabase.refresh(), + "universe|generation", + ); + await noDatabase.close(); +} + +function cppClient( + root: string, + args: readonly string[], + options: { + initializationOptions?: unknown; + readyTimeoutMs?: number; + validate?: () => void; + } = {}, +): CppGraphClient { + return new CppGraphClient({ + root, + languages: ["c", "cpp"], + command: process.execPath, + args: [GraphPaths.fakeCppGraphServer, `--commit=${COMMIT}`, ...args], + producerCommit: COMMIT, + initializationOptions: options.initializationOptions, + requestTimeoutMs: 5_000, + readyTimeoutMs: options.readyTimeoutMs ?? 10_000, + validate: options.validate, + }); +} + +function nodeShim( + root: string, + name: string, + commit: string, +): string { + const directory = path.join(root, "shims"); + fs.mkdirSync(directory, { recursive: true }); + const file = path.join( + directory, + process.platform === "win32" ? `${name}.cmd` : name, + ); + const invocation = [ + `"${process.execPath}"`, + `"${GraphPaths.fakeCppGraphServer}"`, + `--commit=${commit}`, + ].join(" "); + fs.writeFileSync( + file, + process.platform === "win32" + ? `@echo off\r\n${invocation} %*\r\n` + : `#!/bin/sh\nexec ${invocation} "$@"\n`, + ); + if (process.platform !== "win32") fs.chmodSync(file, 0o755); + return file; +} + +function readLines(file: string): Array> { + return fs + .readFileSync(file, "utf8") + .trim() + .split(/\r?\n/u) + .filter((line) => line !== "") + .map((line) => JSON.parse(line) as Record); +} + +function nativeShardDigest(shard: ICppGraphSnapshot.IShard): string { + return sha256( + `${shard.key}\n${shard.checkerDigest}\n${shard.interfaceFingerprint}\n${JSON.stringify(shard.graph)}`, + ); +} + +function nativeGeneration( + universe: string, + manifest: readonly { key: string; digest: string }[], +): string { + return sha256( + universe + + manifest + .map( + (entry) => + `${Buffer.byteLength(entry.key, "utf8")}:${entry.key}${entry.digest}`, + ) + .join(""), + ); +} + +function sha256(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +async function rejected( + label: string, + promise: Promise, + message: string, +): Promise { + let error: Error | undefined; + try { + await promise; + } catch (caught) { + error = caught instanceof Error ? caught : new Error(String(caught)); + } + TestValidator.predicate( + label, + error !== undefined && + message.split("|").some((candidate) => error!.message.includes(candidate)), + ); +} diff --git a/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts b/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts index 59f5b589..b657b4e2 100644 --- a/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts +++ b/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts @@ -169,16 +169,24 @@ export const test_experiment_corpora_are_commit_pinned = () => { ); TestValidator.predicate( "every producer with no grounded edge family states that limitation explicitly", - [csharp, cpp, c].every( - (row) => - row.includes("semanticEdges: []") && - !row.includes("crossFileEdge:") && - declares(row, "semanticLimitation"), - ) && + csharp.includes("semanticEdges: []") && + !csharp.includes("crossFileEdge:") && + declares(csharp, "semanticLimitation") && runner.includes("experiment.semanticEdges.length === 0") && runner.includes("crossFileEdge !== undefined") && runner.includes("semanticLimitation.trim() ==="), ); + TestValidator.predicate( + "the native C and C++ producer grounds cross-file graph families", + [cpp, c].every( + (row) => + row.includes('strictProvider: "clangd-snapshot"') && + row.includes('crossFileEdge: "references"') && + row.includes('"contains"') && + row.includes('"references"') && + !row.includes("semanticEdges: []"), + ), + ); // scip-python 0.6.6 recovers from a malformed `pyproject.toml`, falls back to // Pyright defaults and emits no SCIP diagnostics. On the pinned Click // fixture, the source and semantic fact planes stay unchanged. The aggregate @@ -216,37 +224,21 @@ export const test_experiment_corpora_are_commit_pinned = () => { lifecycle.includes("normalized dump fact planes are equal"), ); TestValidator.predicate( - "a malformed compilation database proves strict decline and warned fallback", - [cpp, c].every( - (row) => - row.includes('failurePolicy: "fallback"') && - declares(row, "failureLimitation"), - ) && - lifecycle.includes('fixture.failurePolicy === "fallback"') && - lifecycle.includes('status: "fallback-with-limitation"') && - lifecycle.includes("row.provider === experiment.strictProvider") && - lifecycle.includes("warning.includes(experiment.strictProvider)") && - !lifecycle.includes("JSON.stringify(fallback)") && - lifecycle.includes('? ["initial", ...CHANGED_MODES]'), + "a malformed compilation database rejects the native generation", + [cpp, c].every((row) => row.includes('failurePolicy: "reject"')) && + lifecycle.includes('fixture.failurePolicy === "reject"') && + lifecycle.includes('status: "rejected"'), ); - // Regenerating an unchanged project must reproduce it, and that assertion is - // the lifecycle's strongest. Exactly one registered producer cannot meet it: - // scip-clang 0.4.0 documents `--deterministic` as not scheduling work - // deterministically, and warns separately that non-determinism changes how - // many files each indexing job skips — which moves the source manifest as - // well as the facts, because the manifest lists the files it reported. The - // exemption is therefore a declared, explained property of those two rows - // rather than a relaxed default, and it covers one claim rather than two. + // Native C/C++ shards and manifests are canonical independently of + // background scheduling, so these rows keep the strongest reproduction + // assertion and carry no producer-specific exemption. TestValidator.predicate( - "an unreproducible producer is declared rather than serialized", - [cpp, c].every((row) => declares(row, "regenerationLimitation")) && - // Counted over the whole catalog, not checked against a list of the rows - // that happen not to declare it. The exemption drops the lifecycle's - // strongest assertion for whichever row carries it, so a third one - // appearing has to be a reviewed edit here rather than eight words in a - // catalog nobody re-reads. - [...catalog.matchAll(/regenerationLimitation:/g)].length === 2 && + "native C and C++ regeneration stays reproducible", + [cpp, c].every((row) => !declares(row, "regenerationLimitation")) && + // Counted over the whole catalog so any future reproduction exemption + // requires a reviewed contract change here. + [...catalog.matchAll(/regenerationLimitation:/g)].length === 0 && runner.includes("experiment.regenerationLimitation !== undefined") && runner.includes("regenerationLimitation.trim() === \"\"") && runner.includes( diff --git a/tests/test-graph/src/features/test_provider_registry_selects_one_owner_per_language.ts b/tests/test-graph/src/features/test_provider_registry_selects_one_owner_per_language.ts index 5ad87abe..f4c2d572 100644 --- a/tests/test-graph/src/features/test_provider_registry_selects_one_owner_per_language.ts +++ b/tests/test-graph/src/features/test_provider_registry_selects_one_owner_per_language.ts @@ -151,7 +151,6 @@ async function assertSelection(): Promise { Object.fromEntries( GRAPH_PROVIDERS.filter((provider) => [ - "scip-clang", "scip-java", "scip-dotnet", "scip-python", @@ -162,7 +161,6 @@ async function assertSelection(): Promise { ).map((provider) => [provider.name, provider.facts]), ), { - "scip-clang": [], "scip-java": ["contains", "references"], "scip-dotnet": [], "scip-python": ["references"], diff --git a/tests/test-graph/src/features/test_provider_support_manifest_matches_registry_and_evidence.ts b/tests/test-graph/src/features/test_provider_support_manifest_matches_registry_and_evidence.ts index 7d9dc4bd..36bfe81e 100644 --- a/tests/test-graph/src/features/test_provider_support_manifest_matches_registry_and_evidence.ts +++ b/tests/test-graph/src/features/test_provider_support_manifest_matches_registry_and_evidence.ts @@ -144,10 +144,10 @@ export const test_provider_support_manifest_matches_registry_and_evidence = const incompleteProjectCommands = structuredClone(parsed); const clang = incompleteProjectCommands.providers.find( - (provider) => provider.provider === "scip-clang", + (provider) => provider.provider === "clangd-snapshot", ); if (clang === undefined) - throw new Error("the canonical manifest must contain scip-clang"); + throw new Error("the canonical manifest must contain clangd-snapshot"); clang.projectCommandSources = ["compile_commands.json"]; const incompleteProjectCommandsFile = path.join( root, @@ -161,16 +161,16 @@ export const test_provider_support_manifest_matches_registry_and_evidence = "an omitted project-owned command source fails closed", failsValidation( validate(incompleteProjectCommandsFile, root), - "scip-clang project command sources differ from its resolver descriptor", + "clangd-snapshot project command sources differ from its resolver descriptor", ), ); const duplicateProjectCommands = structuredClone(parsed); const duplicateClang = duplicateProjectCommands.providers.find( - (provider) => provider.provider === "scip-clang", + (provider) => provider.provider === "clangd-snapshot", ); if (duplicateClang === undefined) - throw new Error("the canonical manifest must contain scip-clang"); + throw new Error("the canonical manifest must contain clangd-snapshot"); duplicateClang.projectCommandSources = [ "compile_commands.json", "build/compile_commands.json", @@ -188,7 +188,7 @@ export const test_provider_support_manifest_matches_registry_and_evidence = "a duplicate project-owned command source fails closed", failsValidation( validate(duplicateProjectCommandsFile, root), - "scip-clang project command source rows must be unique", + "clangd-snapshot project command source rows must be unique", ), ); diff --git a/tests/test-graph/src/features/test_standard_providers_execute_their_exact_contracts.ts b/tests/test-graph/src/features/test_standard_providers_execute_their_exact_contracts.ts index f0b6866f..6eda92e0 100644 --- a/tests/test-graph/src/features/test_standard_providers_execute_their_exact_contracts.ts +++ b/tests/test-graph/src/features/test_standard_providers_execute_their_exact_contracts.ts @@ -6,6 +6,8 @@ import { type IBulkGraphSession, type IGraphProvider, RUST_GRAPH_PRODUCER_COMMIT, + CPP_CLANG_PRODUCER_COMMIT, + cppGraphProvider, goGraphProvider, luaGraphProvider, rustGraphProvider, @@ -926,7 +928,10 @@ function assertFixtureRegistryCoverage(): void { goGraphProvider, luaGraphProvider, rustGraphProvider, - ...standardScipProviders, + cppGraphProvider, + ...standardScipProviders.filter( + (provider) => provider.name !== "scip-clang", + ), ...standardSidecarProviders, ] .map((provider) => provider.name) @@ -1229,6 +1234,15 @@ async function assertRemainingRegisteredFixtures(root: string): Promise { await assertRegisteredFixture(goGraphProvider, goCommand, root); await assertHeuristicTwinFails(goGraphProvider, goCommand, root); + const cppCommand: IGraphProvider.ICommand = { + command: process.execPath, + args: [ + GraphPaths.fakeCppGraphServer, + `--commit=${CPP_CLANG_PRODUCER_COMMIT}`, + ], + }; + await assertRegisteredFixture(cppGraphProvider, cppCommand, root, "calls"); + // Lua's producer is the language server itself, driven through its `--doc` // export with our exporter injected, so the fixture stands in for the server // rather than for a binary of ours. `prepare` writes the config that carries diff --git a/tests/test-graph/src/internal/GraphPaths.ts b/tests/test-graph/src/internal/GraphPaths.ts index ed37a546..6b99a589 100644 --- a/tests/test-graph/src/internal/GraphPaths.ts +++ b/tests/test-graph/src/internal/GraphPaths.ts @@ -63,6 +63,7 @@ export const GraphPaths = { createTempDirectory, fakeCmake: path.join(repositoryRoot, "tests", "test-graph", "src", "internal", "fake-cmake.cjs"), fakeLspServer: path.join(repositoryRoot, "tests", "test-graph", "src", "internal", "fake-lsp-server.cjs"), + fakeCppGraphServer: path.join(repositoryRoot, "tests", "test-graph", "src", "internal", "fake-cpp-graph-server.cjs"), fakeRustGraphServer: path.join(repositoryRoot, "tests", "test-graph", "src", "internal", "fake-rust-graph-server.cjs"), fakeTtscGraphServer: path.join(repositoryRoot, "tests", "test-graph", "src", "internal", "fake-ttscgraph-server.cjs"), fakePub: path.join(repositoryRoot, "tests", "test-graph", "src", "internal", "fake-pub.cjs"), diff --git a/tests/test-graph/src/internal/fake-cpp-graph-server.cjs b/tests/test-graph/src/internal/fake-cpp-graph-server.cjs new file mode 100644 index 00000000..0fe4eea0 --- /dev/null +++ b/tests/test-graph/src/internal/fake-cpp-graph-server.cjs @@ -0,0 +1,625 @@ +#!/usr/bin/env node +"use strict"; + +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const path = require("node:path"); +const { pathToFileURL } = require("node:url"); + +const args = process.argv.slice(2); +const valueOf = (prefix) => + args.find((argument) => argument.startsWith(prefix))?.slice(prefix.length); +const commit = + valueOf("--commit=") ?? "1111111111111111111111111111111111111111"; +const requestLog = valueOf("--request-log="); +const watchLog = valueOf("--watch-log="); +let retry = Number(valueOf("--retry=") ?? 0); +let contentModified = Number(valueOf("--content-modified=") ?? 0); +const moveInputOnContentModified = args.includes( + "--move-input-on-content-modified", +); +const hang = args.includes("--hang"); +const internalError = args.includes("--internal-error"); +const malformed = args.includes("--malformed"); +const initializeError = args.includes("--initialize-error"); +const hangInitialize = args.includes("--hang-initialize"); +const requestConfiguration = args.includes("--request-configuration"); +const requestEmptyConfiguration = args.includes("--request-empty-configuration"); +const requestUnknown = args.includes("--request-unknown"); +const pageCorruption = valueOf("--page-corruption="); +const edgeCases = args.includes("--edge-cases"); +const invalidSourceUri = args.includes("--invalid-source-uri"); +const unsupportedSourceUri = args.includes("--unsupported-source-uri"); +const checkerOverlay = args.includes("--checker-overlay"); +const emptyDiskDigest = args.includes("--empty-disk-digest"); +const EDGE_KINDS = [ + "contains", "exports", "imports", "calls", "accesses", + "instantiates", "type_ref", "extends", "implements", "overrides", + "dispatches", "decorates", "renders", "tests", "references", +]; +const COVERAGE = { + contains: "complete", + exports: "partial", + imports: "complete", + calls: "partial", + accesses: "complete", + instantiates: "partial", + type_ref: "complete", + extends: "complete", + implements: "partial", + overrides: "complete", + dispatches: "partial", + decorates: "unsupported", + renders: "unsupported", + tests: "unsupported", + references: "complete", +}; +let sequence = 0; +let published; +let activePlan; + +if (args.includes("--version")) { + process.stdout.write(`clangd version 22.1.8 (${commit})\n`); + process.exit(0); +} +if (args.includes("--snapshot")) { + process.stdout.write(JSON.stringify(snapshot(null, undefined, 32))); + process.exit(0); +} + +let buffer = Buffer.alloc(0); + +process.stdin.on("data", (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + for (;;) { + const headerEnd = buffer.indexOf("\r\n\r\n"); + if (headerEnd < 0) return; + const header = buffer.slice(0, headerEnd).toString("ascii"); + const length = Number(/Content-Length:\s*(\d+)/i.exec(header)?.[1]); + const bodyStart = headerEnd + 4; + const bodyEnd = bodyStart + length; + if (!Number.isSafeInteger(length) || buffer.length < bodyEnd) return; + const message = JSON.parse(buffer.slice(bodyStart, bodyEnd).toString("utf8")); + buffer = buffer.slice(bodyEnd); + handle(message); + } +}); + +function handle(message) { + if (message.method === "initialize") { + if (hangInitialize) return; + if (initializeError) { + sendError(message.id, -32603, "fixture initialize failure"); + return; + } + send({ jsonrpc: "2.0", id: message.id, result: { capabilities: {} } }); + if (requestConfiguration) { + send({ + jsonrpc: "2.0", + id: "fixture-configuration", + method: "workspace/configuration", + params: { items: [{ section: "clangd" }, { section: "clangd.graph" }] }, + }); + } + if (requestEmptyConfiguration) { + send({ + jsonrpc: "2.0", + id: "fixture-empty-configuration", + method: "workspace/configuration", + params: {}, + }); + } + if (requestUnknown) { + send({ + jsonrpc: "2.0", + id: "fixture-unknown", + method: "workspace/unknown", + params: {}, + }); + } + return; + } + if (message.method === "workspace/didChangeWatchedFiles") { + if (watchLog !== undefined) { + fs.appendFileSync(watchLog, `${JSON.stringify(message.params)}\n`); + } + return; + } + if (message.method === "samchon/graphSnapshot") { + if (requestLog !== undefined) { + fs.appendFileSync(requestLog, `${JSON.stringify(message.params)}\n`); + } + if (hang) return; + if (internalError) { + sendError(message.id, -32603, "fixture internal failure"); + return; + } + if (retry > 0) { + retry -= 1; + sendError(message.id, -32802, "fixture graph is not ready"); + return; + } + if (contentModified > 0) { + contentModified -= 1; + if (moveInputOnContentModified) { + fs.writeFileSync( + path.join(process.cwd(), "main.cpp"), + "void moved_during_snapshot() {}\n", + ); + } + sendError(message.id, -32801, "fixture graph moved"); + return; + } + let result = snapshot( + message.params?.knownGeneration ?? null, + message.params?.cursor, + message.params?.maxShards ?? 32, + ); + if (malformed) result.producer.commit = "wrong"; + result = corruptPage(result, message.params?.cursor !== undefined); + send({ jsonrpc: "2.0", id: message.id, result }); + return; + } + if (message.method === "shutdown") { + send({ jsonrpc: "2.0", id: message.id, result: null }); + return; + } + if (message.method === "exit") process.exit(0); +} + +function corruptPage(result, continuation) { + if (pageCorruption === "generation") return null; + if (pageCorruption === "envelope") result.page.offset = -1; + if (pageCorruption === "telemetry") result.phases.validationMillis = -1; + if (pageCorruption === "cache") result.phases.cacheHit = "invalid"; + if (pageCorruption === "early" && !continuation) result.page.nextCursor = null; + if (pageCorruption === "cursor" && !continuation) { + result.page.total = result.page.count; + } + if (pageCorruption === "metadata" && continuation) { + result.manifest = [{ key: "repeated", digest: "0".repeat(64) }]; + } + if (pageCorruption === "cross-generation" && continuation) { + result.sequence += 1; + } + return result; +} + +function snapshot(knownGeneration, cursor, maxShards) { + if (cursor !== undefined) { + if (activePlan === undefined || !cursor.startsWith(`${activePlan.token}:`)) { + throw new Error("fixture stale graph cursor"); + } + return pageOf(activePlan, Number(cursor.slice(cursor.lastIndexOf(":") + 1)), maxShards); + } + const prior = published; + const shards = compilationCommands().map(graphShard).sort(compareKey); + const manifest = shards.map((shard) => ({ + digest: shard.digest, + key: shard.key, + })); + const targets = [...new Set(shards.map((shard) => shard.graph.targetTriple))] + .sort(); + const configurations = [ + ...new Set(shards.map((shard) => shard.configuration)), + ].sort(); + const producer = { + name: "samchon-clangd", + version: "clang version 22.1.8", + commit, + }; + const fingerprint = producerFingerprint(producer); + const workspaceRoots = [canonicalRoot(process.cwd())]; + const toolchains = [ + ...new Set(shards.map((shard) => shard.graph.toolchainFingerprint)), + ].sort(); + let universeMaterial = coordinate("producer", fingerprint); + for (const target of targets) universeMaterial += coordinate("target", target); + for (const root of workspaceRoots) universeMaterial += coordinate("root", root); + for (const toolchain of toolchains) + universeMaterial += coordinate("toolchain", toolchain); + for (const configuration of configurations) + universeMaterial += coordinate("configuration", configuration); + const universe = digest(universeMaterial); + const generationMaterial = manifest + .map((entry) => `${Buffer.byteLength(entry.key)}:${entry.key}${entry.digest}`) + .join(""); + const generation = digest(universe + generationMaterial); + const noChange = knownGeneration === generation; + const delta = !noChange && prior?.generation === knownGeneration; + const previous = new Map( + (prior?.manifest ?? []).map((entry) => [entry.key, entry.digest]), + ); + const current = new Map(manifest.map((entry) => [entry.key, entry.digest])); + const upserts = noChange + ? [] + : delta + ? shards.filter((shard) => previous.get(shard.key) !== shard.digest) + : shards; + const deletes = delta + ? [...previous.keys()].filter((key) => !current.has(key)).sort() + : []; + sequence += 1; + activePlan = { + token: digest(`${generation}:${sequence}:${knownGeneration ?? "full"}`), + protocolVersion: 1, + schemaVersion: 1, + producer, + universe: { + digest: universe, + targets, + workspaceRoots, + toolchains, + configurations, + }, + sequence, + generation, + baseGeneration: noChange || delta ? knownGeneration : null, + upserts, + deletes, + manifest, + cacheHit: noChange, + }; + published = { generation, manifest }; + return pageOf(activePlan, 0, maxShards); +} + +function pageOf(plan, offset, maxShards) { + const pageSize = Math.max(1, Math.min(128, Number(maxShards) || 32)); + const end = Math.min(plan.upserts.length, offset + pageSize); + const semanticMillis = offset === 0 && !plan.cacheHit ? 1 : 0; + const shardMillis = offset === 0 && !plan.cacheHit ? 1 : 0; + const validationMillis = 1; + const encodeMillis = 1; + return { + protocolVersion: plan.protocolVersion, + schemaVersion: plan.schemaVersion, + producer: plan.producer, + universe: plan.universe, + sequence: plan.sequence, + generation: plan.generation, + baseGeneration: plan.baseGeneration, + upserts: plan.upserts.slice(offset, end), + deletes: offset === 0 ? plan.deletes : [], + manifest: offset === 0 && !plan.cacheHit ? plan.manifest : [], + page: { + offset, + count: end - offset, + total: plan.upserts.length, + nextCursor: + end < plan.upserts.length ? `${plan.token}:${end}` : null, + }, + phases: { + validationMillis, + semanticMillis, + shardMillis, + encodeMillis, + totalMillis: + validationMillis + semanticMillis + shardMillis + encodeMillis, + cacheHit: plan.cacheHit, + }, + }; +} + +function compilationCommands() { + for (const candidate of [ + path.join(process.cwd(), "compile_commands.json"), + path.join(process.cwd(), "build", "compile_commands.json"), + ]) { + try { + const rows = JSON.parse(fs.readFileSync(candidate, "utf8")); + if (Array.isArray(rows)) { + return rows.filter( + (row) => typeof row?.file === "string" && row.file !== "", + ); + } + } catch {} + } + return []; +} + +function graphShard(command) { + const directory = path.resolve(command.directory || process.cwd()); + const mainFile = path.resolve(directory, command.file); + const mainFileUri = pathToFileURL(mainFile).href; + const commandLine = Array.isArray(command.arguments) + ? command.arguments + : String(command.command || "clang++ -c fixture.cpp").split(/\s+/u); + const commandDigest = digest( + `${directory.length}:${directory}${mainFile.length}:${mainFile}${commandLine + .map((argument) => `${argument.length}:${argument}`) + .join("")}`, + ); + const language = commandLine.some((argument) => argument === "c") || + path.extname(mainFile).toLowerCase() === ".c" + ? "c" + : "cpp"; + const targetTriple = "x86_64-pc-windows-msvc"; + const diskText = fs.readFileSync(mainFile); + const sourceText = checkerOverlay + ? Buffer.concat([Buffer.from("// checker overlay\n"), diskText]) + : diskText; + const sourceDigest = digest(sourceText); + const diskDigest = emptyDiskDigest ? "" : digest(diskText); + const text = sourceText.toString("utf8"); + const callerRange = wordRanges(text, mainFileUri, "caller")[0] ?? + range(mainFileUri, 0, 0, 0, 0); + const calleeRanges = wordRanges(text, mainFileUri, "callee"); + const calleeReference = calleeRanges.at(-2) ?? callerRange; + const calleeDefinition = calleeRanges.at(-1) ?? callerRange; + const sourceRange = callerRange; + const callerName = sourceText.includes("edited") ? "editedCaller" : "caller"; + const caller = symbol("c:@F@caller#", callerName, 13, callerRange, true); + const callee = symbol("c:@F@callee#", "callee", 13, calleeDefinition, true); + const base = symbol("c:@S@Base", "Base", 7, sourceRange, true); + const derived = symbol("c:@S@Derived", "Derived", 7, sourceRange, true); + const constructor = symbol("c:@S@Derived@F@Derived#", "Derived", 23, sourceRange, true); + const field = symbol("c:@S@Derived@FI@value", "value", 15, sourceRange, false); + const symbols = [caller, callee, base, derived, constructor, field]; + const sources = [{ + uri: mainFileUri, + digest: sourceDigest, + diskDigest, + flags: 1, + }]; + const header = path.join(directory, "include", "fixture.h"); + let includes = []; + try { + const headerUri = pathToFileURL(header).href; + const headerDigest = digest(fs.readFileSync(header)); + sources.push({ + uri: headerUri, + digest: headerDigest, + diskDigest: headerDigest, + flags: 0, + }); + includes = [{ + source: mainFileUri, + target: headerUri, + spelling: "fixture.h", + angled: false, + moduleImported: false, + evidence: sourceRange, + }]; + } catch {} + const graph = { + producerFingerprint: producerFingerprint({ + version: "clang version 22.1.8", + commit, + }), + mainFileUri, + mainFile, + directory, + commandLine, + output: typeof command.output === "string" ? command.output : "", + commandDigest, + toolchainFingerprint: digest("fixture-toolchain"), + targetTriple, + language, + hadErrors: false, + sources, + symbols, + occurrences: [ + occurrence(callee.id, caller.id, (1 << 2) | (1 << 5), 13, calleeReference), + occurrence(constructor.id, caller.id, (1 << 2) | (1 << 5), 23, sourceRange), + occurrence(base.id, derived.id, 1 << 2, 7, sourceRange), + occurrence(field.id, caller.id, (1 << 2) | (1 << 3), 15, sourceRange), + occurrence(callee.id, caller.id, (1 << 2) | (1 << 5) | (1 << 6), 13, sourceRange), + ], + relations: [ + relation(base.id, derived.id, 1 << 11, sourceRange), + relation(derived.id, base.id, 1 << 12, sourceRange), + relation(callee.id, caller.id, 1 << 14, sourceRange), + relation(field.id, derived.id, 1 << 10, sourceRange), + relation(field.id, caller.id, 1 << 16, sourceRange), + relation(derived.id, base.id, 1 << 19, sourceRange), + ], + macros: [ + { + usr: "c:@macro@FIXTURE", + id: "c:@macro@FIXTURE|ordinal=0", + name: "FIXTURE", + roles: 1 << 1, + definition: sourceRange, + spelling: sourceRange, + expansion: sourceRange, + }, + { + usr: "c:@macro@FIXTURE", + id: "c:@macro@FIXTURE|ordinal=0", + name: "FIXTURE", + roles: 1 << 2, + definition: sourceRange, + spelling: sourceRange, + expansion: sourceRange, + }, + ], + includes, + missingIncludes: [], + modules: [{ name: "fixture.module", roles: 1 << 2, evidence: sourceRange }], + diagnostics: [{ + message: "fixture warning", + code: "clang:1", + severity: "warning", + range: sourceRange, + }], + }; + if (edgeCases) applyEdgeCases(graph, sourceRange, caller, callee, base, derived, constructor); + if (invalidSourceUri) { + graph.sources.push({ + uri: "file:%", + digest: digest("invalid-uri"), + diskDigest: "", + flags: 0, + }); + } + if (unsupportedSourceUri) { + graph.sources.push({ + uri: "repo:///fixture/unknown.hpp", + digest: digest("unsupported-source-uri"), + diskDigest: "", + flags: 0, + }); + } + const key = `${mainFileUri}#${commandDigest}`; + const interfaceFingerprint = digest( + symbols + .filter((entry) => entry.exported) + .map((entry) => `${entry.id.length}:${entry.id}${entry.signature.length}:${entry.signature}`) + .join(""), + ); + const shard = { + key, + source: mainFile, + configuration: commandDigest, + checkerDigest: sourceDigest, + interfaceFingerprint, + digest: "", + graph, + coverage: EDGE_KINDS.map((family) => ({ + family, + state: COVERAGE[family], + })), + }; + shard.digest = digest( + `${key}\n${sourceDigest}\n${interfaceFingerprint}\n${JSON.stringify(graph)}`, + ); + return shard; +} + +function applyEdgeCases(graph, location, caller, callee, base, derived, constructor) { + const empty = range("", 0, 0, 0, 0); + caller.qualifiedName = "fixture::caller"; + callee.qualifiedName = ""; + base.ownerUsr = caller.id; + derived.definition = empty; + constructor.kind = 999; + constructor.declaration = empty; + constructor.definition = empty; + constructor.attributes = []; + graph.sources.push( + { + uri: "bundled:///fixture/system.h", + digest: digest("bundled"), + diskDigest: "", + flags: 0, + }, + { + uri: "relative.cpp", + digest: digest("relative"), + diskDigest: "", + flags: 0, + }, + { + uri: path.join(graph.directory, "absolute.cpp"), + digest: digest("absolute"), + diskDigest: digest("absolute"), + flags: 0, + }, + ); + graph.occurrences[0].containerId = ""; + graph.occurrences[0].expansion = empty; + graph.occurrences.push(occurrence(callee.id, "", 1 << 2, 13, empty)); + graph.relations.push( + relation(base.id, derived.id, 1 << 15, location), + relation(caller.id, caller.id, 1 << 12, location), + relation(caller.id, callee.id, 1 << 12, empty), + relation("c:@F@external#", caller.id, 1 << 12, location), + ); + graph.macros[0].definition = empty; + graph.macros[0].spelling = empty; + graph.macros[0].expansion = empty; + graph.macros[1].spelling = empty; + graph.macros[1].expansion = empty; + graph.modules[0].evidence = empty; + graph.diagnostics[0].range = empty; +} + +function symbol(id, name, kind, location, exported) { + return { + usr: id, + id, + name, + qualifiedName: name, + ownerUsr: "", + signature: kind === 13 ? "void ()" : "", + kind, + subKind: 0, + properties: 0, + local: false, + internal: !exported, + anonymous: false, + exported, + declaration: location, + definition: location, + attributes: [{ name: "nodiscard", range: location }], + }; +} + +function occurrence(id, containerId, roles, targetKind, location) { + return { + usr: id, + id, + containerId, + roles, + targetKind, + spelling: location, + expansion: location, + }; +} + +function relation(subjectId, objectId, roles, evidence) { + return { subjectId, objectId, roles, evidence }; +} + +function range(file, startLine, startColumn, endLine, endColumn) { + return { file, startLine, startColumn, endLine, endColumn }; +} + +function wordRanges(text, file, word) { + const output = []; + let offset = 0; + for (;;) { + const found = text.indexOf(word, offset); + if (found < 0) return output; + const prefix = text.slice(0, found); + const line = prefix.split("\n").length - 1; + const lastNewline = prefix.lastIndexOf("\n"); + const column = found - lastNewline - 1; + output.push(range(file, line, column, line, column + word.length)); + offset = found + word.length; + } +} + +function digest(value) { + return crypto.createHash("sha256").update(value).digest("hex"); +} + +function coordinate(label, value) { + return `${label}:${Buffer.byteLength(value)}:${value}`; +} + +function producerFingerprint(producer) { + return digest( + `samchon-graph-schema:1\nversion:${producer.version}\nrepository:${producer.commit}`, + ); +} + +function canonicalRoot(root) { + const slash = path.resolve(root).replace(/\\/gu, "/"); + return process.platform === "win32" ? slash.toLowerCase() : slash; +} + +function compareKey(left, right) { + return left.key < right.key ? -1 : left.key > right.key ? 1 : 0; +} + +function send(message) { + const body = Buffer.from(JSON.stringify(message)); + process.stdout.write(`Content-Length: ${body.length}\r\n\r\n`); + process.stdout.write(body); +} + +function sendError(id, code, message) { + send({ jsonrpc: "2.0", id, error: { code, message } }); +} From 1f55005356bba8e176bf617ddd2ddd4ed0a2d057 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Mon, 3 Aug 2026 17:56:39 +0900 Subject: [PATCH 29/52] fix(graph): close native C and C++ trust boundaries Close #73: [Bulk index][C/C++] Share one Clang compilation-universe provider --- README.md | 2 +- docs/provider-support.json | 2 +- packages/graph/src/indexer/LANGUAGE_SPECS.ts | 1 + .../src/indexer/createResidentGraphSource.ts | 16 +- packages/graph/src/indexer/index.ts | 1 + packages/graph/src/indexer/languageOf.ts | 20 +- packages/graph/src/indexer/languages.ts | 1 + packages/graph/src/indexer/languagesOf.ts | 30 +++ .../graph/src/indexer/selectGraphSources.ts | 13 +- .../graph/src/indexer/staticGraphParts.ts | 35 ++-- .../provider/cpp/CPP_CLANG_PRODUCER_COMMIT.ts | 2 +- .../graph/src/provider/cpp/CppGraphClient.ts | 5 +- .../provider/cpp/CppGraphSnapshotAdapter.ts | 120 +++++++++--- tests/experiment/src/catalog.mjs | 14 +- tests/experiment/src/setup-language.mjs | 100 +++++++++- ..._snapshot_adapter_and_client_are_atomic.ts | 177 +++++++++++++++++- ...atic_preserves_out_of_line_method_flows.ts | 6 +- ...e_compile_commands_wires_into_lsp_build.ts | 11 ++ ...st_experiment_corpora_are_commit_pinned.ts | 36 +++- ...guage_registry_lists_advertised_targets.ts | 16 +- .../src/internal/fake-cpp-graph-server.cjs | 8 +- 21 files changed, 519 insertions(+), 97 deletions(-) create mode 100644 packages/graph/src/indexer/languagesOf.ts diff --git a/README.md b/README.md index 529feb0d..f9bc5c7c 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ The troubleshooting table names the ordinary language-server/static fallback for | `samchon-graph-go` | Go 1.25+; the package ships the Go exporter source. Install corroboration with `go install github.com/scip-code/scip-go/cmd/scip-go@v0.2.7`. | [Go downloads](https://go.dev/dl/), [scip-go 0.2.7 source](https://github.com/scip-code/scip-go/tree/v0.2.7) | `samchon-graph-go`, `go`, `scip-go` | — | `SAMCHON_GRAPH_GO`, `SAMCHON_GRAPH_GO_TOOLCHAIN`, `SAMCHON_GRAPH_SCIP_GO` | Project/PATH `samchon-graph-go`, then the shipped source runner through Go; absolute environment overrides take precedence. | Go workspace/module inputs, selected GOOS/GOARCH/cgo environment, embedded files and vendored inputs. | `linux`, `macos`, `windows` | | `samchon-graph-lua` | Install `lua-language-server`; the package ships `sidecars/lua/export.lua`. | [LuaLS releases](https://github.com/LuaLS/lua-language-server/releases) | `lua-language-server` | — | `SAMCHON_GRAPH_LUA`, `SAMCHON_GRAPH_LUA_EXPORTER` | Absolute `SAMCHON_GRAPH_LUA`, then project/PATH LuaLS; the shipped exporter may be replaced by `SAMCHON_GRAPH_LUA_EXPORTER`. | LuaLS workspace configuration and the shipped readable exporter. | `linux`, `macos`, `windows` | | `samchon-rust-analyzer-hir` | Build the `samchon/rust-analyzer` graph-snapshot fork at commit `2850ecba80311bebd4cdaa9fedc5321533b5b1e7`; point `SAMCHON_GRAPH_RUST_ANALYZER_HIR` at that binary or install it as `samchon-rust-analyzer`. | [native HIR graph producer PR](https://github.com/samchon/rust-analyzer/pull/1), [rust-analyzer build instructions](https://rust-analyzer.github.io/book/contributing.html) | `samchon-rust-analyzer`, `rust-analyzer` | — | `SAMCHON_GRAPH_RUST_ANALYZER_HIR` | Absolute `SAMCHON_GRAPH_RUST_ANALYZER_HIR`, then project/PATH `samchon-rust-analyzer`, then a project/PATH `rust-analyzer` only when its version reports the pinned producer commit. | Cargo metadata/config, lock/toolchain inputs, target/features/cfg and build-script/proc-macro universe. | `linux`, `macos`, `windows` | -| `clangd-snapshot` | Build the `samchon/llvm-project` graph-snapshot fork at commit `8ca950e1bd50a895145e6a5447f5d3253eabcc8c`; point `SAMCHON_GRAPH_CLANGD_SNAPSHOT` at `clangd` or install it as `samchon-clangd`, and provide a compilation database. | [native Clang graph producer PR](https://github.com/samchon/llvm-project/pull/1), [LLVM build instructions](https://llvm.org/docs/CMake.html) | `samchon-clangd`, `clangd` | `compile_commands.json`, `build/compile_commands.json` | `SAMCHON_GRAPH_CLANGD_SNAPSHOT` | Absolute `SAMCHON_GRAPH_CLANGD_SNAPSHOT`, then project/PATH `samchon-clangd`, then a project/PATH `clangd` only when its version reports the pinned producer commit. | A valid compilation database plus every source, header, generated input, command, target and working-directory identity used by its translation units. | `linux`, `macos`, `windows` | +| `clangd-snapshot` | Build the `samchon/llvm-project` graph-snapshot fork at commit `dcc73b6579ebb8b71f6080302a9444f237b7abb8`; point `SAMCHON_GRAPH_CLANGD_SNAPSHOT` at `clangd` or install it as `samchon-clangd`, and provide a compilation database. | [native Clang graph producer PR](https://github.com/samchon/llvm-project/pull/1), [LLVM build instructions](https://llvm.org/docs/CMake.html) | `samchon-clangd`, `clangd` | `compile_commands.json`, `build/compile_commands.json` | `SAMCHON_GRAPH_CLANGD_SNAPSHOT` | Absolute `SAMCHON_GRAPH_CLANGD_SNAPSHOT`, then project/PATH `samchon-clangd`, then a project/PATH `clangd` only when its version reports the pinned producer commit. | A valid compilation database plus every source, header, generated input, command, target and working-directory identity used by its translation units. | `linux`, `macos`, `windows` | | `scip-java` | Install `scip-java` 0.13.1, the `scip` decoder and a compatible JDK; Kotlin experiments pin a compatible source build. | [scip-java 0.13.1 release](https://github.com/scip-code/scip-java/releases/tag/v0.13.1), [SCIP releases](https://github.com/sourcegraph/scip/releases) | `scip-java`, `scip`, `java` | — | `SAMCHON_GRAPH_SCIP_JAVA`, `SAMCHON_GRAPH_SCIP`, `SAMCHON_GRAPH_JAVA_TOOLCHAIN` | Project-local producer/decoder/JDK precede PATH; absolute environment overrides select each tool. | Maven or Gradle project metadata, dependency/classpath state and Java/Kotlin compiler inputs. | `linux`, `macos`, `windows` | | `scip-dotnet` | `dotnet tool install --global scip-dotnet`; install the `scip` decoder and matching .NET SDK. | [scip-dotnet on NuGet](https://www.nuget.org/packages/scip-dotnet), [SCIP releases](https://github.com/sourcegraph/scip/releases) | `scip-dotnet`, `scip`, `dotnet` | — | `SAMCHON_GRAPH_SCIP_DOTNET`, `SAMCHON_GRAPH_SCIP`, `SAMCHON_GRAPH_DOTNET_TOOLCHAIN` | Project-local producer/decoder/toolchain precede PATH; absolute environment overrides select each tool. | Solution/project/TFM/NuGet/MSBuild inputs and a resolvable SDK. | `linux`, `macos`, `windows` | | `scip-python` | `npm install -g @sourcegraph/scip-python@0.6.6`; install the `scip` decoder and select Python. | [scip-python 0.6.6 on npm](https://www.npmjs.com/package/@sourcegraph/scip-python/v/0.6.6), [SCIP releases](https://github.com/sourcegraph/scip/releases) | `scip-python`, `scip`, `python3`, `python`, `py` | — | `SAMCHON_GRAPH_SCIP_PYTHON`, `SAMCHON_GRAPH_SCIP`, `SAMCHON_GRAPH_PYTHON_TOOLCHAIN` | Project-local producer/decoder/interpreter precede PATH; Python aliases are tried in order and absolute overrides select each tool. | Python project/config/environment/import/stub inputs. | `linux`, `macos`, `windows` | diff --git a/docs/provider-support.json b/docs/provider-support.json index 57ae952f..22cef058 100644 --- a/docs/provider-support.json +++ b/docs/provider-support.json @@ -133,7 +133,7 @@ "commands": ["samchon-clangd", "clangd"], "projectCommandSources": ["compile_commands.json", "build/compile_commands.json"], "environmentOverrides": ["SAMCHON_GRAPH_CLANGD_SNAPSHOT"], - "install": "Build the `samchon/llvm-project` graph-snapshot fork at commit `8ca950e1bd50a895145e6a5447f5d3253eabcc8c`; point `SAMCHON_GRAPH_CLANGD_SNAPSHOT` at `clangd` or install it as `samchon-clangd`, and provide a compilation database.", + "install": "Build the `samchon/llvm-project` graph-snapshot fork at commit `dcc73b6579ebb8b71f6080302a9444f237b7abb8`; point `SAMCHON_GRAPH_CLANGD_SNAPSHOT` at `clangd` or install it as `samchon-clangd`, and provide a compilation database.", "installSources": [ {"label": "native Clang graph producer PR", "url": "https://github.com/samchon/llvm-project/pull/1"}, {"label": "LLVM build instructions", "url": "https://llvm.org/docs/CMake.html"} diff --git a/packages/graph/src/indexer/LANGUAGE_SPECS.ts b/packages/graph/src/indexer/LANGUAGE_SPECS.ts index 61fc77c7..a1db7fc6 100644 --- a/packages/graph/src/indexer/LANGUAGE_SPECS.ts +++ b/packages/graph/src/indexer/LANGUAGE_SPECS.ts @@ -32,6 +32,7 @@ export const LANGUAGE_SPECS: ILanguageSpec[] = [ ".hxx", ".h++", ".H", + ".h", ".ipp", ".tpp", ".tcc", diff --git a/packages/graph/src/indexer/createResidentGraphSource.ts b/packages/graph/src/indexer/createResidentGraphSource.ts index a2448eb6..5c8553f9 100644 --- a/packages/graph/src/indexer/createResidentGraphSource.ts +++ b/packages/graph/src/indexer/createResidentGraphSource.ts @@ -31,6 +31,7 @@ import { IBuildGraphOptions } from "./IBuildGraphOptions"; import { ILspSession } from "./ILspSession"; import { IResidentGraphSource } from "./IResidentGraphSource"; import { languageOf } from "./languageOf"; +import { languagesOf as sourceLanguagesOf } from "./languagesOf"; import { mergeProviderSourceDigests } from "./mergeProviderSourceDigests"; import { movedConsumedSource } from "./movedConsumedSource"; import { movedProviderSource } from "./movedProviderSource"; @@ -974,10 +975,19 @@ function snapshotSources( options: IBuildGraphOptions, excludedLanguages: ReadonlySet = new Set(), ): Map { - const files = selectGraphSources(root, options).files; + const selected = selectGraphSources(root, options); + const activeLanguages = new Set(selected.languages); const snapshot = new Map(); - for (const abs of files) { - if (excludedLanguages.has(languageOf(abs))) continue; + for (const abs of selected.files) { + const owners = sourceLanguagesOf(abs).filter((language) => + activeLanguages.has(language), + ); + if ( + owners.length > 0 && + owners.every((language) => excludedLanguages.has(language)) + ) { + continue; + } const text = readText(abs); // A file removed between the walk and the read is simply absent from the // snapshot, which itself is a difference the next comparison will catch. diff --git a/packages/graph/src/indexer/index.ts b/packages/graph/src/indexer/index.ts index 52bb3a94..8aeb21c4 100644 --- a/packages/graph/src/indexer/index.ts +++ b/packages/graph/src/indexer/index.ts @@ -27,6 +27,7 @@ export * from "./IStaticGraphParts"; export * from "./LANGUAGE_SPECS"; export * from "./languageIdOf"; export * from "./languageOf"; +export * from "./languagesOf"; export * from "./markClosures"; export * from "./markIgnored"; export * from "./normalizeRequestedLanguages"; diff --git a/packages/graph/src/indexer/languageOf.ts b/packages/graph/src/indexer/languageOf.ts index afe0fbd0..681e45bf 100644 --- a/packages/graph/src/indexer/languageOf.ts +++ b/packages/graph/src/indexer/languageOf.ts @@ -1,17 +1,11 @@ -import path from "node:path"; import { GraphLanguage } from "../typings"; -import { LANGUAGE_SPECS } from "./LANGUAGE_SPECS"; +import { languagesOf } from "./languagesOf"; export function languageOf(file: string): GraphLanguage { - const exact = path.extname(file); - for (const spec of LANGUAGE_SPECS) { - if (spec.extensions.includes(exact)) return spec.language; - } - const folded = exact.toLowerCase(); - if (folded !== exact) { - for (const spec of LANGUAGE_SPECS) { - if (spec.extensions.includes(folded)) return spec.language; - } - } - return "unknown"; + const candidates = languagesOf(file); + // Compatibility surfaces with one language retain C as the default for a + // shared .h. Indexing uses languagesOf() and therefore never partitions the + // header away from C++ before semantic ownership can be resolved. + if (candidates.includes("c")) return "c"; + return candidates[0] ?? "unknown"; } diff --git a/packages/graph/src/indexer/languages.ts b/packages/graph/src/indexer/languages.ts index dd9bb771..aaba3a28 100644 --- a/packages/graph/src/indexer/languages.ts +++ b/packages/graph/src/indexer/languages.ts @@ -2,4 +2,5 @@ export * from "./allExtensions"; export * from "./ILanguageSpec"; export * from "./LANGUAGE_SPECS"; export * from "./languageOf"; +export * from "./languagesOf"; export * from "./specOf"; diff --git a/packages/graph/src/indexer/languagesOf.ts b/packages/graph/src/indexer/languagesOf.ts new file mode 100644 index 00000000..9634b7f8 --- /dev/null +++ b/packages/graph/src/indexer/languagesOf.ts @@ -0,0 +1,30 @@ +import path from "node:path"; + +import { GraphLanguage } from "../typings"; +import { LANGUAGE_SPECS } from "./LANGUAGE_SPECS"; + +/** Every exact or case-folded language owner registered for one source. */ +export function languagesOf( + file: string, +): Exclude[] { + const exact = path.extname(file); + const exactMatches: Exclude[] = []; + for (const spec of LANGUAGE_SPECS) { + if (spec.language !== "unknown" && spec.extensions.includes(exact)) { + exactMatches.push(spec.language); + } + } + if (exactMatches.length > 0) return exactMatches; + const folded = exact.toLowerCase(); + if (folded !== exact) { + for (const spec of LANGUAGE_SPECS) { + if ( + spec.language !== "unknown" && + spec.extensions.includes(folded) + ) { + exactMatches.push(spec.language); + } + } + } + return exactMatches; +} diff --git a/packages/graph/src/indexer/selectGraphSources.ts b/packages/graph/src/indexer/selectGraphSources.ts index ada27b06..df91b755 100644 --- a/packages/graph/src/indexer/selectGraphSources.ts +++ b/packages/graph/src/indexer/selectGraphSources.ts @@ -3,7 +3,7 @@ import { walkSourceFiles } from "../utils/fs"; import { allExtensions } from "./allExtensions"; import { IBuildGraphOptions } from "./IBuildGraphOptions"; import { IGraphSourceSelection } from "./IGraphSourceSelection"; -import { languageOf } from "./languageOf"; +import { languagesOf } from "./languagesOf"; import { normalizeRequestedLanguages } from "./normalizeRequestedLanguages"; /** @@ -23,11 +23,14 @@ export function selectGraphSources( maxFiles: options.maxFiles, }); const byLanguage = new Map(); + const allowed = requested === undefined ? undefined : new Set(requested); for (const file of files) { - const language = languageOf(file); - const partition = byLanguage.get(language); - if (partition === undefined) byLanguage.set(language, [file]); - else partition.push(file); + for (const language of languagesOf(file)) { + if (allowed !== undefined && !allowed.has(language)) continue; + const partition = byLanguage.get(language); + if (partition === undefined) byLanguage.set(language, [file]); + else partition.push(file); + } } const presentLanguages = [...byLanguage.keys()]; return { diff --git a/packages/graph/src/indexer/staticGraphParts.ts b/packages/graph/src/indexer/staticGraphParts.ts index cb5d80bb..923d6869 100644 --- a/packages/graph/src/indexer/staticGraphParts.ts +++ b/packages/graph/src/indexer/staticGraphParts.ts @@ -12,7 +12,8 @@ import { GraphLanguage } from "../typings"; import { projectRelative, readText } from "../utils/fs"; import { IBuildGraphOptions } from "./IBuildGraphOptions"; import { IStaticGraphParts } from "./IStaticGraphParts"; -import { languageOf } from "./languages"; +import { languagesOf } from "./languages"; +import { normalizeRequestedLanguages } from "./normalizeRequestedLanguages"; import { selectGraphSources } from "./selectGraphSources"; /** @@ -25,27 +26,27 @@ export function staticGraphParts( ): IStaticGraphParts { const root = path.resolve(options.cwd ?? process.cwd()); const discovered = selectedFiles ?? selectGraphSources(root, options).files; + const requested = normalizeRequestedLanguages(options.languages); + const allowed = requested === undefined ? undefined : new Set(requested); const files: IGraphSitterFile[] = []; for (const absolutePath of discovered) { - const language = languageOf(absolutePath); - // `discovered` comes from `walkSourceFiles(allExtensions(...))`, so every - // path's extension maps — through the same `LANGUAGE_SPECS` registry that - // `allExtensions` and `languageOf` share — to a real (non-`unknown`) - // language, and `GraphSitterLanguage` covers every non-`unknown` - // `GraphLanguage` (the LanguageContractParity assertion below). This - // narrowing guard therefore never continues at runtime; it only satisfies - // the compiler that `language` is a `GraphSitterLanguage`. - /* c8 ignore next */ - if (!isGraphSitterLanguage(language)) continue; + // Source selection and extraction share the same multi-owner registry. A + // plain .h is therefore parsed for every selected C/C++ ownership view, + // while exact .H remains C++ as declared by the registry. const source = readText(absolutePath); /* c8 ignore next */ if (source === undefined) continue; - files.push({ - absolutePath, - relativePath: projectRelative(root, absolutePath), - language, - source, - }); + for (const language of languagesOf(absolutePath)) { + if (allowed !== undefined && !allowed.has(language)) continue; + /* c8 ignore next */ + if (!isGraphSitterLanguage(language)) continue; + files.push({ + absolutePath, + relativePath: projectRelative(root, absolutePath), + language, + source, + }); + } } const parts = graphSitterParts({ root, files }); return parts; diff --git a/packages/graph/src/provider/cpp/CPP_CLANG_PRODUCER_COMMIT.ts b/packages/graph/src/provider/cpp/CPP_CLANG_PRODUCER_COMMIT.ts index 5b6e1268..c5352567 100644 --- a/packages/graph/src/provider/cpp/CPP_CLANG_PRODUCER_COMMIT.ts +++ b/packages/graph/src/provider/cpp/CPP_CLANG_PRODUCER_COMMIT.ts @@ -1,3 +1,3 @@ /** Exact samchon/llvm-project producer revision required by this adapter. */ export const CPP_CLANG_PRODUCER_COMMIT = - "8ca950e1bd50a895145e6a5447f5d3253eabcc8c"; + "dcc73b6579ebb8b71f6080302a9444f237b7abb8"; diff --git a/packages/graph/src/provider/cpp/CppGraphClient.ts b/packages/graph/src/provider/cpp/CppGraphClient.ts index 7b9df08b..9e07646f 100644 --- a/packages/graph/src/provider/cpp/CppGraphClient.ts +++ b/packages/graph/src/provider/cpp/CppGraphClient.ts @@ -47,6 +47,7 @@ export class CppGraphClient implements IBulkGraphSession { this.adapter = new CppGraphSnapshotAdapter( options.root, options.producerCommit, + options.languages, ); this.validate = options.validate ?? (() => undefined); this.initializationOptions = options.initializationOptions; @@ -167,13 +168,15 @@ export class CppGraphClient implements IBulkGraphSession { } private commitSnapshotInputs(snapshot: IBulkGraphSession.ISnapshot): void { + const committed = inputDigests(this.root, snapshot); for (const [file, source] of snapshot.sources) { if (!path.isAbsolute(file)) continue; - this.watchedInputs.set( + committed.set( file, source.diskDigest === "" ? null : source.diskDigest, ); } + this.watchedInputs = committed; } private async requestSnapshot( diff --git a/packages/graph/src/provider/cpp/CppGraphSnapshotAdapter.ts b/packages/graph/src/provider/cpp/CppGraphSnapshotAdapter.ts index 5e64b002..ca497fae 100644 --- a/packages/graph/src/provider/cpp/CppGraphSnapshotAdapter.ts +++ b/packages/graph/src/provider/cpp/CppGraphSnapshotAdapter.ts @@ -58,6 +58,7 @@ const CAPABILITIES = [ /** Converts one validated native clangd generation into the common protocol. */ export class CppGraphSnapshotAdapter { public readonly store: GraphSnapshotProtocol.Store; + private readonly selectedLanguages: ReadonlySet; private rawShards = new Map(); private graphShards = new Map(); private rawGeneration: string | undefined; @@ -65,8 +66,10 @@ export class CppGraphSnapshotAdapter { public constructor( private readonly root: string, private readonly producerCommit: string, + languages: readonly GraphLanguage[] = ["c", "cpp"], ) { this.store = new GraphSnapshotProtocol.Store(root); + this.selectedLanguages = new Set(languages); } public get generation(): string | undefined { @@ -96,6 +99,11 @@ export class CppGraphSnapshotAdapter { raw.phases.cacheHit && raw.universe.digest === prior.provenance.universe ) { + assertNativeGeneration( + raw, + nativeManifest(this.rawShards), + this.rawShards, + ); return { changed: false, mode: "unchanged", snapshot: prior }; } const nextRaw = @@ -117,9 +125,7 @@ export class CppGraphSnapshotAdapter { touched.add(shard.key); nextRaw.set(shard.key, structuredClone(shard)); } - const expectedManifest = [...nextRaw.values()] - .sort((left, right) => compareText(left.key, right.key)) - .map((shard) => ({ key: shard.key, digest: shard.digest })); + const expectedManifest = nativeManifest(nextRaw); if ( expectedManifest.length !== raw.manifest.length || expectedManifest.some( @@ -132,7 +138,7 @@ export class CppGraphSnapshotAdapter { } assertNativeGeneration(raw, expectedManifest, nextRaw); - const hello = helloOf(raw, nextRaw); + const hello = helloOf(raw, nextRaw, this.selectedLanguages); const languagesChanged = prior !== undefined && JSON.stringify(prior.languages) !== JSON.stringify(hello.languages); @@ -144,11 +150,18 @@ export class CppGraphSnapshotAdapter { ? new Map() : new Map(this.graphShards); const graphUpserts = requiresReload ? [...nextRaw.values()] : raw.upserts; + for (const key of raw.deletes) nextGraph.delete(graphKey(key)); for (const shard of graphUpserts) { - nextGraph.set( - graphKey(shard.key), - adaptShard(this.root, raw, shard, hello.languages), - ); + const key = graphKey(shard.key); + const language = shard.graph.language; + if ( + (language !== "c" && language !== "cpp") || + !this.selectedLanguages.has(language) + ) { + nextGraph.delete(key); + continue; + } + nextGraph.set(key, adaptShard(this.root, raw, shard, hello.languages)); } const sequence = (prior?.protocol?.sequence ?? 0) + 1; const manifest = [...nextGraph] @@ -787,7 +800,21 @@ function graphFile(root: string, source: string): string { } if (!path.isAbsolute(absolute)) return absolute.replaceAll("\\", "/"); const relative = path.relative(root, absolute).replaceAll("\\", "/"); - return relative; + return path.isAbsolute(relative) + ? externalGraphFile(absolute) + : relative; +} + +/* c8 ignore next -- only Windows cross-volume or UNC sources reach this helper. */ +function externalGraphFile(source: string): string { + const normalized = path.normalize(source); + /* c8 ignore next 3 -- only one platform's path-identity arm runs per host. */ + const identity = process.platform === "win32" + ? normalized.toLowerCase() + : normalized; + /* c8 ignore next -- a producer source is a file, never a filesystem root. */ + const basename = encodeURIComponent(path.basename(normalized) || "source"); + return `bundled:///clang/filesystem/${sha256(identity)}/${basename}`; } function sourceFile(root: string, source: string): string { @@ -802,16 +829,37 @@ function sourceFile(root: string, source: string): string { } function assertSupportedSource(source: string): void { - if ( - !source.startsWith("file:") && - !source.startsWith("bundled:///") && - !path.isAbsolute(source) && - /^[A-Za-z][A-Za-z0-9+.-]*:/u.test(source) - ) { + /* c8 ignore next 3 -- assertGraph validates every native source before adaptation. */ + if (!isSupportedSource(source)) { throw new Error(`unsupported C/C++ graph source URI: ${source}`); } } +function isSupportedSource(source: string): boolean { + if (source.startsWith("bundled:///")) { + const relative = source.slice("bundled:///".length); + return ( + relative !== "" && + !relative.includes("\\") && + path.posix.normalize(relative) === relative && + relative + .split("/") + .every((part) => part !== "" && part !== "." && part !== "..") + ); + } + if (source.startsWith("file:")) { + try { + return path.isAbsolute(fileURLToPath(source)); + } catch { + return false; + } + } + return ( + path.isAbsolute(source) || + !/^[A-Za-z][A-Za-z0-9+.-]*:/u.test(source) + ); +} + function graphKey(raw: string): string { return `cpp-shard:${sha256(raw)}`; } @@ -865,10 +913,14 @@ const NODE_KINDS: Record = { function helloOf( raw: ICppGraphSnapshot, shards: ReadonlyMap, + selectedLanguages: ReadonlySet, ): GraphSnapshotProtocol.IHello { const languages = new Set(); for (const shard of shards.values()) { - if (shard.graph.language === "c" || shard.graph.language === "cpp") { + if ( + (shard.graph.language === "c" || shard.graph.language === "cpp") && + selectedLanguages.has(shard.graph.language) + ) { languages.add(shard.graph.language); } } @@ -982,19 +1034,19 @@ function assertSnapshot(raw: ICppGraphSnapshot, commit: string): void { ) { throw new Error("C/C++ clang graph: malformed assembled page"); } - let lastManifest = ""; + const manifestKeys = new Set(); for (const entry of raw.manifest) { if ( !isRecord(entry) || typeof entry.key !== "string" || entry.key === "" || - entry.key <= lastManifest || + manifestKeys.has(entry.key) || typeof entry.digest !== "string" || !SHA256.test(entry.digest) ) { throw new Error("C/C++ clang graph: malformed native manifest"); } - lastManifest = entry.key; + manifestKeys.add(entry.key); } if ( !nonnegativeInteger(raw.phases.validationMillis) || @@ -1077,8 +1129,10 @@ function assertGraph(graph: ICppGraphSnapshot.ITU, key: string): void { !SHA256.test(graph.producerFingerprint) || typeof graph.mainFileUri !== "string" || graph.mainFileUri === "" || + !isSupportedSource(graph.mainFileUri) || typeof graph.mainFile !== "string" || graph.mainFile === "" || + !isSupportedSource(graph.mainFile) || typeof graph.directory !== "string" || !canonicalStrings(graph.commandLine, true, false) || typeof graph.output !== "string" || @@ -1109,6 +1163,7 @@ function assertGraph(graph: ICppGraphSnapshot.ITU, key: string): void { !isRecord(source) || typeof source.uri !== "string" || source.uri === "" || + !isSupportedSource(source.uri) || sources.has(source.uri) || typeof source.digest !== "string" || !SHA256.test(source.digest) || @@ -1209,8 +1264,10 @@ function assertGraph(graph: ICppGraphSnapshot.ITU, key: string): void { !isRecord(include) || typeof include.source !== "string" || include.source === "" || + !isSupportedSource(include.source) || typeof include.target !== "string" || include.target === "" || + !isSupportedSource(include.target) || typeof include.spelling !== "string" || typeof include.angled !== "boolean" || typeof include.moduleImported !== "boolean" || @@ -1245,6 +1302,24 @@ function assertGraph(graph: ICppGraphSnapshot.ITU, key: string): void { } } +function nativeManifest( + shards: ReadonlyMap, +): Array<{ key: string; digest: string }> { + return [...shards.values()] + .sort((left, right) => { + const mainFile = Buffer.compare( + Buffer.from(left.graph.mainFile, "utf8"), + Buffer.from(right.graph.mainFile, "utf8"), + ); + if (mainFile !== 0) return mainFile; + return Buffer.compare( + Buffer.from(left.configuration, "utf8"), + Buffer.from(right.configuration, "utf8"), + ); + }) + .map((shard) => ({ key: shard.key, digest: shard.digest })); +} + function assertNativeGeneration( raw: ICppGraphSnapshot, manifest: Array<{ key: string; digest: string }>, @@ -1332,9 +1407,10 @@ function validNativeRange( ); } return ( - value.endLine > value.startLine || - (value.endLine === value.startLine && - value.endColumn >= value.startColumn) + isSupportedSource(value.file) && + (value.endLine > value.startLine || + (value.endLine === value.startLine && + value.endColumn >= value.startColumn)) ); } diff --git a/tests/experiment/src/catalog.mjs b/tests/experiment/src/catalog.mjs index 54153d72..999a1ee7 100644 --- a/tests/experiment/src/catalog.mjs +++ b/tests/experiment/src/catalog.mjs @@ -150,6 +150,8 @@ export const LANGUAGE_EXPERIMENTS = [ strictProvider: "clangd-snapshot", strictAuthority: "compiler", strictTool: "samchon-clangd", + producerRepository: "https://github.com/samchon/llvm-project.git", + producerCommit: "dcc73b6579ebb8b71f6080302a9444f237b7abb8", requiredCapabilities: [ "coverage", "diagnostics", @@ -167,12 +169,7 @@ export const LANGUAGE_EXPERIMENTS = [ "imports", "calls", "accesses", - "instantiates", "type_ref", - "extends", - "implements", - "overrides", - "dispatches", "references", ], crossFileEdge: "references", @@ -217,6 +214,8 @@ export const LANGUAGE_EXPERIMENTS = [ strictProvider: "clangd-snapshot", strictAuthority: "compiler", strictTool: "samchon-clangd", + producerRepository: "https://github.com/samchon/llvm-project.git", + producerCommit: "dcc73b6579ebb8b71f6080302a9444f237b7abb8", requiredCapabilities: [ "coverage", "diagnostics", @@ -234,12 +233,7 @@ export const LANGUAGE_EXPERIMENTS = [ "imports", "calls", "accesses", - "instantiates", "type_ref", - "extends", - "implements", - "overrides", - "dispatches", "references", ], crossFileEdge: "references", diff --git a/tests/experiment/src/setup-language.mjs b/tests/experiment/src/setup-language.mjs index 8d2e1b2c..65032b38 100644 --- a/tests/experiment/src/setup-language.mjs +++ b/tests/experiment/src/setup-language.mjs @@ -399,6 +399,97 @@ const installScipClang = () => "06fd18c576f979a726c651594644ec4a35db4f471f2160b3f72eb89fa6001784", }); +const installClangGraphProducer = () => { + if ( + typeof experiment.producerRepository !== "string" || + typeof experiment.producerCommit !== "string" + ) { + throw new Error( + `${experiment.language}: native Clang setup requires an exact producer repository and commit`, + ); + } + const source = path.join(toolsRoot, "samchon-clangd-source"); + const build = path.join(source, "build"); + fs.rmSync(source, { force: true, recursive: true }); + ensureDir(source); + run("git", ["init", "--quiet"], { cwd: source }); + run("git", ["remote", "add", "origin", experiment.producerRepository], { + cwd: source, + }); + run( + "git", + ["fetch", "--depth=1", "origin", experiment.producerCommit], + { cwd: source }, + ); + run("git", ["checkout", "--detach", "FETCH_HEAD"], { cwd: source }); + const revision = String( + run("git", ["rev-parse", "HEAD"], { + cwd: source, + stdio: "pipe", + }).stdout, + ).trim(); + if (revision !== experiment.producerCommit) { + throw new Error( + `${experiment.language}: checked out native Clang ${revision}, expected ${experiment.producerCommit}`, + ); + } + run("cmake", [ + "-S", + path.join(source, "llvm"), + "-B", + build, + "-G", + "Ninja", + "-DCMAKE_BUILD_TYPE=Release", + "-DCMAKE_C_COMPILER=clang", + "-DCMAKE_CXX_COMPILER=clang++", + "-DLLVM_ENABLE_PROJECTS=clang;clang-tools-extra", + "-DLLVM_TARGETS_TO_BUILD=Native", + "-DLLVM_ENABLE_ASSERTIONS=ON", + "-DLLVM_INCLUDE_TESTS=OFF", + "-DCLANG_INCLUDE_TESTS=OFF", + "-DLLVM_INCLUDE_BENCHMARKS=OFF", + "-DLLVM_INCLUDE_EXAMPLES=OFF", + `-DLLVM_FORCE_VC_REVISION=${experiment.producerCommit}`, + `-DLLVM_FORCE_VC_REPOSITORY=${experiment.producerRepository}`, + ]); + run("cmake", [ + "--build", + build, + "--parallel", + "2", + "--target", + "clangd", + ]); + const binary = path.join(build, "bin", "clangd"); + const version = String( + run(binary, ["--version"], { stdio: "pipe" }).stdout, + ); + if (!version.includes(experiment.producerCommit)) { + throw new Error( + `${experiment.language}: native Clang version omits ${experiment.producerCommit}:\n${version}`, + ); + } + for (const command of ["samchon-clangd", "clangd"]) { + const link = path.join(binRoot, command); + fs.rmSync(link, { force: true }); + fs.linkSync(binary, link); + } + record({ + tool: "samchon-clangd", + version: experiment.producerCommit, + source: `${experiment.producerRepository}@${experiment.producerCommit}`, + digest: `git:${experiment.producerCommit}`, + }); + record({ + tool: "clangd", + version: experiment.producerCommit, + source: "alias of samchon-clangd", + digest: `git:${experiment.producerCommit}`, + }); + fs.rmSync(source, { force: true, recursive: true }); +}; + // The published tarball is a webpack bundle whose only runtime `require`s are // Node built-ins, so extracting the integrity-verified archive installs exactly // the bytes the digest covers. `npm install` would instead resolve the package's @@ -643,13 +734,8 @@ switch (experiment.language) { // database, and a Makefile project has no way to emit one — bear records // the compiler invocations as the build runs. A CMake project needs nothing // extra, since configure writes the database on its own. - apt(["clangd", "bear"]); - record({ - tool: "clangd", - version: "unpinned", - source: "apt clangd", - digest: "unpinned", - }); + apt(["clang", "cmake", "ninja-build", "bear"]); + installClangGraphProducer(); record({ tool: "bear", version: "unpinned", diff --git a/tests/test-graph/src/features/test_cpp_clang_snapshot_adapter_and_client_are_atomic.ts b/tests/test-graph/src/features/test_cpp_clang_snapshot_adapter_and_client_are_atomic.ts index 20c7a6ba..182c6c8e 100644 --- a/tests/test-graph/src/features/test_cpp_clang_snapshot_adapter_and_client_are_atomic.ts +++ b/tests/test-graph/src/features/test_cpp_clang_snapshot_adapter_and_client_are_atomic.ts @@ -12,11 +12,17 @@ import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; +import { pathToFileURL } from "node:url"; import { GraphPaths } from "../internal/GraphPaths.js"; const COMMIT = CPP_CLANG_PRODUCER_COMMIT; +/** + * Proves the pinned clangd snapshot's native trust boundary, C/C++ slice + * projection, source identities, resident lifecycle, pagination, and fallback + * registration as one atomic compiler-owned provider contract. + */ export const test_cpp_clang_snapshot_adapter_and_client_are_atomic = async () => { const root = fixtureRoot(); const raw = nativeSnapshot(root); @@ -127,6 +133,8 @@ export const test_cpp_clang_snapshot_adapter_and_client_are_atomic = async () => () => undefined, ), ); + assertCrossVolumeIdentity(root, raw); + assertUnicodeManifestOrdering(); assertNativeRefusals(root, raw); await assertProvider(root); await assertClientLifecycle(root); @@ -221,6 +229,23 @@ async function assertProvider(root: string): Promise { } finally { await session.close(); } + const cppOnly = cppGraphProvider.open({ + root, + command: resolved!, + languages: ["cpp"], + options: {}, + }); + try { + const selected = await cppOnly.refresh(); + TestValidator.predicate( + "one requested language projects its slice from the producer's atomic mixed C/C++ universe", + selected.snapshot.languages.length === 1 && + selected.snapshot.languages[0] === "cpp" && + selected.snapshot.nodes.every((node) => node.language === "cpp"), + ); + } finally { + await cppOnly.close(); + } const absent = GraphPaths.createTempDirectory("samchon-graph-cpp-no-cdb-"); fs.writeFileSync(path.join(absent, "compile_commands.json"), "[]"); fs.mkdirSync(path.join(absent, "build")); @@ -257,6 +282,70 @@ function nativeSnapshot( return JSON.parse(result.stdout) as ICppGraphSnapshot; } +function assertCrossVolumeIdentity( + root: string, + valid: ICppGraphSnapshot, +): void { + if (process.platform !== "win32") return; + const rootDrive = path.parse(root).root.slice(0, 1).toUpperCase(); + const foreignDrive = rootDrive === "C" ? "D" : "C"; + const foreign = path.join(`${foreignDrive}:\\`, "sdk", "foreign.hpp"); + const foreignUri = pathToFileURL(foreign).href; + const candidate = structuredClone(valid); + const graph = candidate.upserts[0]!.graph; + graph.sources.push({ + uri: foreignUri, + digest: sha256("foreign checker source"), + diskDigest: sha256("foreign disk source"), + flags: 0, + }); + graph.symbols[0]!.definition.file = foreignUri; + resealSnapshot(candidate); + const snapshot = new CppGraphSnapshotAdapter(root, COMMIT).apply( + candidate, + () => undefined, + ).snapshot; + TestValidator.predicate( + "a Windows source on another volume keeps a real manifest path and an opaque external graph identity", + snapshot.sources.has(path.normalize(foreign)) && + snapshot.nodes.some( + (node) => + node.name === "caller" && + node.external && + node.file.startsWith("bundled:///clang/filesystem/"), + ), + ); +} + +function assertUnicodeManifestOrdering(): void { + const root = GraphPaths.createTempDirectory("samchon-graph-cpp-unicode-"); + const commands = ["z.cpp", "é.cpp"].map((file) => { + fs.writeFileSync(path.join(root, file), "void caller() {}\n"); + return { + directory: root, + file, + arguments: ["clang++", "-x", "c++", "-c", file], + }; + }); + fs.writeFileSync( + path.join(root, "compile_commands.json"), + JSON.stringify(commands), + ); + const raw = nativeSnapshot(root); + const snapshot = new CppGraphSnapshotAdapter(root, COMMIT).apply( + raw, + () => undefined, + ).snapshot; + TestValidator.equals( + "native raw-path byte order remains valid when URI encoding would sort Unicode first", + [ + raw.upserts.map((shard) => path.basename(shard.source)), + snapshot.sources.size, + ], + [["z.cpp", "é.cpp"], 2], + ); +} + function assertNativeRefusals( root: string, valid: ICppGraphSnapshot, @@ -314,6 +403,11 @@ function assertNativeRefusals( rejects("a malformed native symbol is refused", (value) => { value.upserts[0]!.graph.symbols[0]!.name = ""; }); + rejects("an unsupported URI cannot hide in an unselected native range", (value) => { + value.upserts[0]!.graph.symbols[0]!.declaration.file = + "repo:///hidden-declaration.cpp"; + resealSnapshot(value); + }); rejects("a malformed native occurrence is refused", (value) => { value.upserts[0]!.graph.occurrences[0]!.usr = ""; }); @@ -371,6 +465,18 @@ function assertNativeRefusals( }); const stale = new CppGraphSnapshotAdapter(root, COMMIT); stale.apply(structuredClone(valid), () => undefined); + const malformedNoop = structuredClone(valid); + malformedNoop.baseGeneration = valid.generation; + malformedNoop.upserts = []; + malformedNoop.deletes = []; + malformedNoop.manifest = []; + malformedNoop.page = { offset: 0, count: 0, total: 0, nextCursor: null }; + malformedNoop.phases.cacheHit = true; + malformedNoop.universe.targets = ["foreign-target"]; + TestValidator.error( + "an unchanged frame still proves that its universe describes the resident shards", + () => stale.apply(malformedNoop, () => undefined), + ); const delta = structuredClone(valid); delta.baseGeneration = "0".repeat(64); TestValidator.error("a Clang delta must name the exact resident base", () => @@ -590,15 +696,16 @@ async function assertClientInputShapes(): Promise { } fs.writeFileSync(path.join(build, "fallback.cpp"), "void caller() {}\n"); const absolute = path.join(root, "absolute.cpp"); + const buildCommands = [ + {}, + { file: "" }, + { file: "fallback.cpp" }, + { directory: root, file: "direct.cpp" }, + { directory: "", file: absolute }, + ]; fs.writeFileSync( path.join(build, "compile_commands.json"), - JSON.stringify([ - {}, - { file: "" }, - { file: "fallback.cpp" }, - { directory: root, file: "direct.cpp" }, - { directory: "", file: absolute }, - ]), + JSON.stringify(buildCommands), ); const watchLog = path.join(root, "input-watches.ndjson"); const client = new CppGraphClient({ @@ -620,14 +727,51 @@ async function assertClientInputShapes(): Promise { await client.refresh(); fs.unlinkSync(path.join(root, ".clangd")); await client.refresh(); + fs.writeFileSync( + path.join(build, "compile_commands.json"), + JSON.stringify( + buildCommands.filter( + (row) => !("file" in row) || row.file !== "direct.cpp", + ), + ), + ); + await client.refresh(); + await client.refresh(); + fs.unlinkSync(absolute); + fs.writeFileSync( + path.join(build, "compile_commands.json"), + JSON.stringify( + buildCommands.filter( + (row) => + !("file" in row) || + (row.file !== "direct.cpp" && row.file !== absolute), + ), + ), + ); + await client.refresh(); + await client.refresh(); + const watched = readLines(watchLog).flatMap((row) => row.changes); TestValidator.equals( "CDB input discovery accepts fallback directories and tracks null-to-file transitions", - readLines(watchLog) - .flatMap((row) => row.changes) + watched .filter((change) => String(change.uri).endsWith("/.clangd")) .map((change) => change.type), [1, 3], ); + TestValidator.equals( + "a file removed only from the compilation database is not falsely deleted on the following refresh", + watched + .filter((change) => String(change.uri).endsWith("/direct.cpp")) + .map((change) => change.type), + [1], + ); + TestValidator.equals( + "a deleted source is notified once and never reborn from a stale null baseline", + watched + .filter((change) => String(change.uri).endsWith("/absolute.cpp")) + .map((change) => change.type), + [1, 3], + ); } finally { await client.close(); } @@ -929,6 +1073,21 @@ function nativeShardDigest(shard: ICppGraphSnapshot.IShard): string { ); } +function resealSnapshot(snapshot: ICppGraphSnapshot): void { + for (const shard of snapshot.upserts) shard.digest = nativeShardDigest(shard); + const digests = new Map( + snapshot.upserts.map((shard) => [shard.key, shard.digest]), + ); + snapshot.manifest = snapshot.manifest.map((entry) => ({ + key: entry.key, + digest: digests.get(entry.key) ?? entry.digest, + })); + snapshot.generation = nativeGeneration( + snapshot.universe.digest, + snapshot.manifest, + ); +} + function nativeGeneration( universe: string, manifest: readonly { key: string; digest: string }[], diff --git a/tests/test-graph/src/features/test_cpp_static_preserves_out_of_line_method_flows.ts b/tests/test-graph/src/features/test_cpp_static_preserves_out_of_line_method_flows.ts index b116bf34..1cb35176 100644 --- a/tests/test-graph/src/features/test_cpp_static_preserves_out_of_line_method_flows.ts +++ b/tests/test-graph/src/features/test_cpp_static_preserves_out_of_line_method_flows.ts @@ -9,7 +9,7 @@ import { GraphPaths } from "../internal/GraphPaths"; export const test_cpp_static_preserves_out_of_line_method_flows = async () => { const root = GraphPaths.createTempDirectory("samchon-cpp-methods-"); fs.writeFileSync( - path.join(root, "engine.hpp"), + path.join(root, "engine.h"), [ "namespace storage {", "struct Status {};", @@ -85,7 +85,7 @@ export const test_cpp_static_preserves_out_of_line_method_flows = async () => { const write = method("Write"); const engine = graph.nodes.find( (node) => - node.file.endsWith("engine.hpp") && + node.file.endsWith("engine.h") && node.kind === "class" && (node.qualifiedName ?? node.name) === "storage.Engine", ); @@ -122,7 +122,7 @@ export const test_cpp_static_preserves_out_of_line_method_flows = async () => { ["Put", "Get", "Write"].every((name) => graph.nodes.some( (node) => - node.file.endsWith("engine.hpp") && + node.file.endsWith("engine.h") && node.kind === "method" && node.name === name && node.qualifiedName === `storage.Engine.${name}`, diff --git a/tests/test-graph/src/features/test_ensure_compile_commands_wires_into_lsp_build.ts b/tests/test-graph/src/features/test_ensure_compile_commands_wires_into_lsp_build.ts index a5a6548c..71296785 100644 --- a/tests/test-graph/src/features/test_ensure_compile_commands_wires_into_lsp_build.ts +++ b/tests/test-graph/src/features/test_ensure_compile_commands_wires_into_lsp_build.ts @@ -11,7 +11,12 @@ const fakeCmake = [process.execPath, GraphPaths.fakeCmake]; export const test_ensure_compile_commands_wires_into_lsp_build = async () => { const root = GraphFixtures.createCmakeFixture(); fs.mkdirSync(path.join(root, "src")); + fs.mkdirSync(path.join(root, "include")); fs.writeFileSync(path.join(root, "src", "main.cc"), "int main() { return 0; }\n"); + fs.writeFileSync( + path.join(root, "include", "shared.h"), + "class SharedHeader {};\n", + ); const argsFile = path.join(root, "fake-lsp-args.json"); const previousArgsFile = process.env.SAMCHON_GRAPH_FAKE_LSP_ARGS_FILE; @@ -26,6 +31,12 @@ export const test_ensure_compile_commands_wires_into_lsp_build = async () => { cmakeCommand: fakeCmake, }); TestValidator.equals("cpp LSP build still succeeds", dump.indexer, "lsp"); + TestValidator.predicate( + "generic C++ LSP discovery opens a shared .h before semantic ownership resolution", + dump.nodes.some( + (node) => node.language === "cpp" && node.file.endsWith("shared.h"), + ), + ); const args = JSON.parse(fs.readFileSync(argsFile, "utf8")) as string[]; TestValidator.predicate( "the resolved compile_commands.json directory is passed to the server", diff --git a/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts b/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts index b657b4e2..d3cc17ff 100644 --- a/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts +++ b/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts @@ -1,5 +1,9 @@ import { TestValidator } from "@nestia/e2e"; -import { LANGUAGE_SPECS, RUST_GRAPH_PRODUCER_COMMIT } from "@samchon/graph"; +import { + CPP_CLANG_PRODUCER_COMMIT, + LANGUAGE_SPECS, + RUST_GRAPH_PRODUCER_COMMIT, +} from "@samchon/graph"; import fs from "node:fs"; import path from "node:path"; @@ -47,6 +51,7 @@ export const test_experiment_corpora_are_commit_pinned = () => { const javaSetup = region(setup, 'case "java"', 'case "csharp"'); const kotlinSetup = region(setup, 'case "kotlin"', 'case "swift"'); const rustSetup = region(setup, 'case "rust"', 'case "cpp"'); + const cppSetup = region(setup, 'case "cpp"', 'case "java"'); TestValidator.equals( "every registered strict-provider language has a lifecycle row", [...catalog.matchAll(/strictProvider:\s*"[^"]+"/g)].length, @@ -181,11 +186,38 @@ export const test_experiment_corpora_are_commit_pinned = () => { [cpp, c].every( (row) => row.includes('strictProvider: "clangd-snapshot"') && + row.includes( + 'producerRepository: "https://github.com/samchon/llvm-project.git"', + ) && + row.includes(`producerCommit: "${CPP_CLANG_PRODUCER_COMMIT}"`) && row.includes('crossFileEdge: "references"') && row.includes('"contains"') && row.includes('"references"') && + !row.includes('"implements"') && + !row.includes('"dispatches"') && !row.includes("semanticEdges: []"), - ), + ) && + !c.includes('"instantiates"') && + !c.includes('"extends"') && + !c.includes('"overrides"'), + ); + TestValidator.predicate( + "C and C++ build and record the exact campaign-owned native producer", + cppSetup.includes('apt(["clang", "cmake", "ninja-build", "bear"])') && + cppSetup.includes("installClangGraphProducer()") && + setup.includes( + '["fetch", "--depth=1", "origin", experiment.producerCommit]', + ) && + setup.includes('["checkout", "--detach", "FETCH_HEAD"]') && + setup.includes('["rev-parse", "HEAD"]') && + setup.includes('"-DLLVM_ENABLE_PROJECTS=clang;clang-tools-extra"') && + setup.includes('"--target",') && + setup.includes('"clangd",') && + setup.includes('for (const command of ["samchon-clangd", "clangd"])') && + setup.includes("fs.linkSync(binary, link)") && + setup.includes("version.includes(experiment.producerCommit)") && + setup.includes('tool: "samchon-clangd"') && + !cppSetup.includes('apt(["clangd"'), ); // scip-python 0.6.6 recovers from a malformed `pyproject.toml`, falls back to // Pyright defaults and emits no SCIP diagnostics. On the pinned Click diff --git a/tests/test-graph/src/features/test_language_registry_lists_advertised_targets.ts b/tests/test-graph/src/features/test_language_registry_lists_advertised_targets.ts index 02d85f70..95f2e0ab 100644 --- a/tests/test-graph/src/features/test_language_registry_lists_advertised_targets.ts +++ b/tests/test-graph/src/features/test_language_registry_lists_advertised_targets.ts @@ -1,5 +1,10 @@ import { TestValidator } from "@nestia/e2e"; -import { LANGUAGE_SPECS, languageOf } from "@samchon/graph"; +import { + LANGUAGE_SPECS, + allExtensions, + languageOf, + languagesOf, +} from "@samchon/graph"; import { GraphFixtures } from "../internal/GraphFixtures"; @@ -48,6 +53,15 @@ export const test_language_registry_lists_advertised_targets = () => { languageOf("include/interface.h"), "c", ); + TestValidator.equals( + "lowercase .h reaches both contextual owners", + languagesOf("include/interface.h"), + ["cpp", "c"], + ); + TestValidator.predicate( + "C++-only discovery includes shared .h inputs", + allExtensions(["cpp"]).has(".h"), + ); TestValidator.equals( "an unregistered uppercase suffix remains unknown after folded lookup", languageOf("README.MD"), diff --git a/tests/test-graph/src/internal/fake-cpp-graph-server.cjs b/tests/test-graph/src/internal/fake-cpp-graph-server.cjs index 0fe4eea0..7eaa7a93 100644 --- a/tests/test-graph/src/internal/fake-cpp-graph-server.cjs +++ b/tests/test-graph/src/internal/fake-cpp-graph-server.cjs @@ -611,7 +611,13 @@ function canonicalRoot(root) { } function compareKey(left, right) { - return left.key < right.key ? -1 : left.key > right.key ? 1 : 0; + if (left.source !== right.source) + return left.source < right.source ? -1 : 1; + return left.configuration < right.configuration + ? -1 + : left.configuration > right.configuration + ? 1 + : 0; } function send(message) { From 3f069fbbd982ead1858e8ec98ef4b4e2108f0c43 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Mon, 3 Aug 2026 19:05:19 +0900 Subject: [PATCH 30/52] fix(graph): close C and C++ review gaps Preserve one contextual static header view, install Clang resource headers beside the experiment binary, match producer byte ordering, and keep platform coverage exact. Close #73: [Bulk index][C/C++] Share one Clang compilation-universe provider --- .../graph/src/indexer/staticGraphParts.ts | 56 ++++++++++++++----- .../provider/cpp/CppGraphSnapshotAdapter.ts | 9 +-- tests/experiment/src/setup-language.mjs | 42 ++++++++++++++ ..._snapshot_adapter_and_client_are_atomic.ts | 8 ++- ...atic_preserves_out_of_line_method_flows.ts | 34 +++++++++++ ...e_compile_commands_wires_into_lsp_build.ts | 1 + ...st_experiment_corpora_are_commit_pinned.ts | 5 ++ ...guage_registry_lists_advertised_targets.ts | 1 + .../src/internal/fake-cpp-graph-server.cjs | 16 +++--- 9 files changed, 142 insertions(+), 30 deletions(-) diff --git a/packages/graph/src/indexer/staticGraphParts.ts b/packages/graph/src/indexer/staticGraphParts.ts index 923d6869..ca123737 100644 --- a/packages/graph/src/indexer/staticGraphParts.ts +++ b/packages/graph/src/indexer/staticGraphParts.ts @@ -12,6 +12,7 @@ import { GraphLanguage } from "../typings"; import { projectRelative, readText } from "../utils/fs"; import { IBuildGraphOptions } from "./IBuildGraphOptions"; import { IStaticGraphParts } from "./IStaticGraphParts"; +import { languageOf } from "./languageOf"; import { languagesOf } from "./languages"; import { normalizeRequestedLanguages } from "./normalizeRequestedLanguages"; import { selectGraphSources } from "./selectGraphSources"; @@ -28,30 +29,57 @@ export function staticGraphParts( const discovered = selectedFiles ?? selectGraphSources(root, options).files; const requested = normalizeRequestedLanguages(options.languages); const allowed = requested === undefined ? undefined : new Set(requested); + const contextualLanguages = new Set(); + for (const absolutePath of discovered) { + const owners = staticOwners(absolutePath, allowed); + if (owners.length === 1) contextualLanguages.add(owners[0]!); + } const files: IGraphSitterFile[] = []; for (const absolutePath of discovered) { - // Source selection and extraction share the same multi-owner registry. A - // plain .h is therefore parsed for every selected C/C++ ownership view, - // while exact .H remains C++ as declared by the registry. const source = readText(absolutePath); /* c8 ignore next */ if (source === undefined) continue; - for (const language of languagesOf(absolutePath)) { - if (allowed !== undefined && !allowed.has(language)) continue; - /* c8 ignore next */ - if (!isGraphSitterLanguage(language)) continue; - files.push({ - absolutePath, - relativePath: projectRelative(root, absolutePath), - language, - source, - }); - } + const owners = staticOwners(absolutePath, allowed); + const language = staticOwner(absolutePath, owners, contextualLanguages); + /* c8 ignore next -- normal discovery cannot return a path outside its requested registry. */ + if (language === undefined) continue; + files.push({ + absolutePath, + relativePath: projectRelative(root, absolutePath), + language, + source, + }); } const parts = graphSitterParts({ root, files }); return parts; } +/** Keep graph-sitter's file identity singular while honoring explicit filters. */ +function staticOwners( + absolutePath: string, + allowed: ReadonlySet | undefined, +): GraphSitterLanguage[] { + return languagesOf(absolutePath).filter( + (language): language is GraphSitterLanguage => + (allowed === undefined || allowed.has(language)) && + isGraphSitterLanguage(language), + ); +} + +/** Resolve a shared header from the unambiguous translation units around it. */ +function staticOwner( + absolutePath: string, + owners: readonly GraphSitterLanguage[], + contextualLanguages: ReadonlySet, +): GraphSitterLanguage | undefined { + if (owners.length <= 1) return owners[0]; + if (owners.includes("cpp") && contextualLanguages.has("cpp")) return "cpp"; + if (owners.includes("c") && contextualLanguages.has("c")) return "c"; + // Multiple supported owners currently means a shared .h with both owners + // still allowed, so the singular compatibility owner is one of this set. + return languageOf(absolutePath) as GraphSitterLanguage; +} + // The package boundary is intentionally structural and acyclic. These // bidirectional checks make any raw node, edge, or language drift a compile // failure before an adapter can silently weaken the public graph contract. diff --git a/packages/graph/src/provider/cpp/CppGraphSnapshotAdapter.ts b/packages/graph/src/provider/cpp/CppGraphSnapshotAdapter.ts index ca497fae..9276321a 100644 --- a/packages/graph/src/provider/cpp/CppGraphSnapshotAdapter.ts +++ b/packages/graph/src/provider/cpp/CppGraphSnapshotAdapter.ts @@ -805,17 +805,14 @@ function graphFile(root: string, source: string): string { : relative; } -/* c8 ignore next -- only Windows cross-volume or UNC sources reach this helper. */ +/* c8 ignore start -- only Windows cross-volume or UNC sources reach this helper. */ function externalGraphFile(source: string): string { const normalized = path.normalize(source); - /* c8 ignore next 3 -- only one platform's path-identity arm runs per host. */ - const identity = process.platform === "win32" - ? normalized.toLowerCase() - : normalized; - /* c8 ignore next -- a producer source is a file, never a filesystem root. */ + const identity = normalized.toLowerCase(); const basename = encodeURIComponent(path.basename(normalized) || "source"); return `bundled:///clang/filesystem/${sha256(identity)}/${basename}`; } +/* c8 ignore stop */ function sourceFile(root: string, source: string): string { assertSupportedSource(source); diff --git a/tests/experiment/src/setup-language.mjs b/tests/experiment/src/setup-language.mjs index 65032b38..fa10fb3d 100644 --- a/tests/experiment/src/setup-language.mjs +++ b/tests/experiment/src/setup-language.mjs @@ -470,11 +470,53 @@ const installClangGraphProducer = () => { `${experiment.language}: native Clang version omits ${experiment.producerCommit}:\n${version}`, ); } + const builtResources = path.join(build, "lib", "clang"); + const resourceVersions = fs + .readdirSync(builtResources, { withFileTypes: true }) + .filter( + (entry) => + entry.isDirectory() && + fs.statSync( + path.join(builtResources, entry.name, "include"), + { throwIfNoEntry: false }, + )?.isDirectory(), + ) + .map((entry) => entry.name); + if (resourceVersions.length !== 1) { + throw new Error( + `${experiment.language}: native Clang produced ${resourceVersions.length} resource-header trees`, + ); + } + const installedResources = path.join(toolsRoot, "lib", "clang"); + fs.rmSync(installedResources, { force: true, recursive: true }); + ensureDir(path.dirname(installedResources)); + fs.cpSync(builtResources, installedResources, { recursive: true }); + const installedStddef = path.join( + installedResources, + resourceVersions[0], + "include", + "stddef.h", + ); + if (!fs.statSync(installedStddef, { throwIfNoEntry: false })?.isFile()) { + throw new Error( + `${experiment.language}: native Clang resource headers were not installed at ${installedStddef}`, + ); + } for (const command of ["samchon-clangd", "clangd"]) { const link = path.join(binRoot, command); fs.rmSync(link, { force: true }); fs.linkSync(binary, link); } + const installedVersion = String( + run(path.join(binRoot, "samchon-clangd"), ["--version"], { + stdio: "pipe", + }).stdout, + ); + if (!installedVersion.includes(experiment.producerCommit)) { + throw new Error( + `${experiment.language}: installed native Clang omits ${experiment.producerCommit}:\n${installedVersion}`, + ); + } record({ tool: "samchon-clangd", version: experiment.producerCommit, diff --git a/tests/test-graph/src/features/test_cpp_clang_snapshot_adapter_and_client_are_atomic.ts b/tests/test-graph/src/features/test_cpp_clang_snapshot_adapter_and_client_are_atomic.ts index 182c6c8e..4708bf2c 100644 --- a/tests/test-graph/src/features/test_cpp_clang_snapshot_adapter_and_client_are_atomic.ts +++ b/tests/test-graph/src/features/test_cpp_clang_snapshot_adapter_and_client_are_atomic.ts @@ -319,7 +319,9 @@ function assertCrossVolumeIdentity( function assertUnicodeManifestOrdering(): void { const root = GraphPaths.createTempDirectory("samchon-graph-cpp-unicode-"); - const commands = ["z.cpp", "é.cpp"].map((file) => { + const supplementary = "\u{10000}.cpp"; + const privateUse = "\uE000.cpp"; + const commands = [supplementary, privateUse].map((file) => { fs.writeFileSync(path.join(root, file), "void caller() {}\n"); return { directory: root, @@ -337,12 +339,12 @@ function assertUnicodeManifestOrdering(): void { () => undefined, ).snapshot; TestValidator.equals( - "native raw-path byte order remains valid when URI encoding would sort Unicode first", + "native manifest order compares raw UTF-8 bytes instead of UTF-16 code units", [ raw.upserts.map((shard) => path.basename(shard.source)), snapshot.sources.size, ], - [["z.cpp", "é.cpp"], 2], + [[privateUse, supplementary], 2], ); } diff --git a/tests/test-graph/src/features/test_cpp_static_preserves_out_of_line_method_flows.ts b/tests/test-graph/src/features/test_cpp_static_preserves_out_of_line_method_flows.ts index 1cb35176..53cf17d5 100644 --- a/tests/test-graph/src/features/test_cpp_static_preserves_out_of_line_method_flows.ts +++ b/tests/test-graph/src/features/test_cpp_static_preserves_out_of_line_method_flows.ts @@ -6,6 +6,7 @@ import { buildGraphDump } from "@samchon/graph"; import { GraphPaths } from "../internal/GraphPaths"; +/** Proves shared headers retain their C++ structure and out-of-line flows. */ export const test_cpp_static_preserves_out_of_line_method_flows = async () => { const root = GraphPaths.createTempDirectory("samchon-cpp-methods-"); fs.writeFileSync( @@ -72,6 +73,39 @@ export const test_cpp_static_preserves_out_of_line_method_flows = async () => { mode: "static", languages: ["cpp"], }); + const automatic = await buildGraphDump({ cwd: root, mode: "static" }); + const edgeKeys = (edges: typeof graph.edges) => + edges.map((edge) => JSON.stringify(edge)).sort(); + TestValidator.equals( + "automatic ownership keeps every edge from the contextual C++ header view", + edgeKeys(automatic.edges), + edgeKeys(graph.edges), + ); + const cRoot = GraphPaths.createTempDirectory("samchon-c-header-owner-"); + fs.writeFileSync(path.join(cRoot, "record.h"), "struct Record { int value; };\n"); + fs.writeFileSync(path.join(cRoot, "record.c"), "int read_record(void) { return 0; }\n"); + const cGraph = await buildGraphDump({ cwd: cRoot, mode: "static" }); + TestValidator.predicate( + "an automatic C translation unit keeps its shared header in the C view", + cGraph.nodes.some( + (node) => node.file.endsWith("record.h") && node.language === "c", + ) && + cGraph.nodes.every( + (node) => !node.file.endsWith("record.h") || node.language !== "cpp", + ), + ); + const headerRoot = GraphPaths.createTempDirectory("samchon-header-owner-"); + fs.writeFileSync( + path.join(headerRoot, "standalone.h"), + "struct Standalone { int value; };\n", + ); + const headerGraph = await buildGraphDump({ cwd: headerRoot, mode: "static" }); + TestValidator.predicate( + "a header-only project retains the singular C compatibility owner", + headerGraph.nodes.some( + (node) => node.file.endsWith("standalone.h") && node.language === "c", + ), + ); const sourceMethods = graph.nodes.filter( (node) => node.file.endsWith("engine.cpp") && node.kind === "method", ); diff --git a/tests/test-graph/src/features/test_ensure_compile_commands_wires_into_lsp_build.ts b/tests/test-graph/src/features/test_ensure_compile_commands_wires_into_lsp_build.ts index 71296785..ef512d1a 100644 --- a/tests/test-graph/src/features/test_ensure_compile_commands_wires_into_lsp_build.ts +++ b/tests/test-graph/src/features/test_ensure_compile_commands_wires_into_lsp_build.ts @@ -8,6 +8,7 @@ import { GraphPaths } from "../internal/GraphPaths"; const fakeCmake = [process.execPath, GraphPaths.fakeCmake]; +/** Proves generated compilation databases live for exactly their LSP session. */ export const test_ensure_compile_commands_wires_into_lsp_build = async () => { const root = GraphFixtures.createCmakeFixture(); fs.mkdirSync(path.join(root, "src")); diff --git a/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts b/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts index d3cc17ff..e0498d57 100644 --- a/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts +++ b/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts @@ -215,7 +215,12 @@ export const test_experiment_corpora_are_commit_pinned = () => { setup.includes('"clangd",') && setup.includes('for (const command of ["samchon-clangd", "clangd"])') && setup.includes("fs.linkSync(binary, link)") && + setup.includes('path.join(build, "lib", "clang")') && + setup.includes("fs.cpSync(builtResources, installedResources") && + setup.includes('"include",') && + setup.includes('"stddef.h",') && setup.includes("version.includes(experiment.producerCommit)") && + setup.includes("installedVersion.includes(experiment.producerCommit)") && setup.includes('tool: "samchon-clangd"') && !cppSetup.includes('apt(["clangd"'), ); diff --git a/tests/test-graph/src/features/test_language_registry_lists_advertised_targets.ts b/tests/test-graph/src/features/test_language_registry_lists_advertised_targets.ts index 95f2e0ab..504d49f4 100644 --- a/tests/test-graph/src/features/test_language_registry_lists_advertised_targets.ts +++ b/tests/test-graph/src/features/test_language_registry_lists_advertised_targets.ts @@ -8,6 +8,7 @@ import { import { GraphFixtures } from "../internal/GraphFixtures"; +/** Proves registry order, extension ownership, and advertised defaults agree. */ export const test_language_registry_lists_advertised_targets = () => { TestValidator.equals( "advertised language order", diff --git a/tests/test-graph/src/internal/fake-cpp-graph-server.cjs b/tests/test-graph/src/internal/fake-cpp-graph-server.cjs index 7eaa7a93..5ff2b3ad 100644 --- a/tests/test-graph/src/internal/fake-cpp-graph-server.cjs +++ b/tests/test-graph/src/internal/fake-cpp-graph-server.cjs @@ -611,13 +611,15 @@ function canonicalRoot(root) { } function compareKey(left, right) { - if (left.source !== right.source) - return left.source < right.source ? -1 : 1; - return left.configuration < right.configuration - ? -1 - : left.configuration > right.configuration - ? 1 - : 0; + const source = Buffer.compare( + Buffer.from(left.source, "utf8"), + Buffer.from(right.source, "utf8"), + ); + if (source !== 0) return source; + return Buffer.compare( + Buffer.from(left.configuration, "utf8"), + Buffer.from(right.configuration, "utf8"), + ); } function send(message) { From bccefe485364c7adf8ec7dceef98e306110f0387 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Mon, 3 Aug 2026 19:30:43 +0900 Subject: [PATCH 31/52] fix(graph): exempt Windows-only path branch from Unix coverage --- packages/graph/src/provider/cpp/CppGraphSnapshotAdapter.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/graph/src/provider/cpp/CppGraphSnapshotAdapter.ts b/packages/graph/src/provider/cpp/CppGraphSnapshotAdapter.ts index 9276321a..ce817172 100644 --- a/packages/graph/src/provider/cpp/CppGraphSnapshotAdapter.ts +++ b/packages/graph/src/provider/cpp/CppGraphSnapshotAdapter.ts @@ -800,6 +800,7 @@ function graphFile(root: string, source: string): string { } if (!path.isAbsolute(absolute)) return absolute.replaceAll("\\", "/"); const relative = path.relative(root, absolute).replaceAll("\\", "/"); + /* c8 ignore next -- only Windows cross-volume or UNC sources make the relative path absolute. */ return path.isAbsolute(relative) ? externalGraphFile(absolute) : relative; From c01a42de9cec37862048202b84eb767dda86b17d Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Mon, 3 Aug 2026 20:14:22 +0900 Subject: [PATCH 32/52] fix(graph): ignore the full Windows-only coverage branch --- packages/graph/src/provider/cpp/CppGraphSnapshotAdapter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/graph/src/provider/cpp/CppGraphSnapshotAdapter.ts b/packages/graph/src/provider/cpp/CppGraphSnapshotAdapter.ts index ce817172..872874d1 100644 --- a/packages/graph/src/provider/cpp/CppGraphSnapshotAdapter.ts +++ b/packages/graph/src/provider/cpp/CppGraphSnapshotAdapter.ts @@ -800,7 +800,7 @@ function graphFile(root: string, source: string): string { } if (!path.isAbsolute(absolute)) return absolute.replaceAll("\\", "/"); const relative = path.relative(root, absolute).replaceAll("\\", "/"); - /* c8 ignore next -- only Windows cross-volume or UNC sources make the relative path absolute. */ + /* c8 ignore next 3 -- only Windows cross-volume or UNC sources make the relative path absolute. */ return path.isAbsolute(relative) ? externalGraphFile(absolute) : relative; From 02c357410c1e8c5248f2c2f890fa6e72f322c5da Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Mon, 3 Aug 2026 20:19:33 +0900 Subject: [PATCH 33/52] fix(graph): keep Unix C++ path coverage visible --- .../graph/src/provider/cpp/CppGraphSnapshotAdapter.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/graph/src/provider/cpp/CppGraphSnapshotAdapter.ts b/packages/graph/src/provider/cpp/CppGraphSnapshotAdapter.ts index 872874d1..c5cdfc48 100644 --- a/packages/graph/src/provider/cpp/CppGraphSnapshotAdapter.ts +++ b/packages/graph/src/provider/cpp/CppGraphSnapshotAdapter.ts @@ -800,10 +800,11 @@ function graphFile(root: string, source: string): string { } if (!path.isAbsolute(absolute)) return absolute.replaceAll("\\", "/"); const relative = path.relative(root, absolute).replaceAll("\\", "/"); - /* c8 ignore next 3 -- only Windows cross-volume or UNC sources make the relative path absolute. */ - return path.isAbsolute(relative) - ? externalGraphFile(absolute) - : relative; + if (path.isAbsolute(relative)) { + /* c8 ignore next -- only Windows cross-volume or UNC sources reach this return. */ + return externalGraphFile(absolute); + } + return relative; } /* c8 ignore start -- only Windows cross-volume or UNC sources reach this helper. */ From e9aba080c80cf0bc3fdd3f07105f8b903b0a70a7 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Mon, 3 Aug 2026 20:46:28 +0900 Subject: [PATCH 34/52] fix(graph): scope C++ path coverage to the Windows guard --- packages/graph/src/provider/cpp/CppGraphSnapshotAdapter.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/graph/src/provider/cpp/CppGraphSnapshotAdapter.ts b/packages/graph/src/provider/cpp/CppGraphSnapshotAdapter.ts index c5cdfc48..d98373fe 100644 --- a/packages/graph/src/provider/cpp/CppGraphSnapshotAdapter.ts +++ b/packages/graph/src/provider/cpp/CppGraphSnapshotAdapter.ts @@ -800,10 +800,8 @@ function graphFile(root: string, source: string): string { } if (!path.isAbsolute(absolute)) return absolute.replaceAll("\\", "/"); const relative = path.relative(root, absolute).replaceAll("\\", "/"); - if (path.isAbsolute(relative)) { - /* c8 ignore next -- only Windows cross-volume or UNC sources reach this return. */ - return externalGraphFile(absolute); - } + /* c8 ignore next -- only Windows cross-volume or UNC sources reach this guard. */ + if (path.isAbsolute(relative)) return externalGraphFile(absolute); return relative; } From 8b51eaa19d3a147e493a3eff6bc34ae12e307e1a Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Tue, 4 Aug 2026 01:09:40 +0900 Subject: [PATCH 35/52] Fix exact producer experiment provisioning --- .github/workflows/experiment.yml | 2 +- tests/experiment/src/setup-language.mjs | 7 +++++++ .../test_workflows_use_current_core_action_runtimes.ts | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/experiment.yml b/.github/workflows/experiment.yml index 0f8ba753..4c96f5db 100644 --- a/.github/workflows/experiment.yml +++ b/.github/workflows/experiment.yml @@ -74,7 +74,7 @@ jobs: if: ${{ needs.latest_update.outputs.run == 'true' }} name: ${{ matrix.language }} LSP runs-on: ubuntu-latest - timeout-minutes: 45 + timeout-minutes: 90 strategy: fail-fast: false matrix: diff --git a/tests/experiment/src/setup-language.mjs b/tests/experiment/src/setup-language.mjs index fa10fb3d..64a85973 100644 --- a/tests/experiment/src/setup-language.mjs +++ b/tests/experiment/src/setup-language.mjs @@ -755,6 +755,13 @@ switch (experiment.language) { fs.rmSync(link, { force: true }); fs.linkSync(producerBinary, link); } + recordProvisionedEnvironment( + "SAMCHON_GRAPH_RUST_ANALYZER_HIR", + path.join( + binRoot, + `samchon-rust-analyzer${process.platform === "win32" ? ".exe" : ""}`, + ), + ); record({ tool: "samchon-rust-analyzer", version: experiment.producerCommit, diff --git a/tests/test-graph/src/features/test_workflows_use_current_core_action_runtimes.ts b/tests/test-graph/src/features/test_workflows_use_current_core_action_runtimes.ts index 433f0868..05c9a134 100644 --- a/tests/test-graph/src/features/test_workflows_use_current_core_action_runtimes.ts +++ b/tests/test-graph/src/features/test_workflows_use_current_core_action_runtimes.ts @@ -136,7 +136,7 @@ export const test_workflows_use_current_core_action_runtimes = () => { TestValidator.equals( "every real-tool language lane shares one hang boundary", experimentTimeouts, - ["timeout-minutes: 45"], + ["timeout-minutes: 90"], ); const indexTime = fs.readFileSync( path.join(directory, "index-time.yml"), From 887c3b2c4e33ae3554cce0033ddfe009e5eac8d3 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Tue, 4 Aug 2026 01:28:33 +0900 Subject: [PATCH 36/52] Pin Rust experiment command at the workflow boundary --- .github/workflows/experiment.yml | 2 ++ .../test_workflows_use_current_core_action_runtimes.ts | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/.github/workflows/experiment.yml b/.github/workflows/experiment.yml index 4c96f5db..fcf38537 100644 --- a/.github/workflows/experiment.yml +++ b/.github/workflows/experiment.yml @@ -147,6 +147,8 @@ jobs: - name: Run LSP experiment if: ${{ github.event_name != 'workflow_dispatch' || inputs.language == 'all' || inputs.language == matrix.language }} run: pnpm --filter @samchon/graph-experiment start -- --language ${{ matrix.language }} + env: + SAMCHON_GRAPH_RUST_ANALYZER_HIR: ${{ github.workspace }}/tests/experiment/.work/tools/bin/samchon-rust-analyzer - name: Upload result if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.language == 'all' || inputs.language == matrix.language) }} diff --git a/tests/test-graph/src/features/test_workflows_use_current_core_action_runtimes.ts b/tests/test-graph/src/features/test_workflows_use_current_core_action_runtimes.ts index 05c9a134..59dd07fb 100644 --- a/tests/test-graph/src/features/test_workflows_use_current_core_action_runtimes.ts +++ b/tests/test-graph/src/features/test_workflows_use_current_core_action_runtimes.ts @@ -138,6 +138,12 @@ export const test_workflows_use_current_core_action_runtimes = () => { experimentTimeouts, ["timeout-minutes: 90"], ); + TestValidator.predicate( + "the Rust experiment launches the exact binary provisioned by setup", + experimentJob.includes( + "SAMCHON_GRAPH_RUST_ANALYZER_HIR: ${{ github.workspace }}/tests/experiment/.work/tools/bin/samchon-rust-analyzer", + ), + ); const indexTime = fs.readFileSync( path.join(directory, "index-time.yml"), "utf8", From 6d24b125a7488f052c109e8f155ebc757e302c63 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Tue, 4 Aug 2026 01:44:09 +0900 Subject: [PATCH 37/52] Accept exact shallow Rust commit abbreviations --- .../src/provider/rust/rustGraphProvider.ts | 9 +++++-- ...lient_restores_retries_and_fails_closed.ts | 24 +++++++++++++++++++ .../src/internal/fake-rust-graph-server.cjs | 5 +++- 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/packages/graph/src/provider/rust/rustGraphProvider.ts b/packages/graph/src/provider/rust/rustGraphProvider.ts index 45acc52b..90e43f2c 100644 --- a/packages/graph/src/provider/rust/rustGraphProvider.ts +++ b/packages/graph/src/provider/rust/rustGraphProvider.ts @@ -96,6 +96,11 @@ function hasPinnedVersion( windowsVerbatimArguments: invocation.windowsVerbatimArguments, }); if (result.status !== 0 || result.error !== undefined) return false; - const short = RUST_GRAPH_PRODUCER_COMMIT.slice(0, 9); - return new RegExp(`\\(${short}(?:\\s|\\))`, "u").test(result.stdout); + const reported = /\(([0-9a-f]{7,40})(?:\s|\))/u.exec( + result.stdout, + )?.[1]; + return ( + reported !== undefined && + RUST_GRAPH_PRODUCER_COMMIT.startsWith(reported) + ); } diff --git a/tests/test-graph/src/features/test_rust_hir_client_restores_retries_and_fails_closed.ts b/tests/test-graph/src/features/test_rust_hir_client_restores_retries_and_fails_closed.ts index e2f5d462..19bf9f8a 100644 --- a/tests/test-graph/src/features/test_rust_hir_client_restores_retries_and_fails_closed.ts +++ b/tests/test-graph/src/features/test_rust_hir_client_restores_retries_and_fails_closed.ts @@ -367,24 +367,48 @@ async function assertPublicCommitFence(root: string): Promise { async function assertPinnedResolution(root: string): Promise { const pinned = nodeShim(root, "pinned-rust-analyzer", RUST_GRAPH_PRODUCER_COMMIT); + const shallowPinned = nodeShim( + root, + "shallow-pinned-rust-analyzer", + RUST_GRAPH_PRODUCER_COMMIT, + ["--version-commit-length=7"], + ); + const tooShort = nodeShim( + root, + "too-short-rust-analyzer", + RUST_GRAPH_PRODUCER_COMMIT, + ["--version-commit-length=6"], + ); const wrong = nodeShim(root, "wrong-rust-analyzer", "0000000000000000000000000000000000000000"); const failing = nodeShim(root, "failing-rust-analyzer", RUST_GRAPH_PRODUCER_COMMIT, [ "--fail-version", ]); const override = "SAMCHON_GRAPH_RUST_ANALYZER_HIR"; const resolved = rustGraphProvider.resolve(root, { ...process.env, [override]: pinned }); + const shallowResolved = rustGraphProvider.resolve(root, { + ...process.env, + [override]: shallowPinned, + }); + const shortRejected = rustGraphProvider.resolve(root, { + ...process.env, + [override]: tooShort, + }); const rejected = rustGraphProvider.resolve(root, { ...process.env, [override]: wrong }); const failed = rustGraphProvider.resolve(root, { ...process.env, [override]: failing }); TestValidator.equals( "the HIR provider resolves only the exact disclosed producer commit", [ resolved !== undefined, + shallowResolved !== undefined, + shortRejected, rejected, failed, rustGraphProvider.configuration?.(root, { [override]: pinned }), ], [ true, + true, + undefined, undefined, undefined, [ diff --git a/tests/test-graph/src/internal/fake-rust-graph-server.cjs b/tests/test-graph/src/internal/fake-rust-graph-server.cjs index 2a6f8305..edf40a34 100644 --- a/tests/test-graph/src/internal/fake-rust-graph-server.cjs +++ b/tests/test-graph/src/internal/fake-rust-graph-server.cjs @@ -23,12 +23,15 @@ const configurationWithoutItems = args.includes("--configuration-without-items") const expectInitializationOptions = args.includes("--expect-initialization-options"); const initializeError = args.includes("--initialize-error"); const failVersion = args.includes("--fail-version"); +const versionCommitLength = Number(valueOf("--version-commit-length=") ?? 9); const conformance = args.includes("--conformance"); const conformanceHeuristic = args.includes("--conformance-heuristic"); if (args.includes("--version")) { if (failVersion) process.exit(7); - process.stdout.write(`rust-analyzer 1.95.0 (${commit.slice(0, 9)} 2026-08-01)\n`); + process.stdout.write( + `rust-analyzer 1.95.0 (${commit.slice(0, versionCommitLength)} 2026-08-01)\n`, + ); process.exit(0); } From 246d319b74feca2ed9cd87a2b538af5e480c50ef Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Tue, 4 Aug 2026 04:01:33 +0900 Subject: [PATCH 38/52] Open every changed feature case with its contract comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The development skill requires a case-opening doc comment on each new or modified test, and fourteen suites this cycle touches had none. Six are new: the Rust HIR client and adapter, both repository-context protocol and adapter suites, the resident repository-context source, and the MCP topology join. The filename already states the assertion, so each comment spends itself on the reason the case exists instead — the seam a reader cannot see from either side alone, the failure that leaves no trace in the result, or the invariant whose absence would look exactly like success. An earlier round closed three members of this class; this closes the rest. Comments only. No test behaviour, fixture, or assertion changed. --- .../test_application_exercises_every_request_branch.ts | 8 ++++++++ ...ct_fixture_covers_every_graph_node_and_edge_kind.ts | 8 ++++++++ ...h_dump_parser_closes_every_public_trust_boundary.ts | 10 ++++++++++ ...closes_servers_that_break_the_shutdown_handshake.ts | 9 +++++++++ .../test_mcp_resident_close_handler_settles_once.ts | 8 ++++++++ .../test_mcp_server_exposes_inspect_code_graph.ts | 8 ++++++++ ...cp_topology_fences_file_joins_by_code_generation.ts | 9 +++++++++ ...y_context_adapters_preserve_authoritative_models.ts | 9 +++++++++ ...epository_context_protocol_commits_atomic_shards.ts | 10 ++++++++++ ...ident_repository_context_is_atomic_and_retryable.ts | 10 ++++++++++ ...ust_hir_client_restores_retries_and_fails_closed.ts | 10 ++++++++++ ...est_rust_hir_snapshot_adapter_fences_generations.ts | 10 ++++++++++ ...pped_source_does_not_leak_benchmark_corpus_names.ts | 9 +++++++++ .../test_workflows_use_current_core_action_runtimes.ts | 7 +++++++ 14 files changed, 125 insertions(+) diff --git a/tests/test-graph/src/features/test_application_exercises_every_request_branch.ts b/tests/test-graph/src/features/test_application_exercises_every_request_branch.ts index ab5197bf..6ed41876 100644 --- a/tests/test-graph/src/features/test_application_exercises_every_request_branch.ts +++ b/tests/test-graph/src/features/test_application_exercises_every_request_branch.ts @@ -3,6 +3,14 @@ import { TestValidator } from "@nestia/e2e"; import { ContractGraph } from "../internal/ContractGraph"; import { GraphFixtures } from "../internal/GraphFixtures"; +/** + * The application's request switch is the one place where a new MCP member can + * be added to the union, compile, and never run: the type checker is satisfied + * by the union alone, and each arm's audit, trust envelope, and `next` decision + * are chosen independently of the others. This walks every discriminator, + * including the `topology` arm and the `escape` arm that must carry no trust + * envelope, so an unexercised branch fails here rather than at a caller. + */ export const test_application_exercises_every_request_branch = async () => { const app = ContractGraph.createApplication(); const requests = [ diff --git a/tests/test-graph/src/features/test_contract_fixture_covers_every_graph_node_and_edge_kind.ts b/tests/test-graph/src/features/test_contract_fixture_covers_every_graph_node_and_edge_kind.ts index 4e6835de..edf3017b 100644 --- a/tests/test-graph/src/features/test_contract_fixture_covers_every_graph_node_and_edge_kind.ts +++ b/tests/test-graph/src/features/test_contract_fixture_covers_every_graph_node_and_edge_kind.ts @@ -3,6 +3,14 @@ import { GRAPH_EDGE_KINDS, SamchonGraphMemory } from "@samchon/graph"; import { GraphFixtures } from "../internal/GraphFixtures"; +/** + * The contract fixture is the shared corpus every operation test reasons from, + * so a node or edge kind it happens not to contain is a kind nothing tests — + * silently, and more so after each new family is added. This is the mechanical + * completeness gate for that: the fixture must realize the whole public node + * union and the whole edge union, in the same deterministic protocol order the + * coverage matrix iterates. + */ export const test_contract_fixture_covers_every_graph_node_and_edge_kind = () => { const { dump } = GraphFixtures.createContractFixture(); const graph = SamchonGraphMemory.from(dump); diff --git a/tests/test-graph/src/features/test_graph_dump_parser_closes_every_public_trust_boundary.ts b/tests/test-graph/src/features/test_graph_dump_parser_closes_every_public_trust_boundary.ts index cfae02cb..d353d332 100644 --- a/tests/test-graph/src/features/test_graph_dump_parser_closes_every_public_trust_boundary.ts +++ b/tests/test-graph/src/features/test_graph_dump_parser_closes_every_public_trust_boundary.ts @@ -48,6 +48,16 @@ const valid = () => ({ }>, }); +/** + * `parseGraphDump` is the only gate between an untrusted dump file and every + * consumer that treats its facts as checked — the MCP server, the viewer, and + * the resident source all skip revalidation because this ran. A rule it fails + * to enforce is therefore not a parse error but a false claim downstream. This + * walks each boundary it owns with a negative twin: identity and path shape, + * edge endpoint closure, provenance uniqueness, exhaustive coverage per + * published slice, and unresolved sites bound to a partial row in their + * provider's own universe. + */ export const test_graph_dump_parser_closes_every_public_trust_boundary = async () => { const dump = valid(); diff --git a/tests/test-graph/src/features/test_lsp_client_closes_servers_that_break_the_shutdown_handshake.ts b/tests/test-graph/src/features/test_lsp_client_closes_servers_that_break_the_shutdown_handshake.ts index f672e1c6..98048c39 100644 --- a/tests/test-graph/src/features/test_lsp_client_closes_servers_that_break_the_shutdown_handshake.ts +++ b/tests/test-graph/src/features/test_lsp_client_closes_servers_that_break_the_shutdown_handshake.ts @@ -97,6 +97,15 @@ const importLib = (relative: string): Promise => pathToFileURL(path.join(GraphPaths.graphPackageRoot, "lib", relative)).href ) as Promise; +/** + * A language server that misbehaves during teardown leaves nothing behind in + * the graph, so no result-shaped assertion can notice it; the only evidence is + * a process that outlives the session. This pins the client's escalation for + * each way that happens — a server that answers `shutdown` and ignores `exit`, + * and one that never answers at all — plus the server-initiated request path, + * where a request left unanswered deadlocks servers that withhold their own + * replies until it is acknowledged. + */ export const test_lsp_client_closes_servers_that_break_the_shutdown_handshake = async () => { const { LspClient } = await importLib<{ diff --git a/tests/test-graph/src/features/test_mcp_resident_close_handler_settles_once.ts b/tests/test-graph/src/features/test_mcp_resident_close_handler_settles_once.ts index 61723ad9..94222d82 100644 --- a/tests/test-graph/src/features/test_mcp_resident_close_handler_settles_once.ts +++ b/tests/test-graph/src/features/test_mcp_resident_close_handler_settles_once.ts @@ -5,6 +5,14 @@ import { pathToFileURL } from "node:url"; import { GraphPaths } from "../internal/GraphPaths"; import { createCompositeResidentClose } from "../../../../packages/graph/src/mcp/createCompositeResidentClose"; +/** + * Shutdown arrives twice — the transport closing and stdin ending are separate + * events — and both reach the same handler. Closing twice would either kill a + * resident mid-teardown or double-report the same failure, and neither shows up + * in a single-path test. This pins one shared shutdown promise, one close, one + * contained report, and the composite that now fronts several resident planes + * closing each of them in order while retaining the first failure. + */ export const test_mcp_resident_close_handler_settles_once = async () => { const module = (await import( pathToFileURL( diff --git a/tests/test-graph/src/features/test_mcp_server_exposes_inspect_code_graph.ts b/tests/test-graph/src/features/test_mcp_server_exposes_inspect_code_graph.ts index 8dd0da1d..aa6ad121 100644 --- a/tests/test-graph/src/features/test_mcp_server_exposes_inspect_code_graph.ts +++ b/tests/test-graph/src/features/test_mcp_server_exposes_inspect_code_graph.ts @@ -72,6 +72,14 @@ const overview = async (args: string[]) => { } }; +/** + * Everything else about the graph is tested through the TypeScript API, which + * cannot see the one boundary an agent actually uses: a spawned process, one + * registered tool, and a structured result over stdio. This drives that + * boundary end to end, including the pre-built graph-file lane that must serve + * without re-indexing and the cold `escape` request that must answer without + * building a graph at all. + */ export const test_mcp_server_exposes_inspect_code_graph = async () => { const root = GraphFixtures.createOrderFixture(); const parsed = await overview(["--mode", "static", "--cwd", root]); diff --git a/tests/test-graph/src/features/test_mcp_topology_fences_file_joins_by_code_generation.ts b/tests/test-graph/src/features/test_mcp_topology_fences_file_joins_by_code_generation.ts index 9a70ad9e..4f41463b 100644 --- a/tests/test-graph/src/features/test_mcp_topology_fences_file_joins_by_code_generation.ts +++ b/tests/test-graph/src/features/test_mcp_topology_fences_file_joins_by_code_generation.ts @@ -14,6 +14,15 @@ import { GraphFixtures } from "../internal/GraphFixtures"; const { repositoryContextCoverage, repositoryContextId } = repositoryContextFacts; +/** + * A `joins-file` edge is the one place the two planes touch, and it is only + * true of one pair of generations. If the code generation moves while topology + * is loading, the join still looks well-formed — both endpoints exist — so + * nothing in either plane's own validation can reject it. This pins the fence + * that can: joins are admitted only when the code input generation is stable + * across the topology load, and are otherwise withheld with an explicit + * `unavailable` reason rather than returned as ordinary facts. + */ export const test_mcp_topology_fences_file_joins_by_code_generation = async () => { const fixture = GraphFixtures.createContractFixture(); diff --git a/tests/test-graph/src/features/test_repository_context_adapters_preserve_authoritative_models.ts b/tests/test-graph/src/features/test_repository_context_adapters_preserve_authoritative_models.ts index 8add7c76..ca6abc8c 100644 --- a/tests/test-graph/src/features/test_repository_context_adapters_preserve_authoritative_models.ts +++ b/tests/test-graph/src/features/test_repository_context_adapters_preserve_authoritative_models.ts @@ -13,6 +13,15 @@ import path from "node:path"; import { GraphPaths } from "../internal/GraphPaths"; import { parseGradleRepositoryContextModel } from "../../../../packages/graph/src/repository/parseGradleRepositoryContextModel"; +/** + * Each adapter reads a different owning tool, and the tempting failure is the + * same in all four: when the model is missing, stale, or refuses to answer, + * reconstruct the topology from directory layout and publish it as though the + * tool had said it. This pins the opposite behaviour per ecosystem — detection + * keyed to the owning manifest rather than to any repository that happens to + * contain a folder, declared versus tool-resolved authority kept distinct, and + * an unavailable model becoming unsupported coverage instead of a guess. + */ export const test_repository_context_adapters_preserve_authoritative_models = async () => { const root = GraphPaths.createTempDirectory( diff --git a/tests/test-graph/src/features/test_repository_context_protocol_commits_atomic_shards.ts b/tests/test-graph/src/features/test_repository_context_protocol_commits_atomic_shards.ts index 54ce92e0..73f6fc10 100644 --- a/tests/test-graph/src/features/test_repository_context_protocol_commits_atomic_shards.ts +++ b/tests/test-graph/src/features/test_repository_context_protocol_commits_atomic_shards.ts @@ -7,6 +7,16 @@ import { const { repositoryContextCoverage, repositoryContextId } = repositoryContextFacts; +/** + * The repository plane is a second protocol with the same atomicity promise as + * the code one, and nothing in the code protocol's tests reaches it. This pins + * the boundary that keeps the two planes from drifting apart: a published + * topology generation is frozen, a delta must extend exactly the current + * generation, coverage stays exhaustive over every relation family, `joins-file` + * is the one relation whose target is a file rather than a node, and version 1 + * refuses `inferred` authority outright rather than publishing a guessed build + * fact beside a tool-resolved one. + */ export const test_repository_context_protocol_commits_atomic_shards = async () => { const store = new RepositoryContextProtocol.Store(); diff --git a/tests/test-graph/src/features/test_resident_repository_context_is_atomic_and_retryable.ts b/tests/test-graph/src/features/test_resident_repository_context_is_atomic_and_retryable.ts index cdfb46fd..1dfb19ec 100644 --- a/tests/test-graph/src/features/test_resident_repository_context_is_atomic_and_retryable.ts +++ b/tests/test-graph/src/features/test_resident_repository_context_is_atomic_and_retryable.ts @@ -19,6 +19,16 @@ const { repositoryContextSource, } = repositoryContextFacts; +/** + * A repository model is expensive enough that the resident source is expected + * to reuse it, and that expectation is exactly what makes a failed provider + * dangerous: reusing the last good model after the build files moved would + * serve a topology no checkout has. This pins the input fence around that reuse + * — an unchanged input generation reuses the snapshot, a moved one recollects, + * a provider that throws contributes unsupported coverage and a warning instead + * of removing the plane, and a later success recovers without the failure + * leaving residue in the merged generation. + */ export const test_resident_repository_context_is_atomic_and_retryable = async () => { const root = GraphPaths.createTempDirectory( diff --git a/tests/test-graph/src/features/test_rust_hir_client_restores_retries_and_fails_closed.ts b/tests/test-graph/src/features/test_rust_hir_client_restores_retries_and_fails_closed.ts index 19bf9f8a..cd09ed89 100644 --- a/tests/test-graph/src/features/test_rust_hir_client_restores_retries_and_fails_closed.ts +++ b/tests/test-graph/src/features/test_rust_hir_client_restores_retries_and_fails_closed.ts @@ -10,6 +10,16 @@ import path from "node:path"; import { GraphPaths } from "../internal/GraphPaths.js"; +/** + * A resident producer answers before it is ready and restarts underneath a live + * session, and neither condition is an error the caller may see as a fallback. + * This pins the client's side of that: a cancelled or content-modified response + * is retried until the ready deadline rather than published, a rejected restart + * checkpoint discards the persisted generation instead of reusing it, an + * unpersistable checkpoint degrades to a warning while the validated snapshot + * stays resident, and a refused commit leaves the previous good generation + * exactly where it was. + */ export const test_rust_hir_client_restores_retries_and_fails_closed = async () => { const root = GraphPaths.createTempDirectory("samchon-graph-rust-client-"); const cacheRoot = GraphPaths.createTempDirectory("samchon-graph-rust-checkpoints-"); diff --git a/tests/test-graph/src/features/test_rust_hir_snapshot_adapter_fences_generations.ts b/tests/test-graph/src/features/test_rust_hir_snapshot_adapter_fences_generations.ts index 7d061b47..865494f5 100644 --- a/tests/test-graph/src/features/test_rust_hir_snapshot_adapter_fences_generations.ts +++ b/tests/test-graph/src/features/test_rust_hir_snapshot_adapter_fences_generations.ts @@ -17,6 +17,16 @@ import { GraphPaths } from "../internal/GraphPaths.js"; const COMMIT = RUST_GRAPH_PRODUCER_COMMIT; +/** + * The adapter is the only place a producer-owned HIR generation becomes a + * public one, so every fence it applies is invisible from either side alone: + * the producer cannot see the prior committed generation, and the common store + * cannot see the raw shard digests the producer signed. This pins that seam — + * a delta on a stale base, an unchanged generation that lost its base, a raw + * manifest or generation digest the producer contradicts, and a restart + * checkpoint whose normalized frames are trusted only after the live producer + * revalidates its raw HIR facts. + */ export const test_rust_hir_snapshot_adapter_fences_generations = () => { const root = GraphPaths.createTempDirectory("samchon-graph-rust-adapter-"); fs.mkdirSync(path.join(root, "src")); diff --git a/tests/test-graph/src/features/test_shipped_source_does_not_leak_benchmark_corpus_names.ts b/tests/test-graph/src/features/test_shipped_source_does_not_leak_benchmark_corpus_names.ts index cc15ef60..39b7ca40 100644 --- a/tests/test-graph/src/features/test_shipped_source_does_not_leak_benchmark_corpus_names.ts +++ b/tests/test-graph/src/features/test_shipped_source_does_not_leak_benchmark_corpus_names.ts @@ -21,6 +21,15 @@ const CORPUS_NAMES = [ "darthttp", ] as const; +/** + * Every corpus repository below has, at some point, explained a real bug in a + * source comment — a package that imports its own `testdata`, a package with + * several `func init()`. That is exactly how a fixture name becomes product + * behaviour: the next reader treats the named project as the specification and + * special-cases it. This pins the prohibition mechanically over the shipped + * source and sidecars, so a corpus name cannot reach a published artifact even + * as prose. + */ export const test_shipped_source_does_not_leak_benchmark_corpus_names = () => { const roots = [ path.join(GraphPaths.graphPackageRoot, "src"), diff --git a/tests/test-graph/src/features/test_workflows_use_current_core_action_runtimes.ts b/tests/test-graph/src/features/test_workflows_use_current_core_action_runtimes.ts index 59dd07fb..57b5acca 100644 --- a/tests/test-graph/src/features/test_workflows_use_current_core_action_runtimes.ts +++ b/tests/test-graph/src/features/test_workflows_use_current_core_action_runtimes.ts @@ -27,6 +27,13 @@ const MAINTAINED: Record = { "download-artifact": 8, }; +/** + * A deprecated `actions/*` major keeps working long after GitHub announces its + * retirement, so the workflows stay green right up to the day every lane goes + * red at once — and nothing in the product suite reads them. This pins the + * pinned majors across every workflow file, and reads the directory itself so + * a renamed or newly added workflow cannot opt out by not being listed. + */ export const test_workflows_use_current_core_action_runtimes = () => { const directory = path.join(GraphPaths.repositoryRoot, ".github", "workflows"); const files = fs From 53e70f120c745b0ee64a37898025ac468fffa34c Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Tue, 4 Aug 2026 04:01:49 +0900 Subject: [PATCH 39/52] Build the pinned Clang producer with the whole runner The C and C++ experiment lanes were killed at the job timeout on 6d24b12, and the log says why rather than leaving it to inference: the LLVM build started at ninja step 1 of 3,125 and had reached 2,431 when the runner cancelled it 85 minutes later. That is 78 percent of the build, extrapolating to roughly 109 minutes, against a bound of 90 that also has to cover nine minutes of setup and the real-corpus lifecycle run afterwards. The cause was `--parallel 2`: a bare literal in a file that comments every other deliberate constraint, leaving half of a four-core hosted runner idle. Nothing required the build serialized. `clangd` is the single linked target and the log records no allocation failure, no OOM kill and no memory warning, so the constant bought nothing but wall clock. Sizing the build by os.availableParallelism() lets it use the machine it is actually running on. Widening the job bound for these two rows was tried first and rejected: the workflow contract holds every language to one hang boundary precisely because the previous 45-to-90 raise preserved this same serialization instead of bounding it. The boundary is unchanged; only the cause is. --- tests/experiment/src/setup-language.mjs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/experiment/src/setup-language.mjs b/tests/experiment/src/setup-language.mjs index 64a85973..0c856ee3 100644 --- a/tests/experiment/src/setup-language.mjs +++ b/tests/experiment/src/setup-language.mjs @@ -453,11 +453,18 @@ const installClangGraphProducer = () => { `-DLLVM_FORCE_VC_REVISION=${experiment.producerCommit}`, `-DLLVM_FORCE_VC_REPOSITORY=${experiment.producerRepository}`, ]); + // Build with the machine, not with a number. A fixed `2` left half of a + // four-core hosted runner idle and turned a ~55-minute build into a + // ~109-minute one, which is how the C and C++ lanes reached 78 percent of + // 3,125 steps and were killed at the job timeout. Nothing here needs the + // build serialized: the run carries no memory pressure — `clangd` is the one + // linked target and the log recorded no allocation failure — so the only + // thing the constant bought was a longer wall clock. run("cmake", [ "--build", build, "--parallel", - "2", + String(os.availableParallelism()), "--target", "clangd", ]); From edecb8566920f7ded15e3b67380c1a7c27893f95 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Tue, 4 Aug 2026 04:53:06 +0900 Subject: [PATCH 40/52] Correct every contract comment to what its case actually pins The commit early-warning pass on 246d319 read each new comment against the body it sits above and found eight that described behaviour their case does not have. Every one reproduced: - the MCP server case was said to drive a cold `escape` request; `escape` occurred once in the file, inside the comment; - the contract fixture was said to realize the whole edge union in protocol order; stored kinds exclude the traversal-only ones and both sides are sorted; - the repository-context adapters were said to turn an unavailable model into unsupported coverage; every such path throws, and `unsupported` occurred only in the comment; - the corpus-name guard was said to keep a name out of any published artifact; the published README names all thirteen deliberately, and the walk does not read `.java`; - the LSP teardown case was said to escalate for a server that never answers; that server answers `initialize`, and its assertion is that no escalation happens; - the Rust client was said to retain a previous good generation across a refusal; both refusals run on fresh clients and assert nothing is resident; - the workflow case repeated the rationale already stated above `MAINTAINED`; - the request-branch case was said to catch an arm nothing runs, but both lists it compares are hand-maintained, so the branch-coverage gate is that guard. Writing a comment that is merely evocative is worse than writing none: the next reader has no reason to doubt it and every reason to reason from it. Each one now states only what its case establishes, verified against the assertions. The pass also found `joins-file` to be the one boundary the repository-context protocol comment claimed without a negative twin. Rather than weaken the comment, the twin is added: a join naming a file the shard never declared is now rejected, which is the endpoint the ordinary node-closure check cannot cover. --- ...lication_exercises_every_request_branch.ts | 18 +++++++++----- ...e_covers_every_graph_node_and_edge_kind.ts | 8 ++++--- ...rvers_that_break_the_shutdown_handshake.ts | 24 ++++++++++++++----- ...t_mcp_server_exposes_inspect_code_graph.ts | 10 ++++---- ..._adapters_preserve_authoritative_models.ts | 12 ++++++---- ..._context_protocol_commits_atomic_shards.ts | 11 +++++++++ ...lient_restores_retries_and_fails_closed.ts | 13 ++++++---- ...ce_does_not_leak_benchmark_corpus_names.ts | 17 +++++++------ ...kflows_use_current_core_action_runtimes.ts | 13 ++++++---- 9 files changed, 87 insertions(+), 39 deletions(-) diff --git a/tests/test-graph/src/features/test_application_exercises_every_request_branch.ts b/tests/test-graph/src/features/test_application_exercises_every_request_branch.ts index 6ed41876..3a8e8414 100644 --- a/tests/test-graph/src/features/test_application_exercises_every_request_branch.ts +++ b/tests/test-graph/src/features/test_application_exercises_every_request_branch.ts @@ -4,12 +4,18 @@ import { ContractGraph } from "../internal/ContractGraph"; import { GraphFixtures } from "../internal/GraphFixtures"; /** - * The application's request switch is the one place where a new MCP member can - * be added to the union, compile, and never run: the type checker is satisfied - * by the union alone, and each arm's audit, trust envelope, and `next` decision - * are chosen independently of the others. This walks every discriminator, - * including the `topology` arm and the `escape` arm that must carry no trust - * envelope, so an unexercised branch fails here rather than at a caller. + * Every request member is driven through the real application and its result + * discriminator is compared, in order, against the advertised request list. + * The property that buys is narrow but not otherwise held anywhere: no arm may + * answer as another arm. Each one selects its own result union member, and a + * mis-wired `switch` that returned an overview for a trace would satisfy the + * type checker, every per-operation suite that only calls its own request, and + * the coverage gate that only asks whether the line ran. + * + * This is not the guard against an unexercised arm. Both the driven list here + * and {@link GraphFixtures.GRAPH_REQUEST_TYPES} are hand-maintained, so a new + * union member reaches neither by itself; the 100 percent branch-coverage gate + * is what refuses an arm nothing runs. */ export const test_application_exercises_every_request_branch = async () => { const app = ContractGraph.createApplication(); diff --git a/tests/test-graph/src/features/test_contract_fixture_covers_every_graph_node_and_edge_kind.ts b/tests/test-graph/src/features/test_contract_fixture_covers_every_graph_node_and_edge_kind.ts index edf3017b..c8114d4a 100644 --- a/tests/test-graph/src/features/test_contract_fixture_covers_every_graph_node_and_edge_kind.ts +++ b/tests/test-graph/src/features/test_contract_fixture_covers_every_graph_node_and_edge_kind.ts @@ -7,9 +7,11 @@ import { GraphFixtures } from "../internal/GraphFixtures"; * The contract fixture is the shared corpus every operation test reasons from, * so a node or edge kind it happens not to contain is a kind nothing tests — * silently, and more so after each new family is added. This is the mechanical - * completeness gate for that: the fixture must realize the whole public node - * union and the whole edge union, in the same deterministic protocol order the - * coverage matrix iterates. + * completeness gate for that: every public node kind is realized, and every + * edge kind an index can store is realized. The split matters as much as the + * count. Traversal-only kinds are asserted to be stored by nothing, so the + * fixture cannot quietly satisfy this gate by persisting a derived edge that + * the graph is supposed to compute rather than keep. */ export const test_contract_fixture_covers_every_graph_node_and_edge_kind = () => { const { dump } = GraphFixtures.createContractFixture(); diff --git a/tests/test-graph/src/features/test_lsp_client_closes_servers_that_break_the_shutdown_handshake.ts b/tests/test-graph/src/features/test_lsp_client_closes_servers_that_break_the_shutdown_handshake.ts index 98048c39..ca1cf25a 100644 --- a/tests/test-graph/src/features/test_lsp_client_closes_servers_that_break_the_shutdown_handshake.ts +++ b/tests/test-graph/src/features/test_lsp_client_closes_servers_that_break_the_shutdown_handshake.ts @@ -99,12 +99,24 @@ const importLib = (relative: string): Promise => /** * A language server that misbehaves during teardown leaves nothing behind in - * the graph, so no result-shaped assertion can notice it; the only evidence is - * a process that outlives the session. This pins the client's escalation for - * each way that happens — a server that answers `shutdown` and ignores `exit`, - * and one that never answers at all — plus the server-initiated request path, - * where a request left unanswered deadlocks servers that withhold their own - * replies until it is acknowledged. + * the graph, so no result-shaped assertion can notice it; the evidence is a + * process that outlives its session, or a second of wall clock nobody can + * account for. + * + * The two servers here break the handshake in opposite directions, and the + * correct response to each is the opposite of the other. One acknowledges + * `shutdown` and then ignores `exit`, so the client must escalate: wait, kill, + * and reject the in-flight request with the signal it had to send rather than + * leaving it pending forever. The other exits on `shutdown` instead of + * replying, so the client must *not* escalate — it is already gone, and + * waiting out its grace period would cost every teardown a full second for + * nothing. A client that only handled the first would pass a test that only + * asked about leaks. + * + * The case continues past teardown into the rest of the client's process and + * transport surface, including the server-initiated request path, where a + * request left unanswered deadlocks servers that withhold their own replies + * until it is acknowledged. */ export const test_lsp_client_closes_servers_that_break_the_shutdown_handshake = async () => { diff --git a/tests/test-graph/src/features/test_mcp_server_exposes_inspect_code_graph.ts b/tests/test-graph/src/features/test_mcp_server_exposes_inspect_code_graph.ts index aa6ad121..22324605 100644 --- a/tests/test-graph/src/features/test_mcp_server_exposes_inspect_code_graph.ts +++ b/tests/test-graph/src/features/test_mcp_server_exposes_inspect_code_graph.ts @@ -75,10 +75,12 @@ const overview = async (args: string[]) => { /** * Everything else about the graph is tested through the TypeScript API, which * cannot see the one boundary an agent actually uses: a spawned process, one - * registered tool, and a structured result over stdio. This drives that - * boundary end to end, including the pre-built graph-file lane that must serve - * without re-indexing and the cold `escape` request that must answer without - * building a graph at all. + * registered tool, and a structured result arriving over stdio. This drives + * that boundary end to end. The second half is the reason it runs twice — a + * `--graph-file` server must answer from the dump the benchmark pre-warms it + * with, and it is held to the same node count as the lane that indexed the + * project itself, so serving a stale or partial graph is not a smaller answer + * but a failure. */ export const test_mcp_server_exposes_inspect_code_graph = async () => { const root = GraphFixtures.createOrderFixture(); diff --git a/tests/test-graph/src/features/test_repository_context_adapters_preserve_authoritative_models.ts b/tests/test-graph/src/features/test_repository_context_adapters_preserve_authoritative_models.ts index ca6abc8c..73b78e89 100644 --- a/tests/test-graph/src/features/test_repository_context_adapters_preserve_authoritative_models.ts +++ b/tests/test-graph/src/features/test_repository_context_adapters_preserve_authoritative_models.ts @@ -17,10 +17,14 @@ import { parseGradleRepositoryContextModel } from "../../../../packages/graph/sr * Each adapter reads a different owning tool, and the tempting failure is the * same in all four: when the model is missing, stale, or refuses to answer, * reconstruct the topology from directory layout and publish it as though the - * tool had said it. This pins the opposite behaviour per ecosystem — detection - * keyed to the owning manifest rather than to any repository that happens to - * contain a folder, declared versus tool-resolved authority kept distinct, and - * an unavailable model becoming unsupported coverage instead of a guess. + * tool had said it. This pins the opposite behaviour per ecosystem. Detection + * is keyed to the owning manifest rather than to any repository that happens + * to contain a folder; `declared` and `tool-resolved` authority stay distinct + * per node and edge; an absent Tooling API classpath, a failed tool, malformed + * JSON, a stale CMake reply and a missing File API query each make the adapter + * throw rather than answer; and a Gradle module name that resolves ambiguously + * degrades `depends-on` to partial coverage with a warning instead of emitting + * the edge it cannot prove. */ export const test_repository_context_adapters_preserve_authoritative_models = async () => { diff --git a/tests/test-graph/src/features/test_repository_context_protocol_commits_atomic_shards.ts b/tests/test-graph/src/features/test_repository_context_protocol_commits_atomic_shards.ts index 73f6fc10..4f68b87c 100644 --- a/tests/test-graph/src/features/test_repository_context_protocol_commits_atomic_shards.ts +++ b/tests/test-graph/src/features/test_repository_context_protocol_commits_atomic_shards.ts @@ -241,6 +241,17 @@ export const test_repository_context_protocol_commits_atomic_shards = const upsert = changedUpsert(frames); upsert.shard.edges[0]!.from = ""; }), + // `joins-file` is the one relation whose target is a file identity rather + // than a node identity, so it is the one endpoint the ordinary node + // closure check cannot cover. A join to a file the shard never declared + // is how the topology plane would start naming code the code generation + // has no record of. + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const upsert = changedUpsert(frames); + upsert.shard.edges.find((edge) => edge.kind === "joins-file")!.to = + "src/undeclared.ts"; + refresh(frames); + }), mutate(transaction(3, unchanged, changedShard()), (frames) => { const upsert = changedUpsert(frames); upsert.shard.edges.push(structuredClone(upsert.shard.edges[0]!)); diff --git a/tests/test-graph/src/features/test_rust_hir_client_restores_retries_and_fails_closed.ts b/tests/test-graph/src/features/test_rust_hir_client_restores_retries_and_fails_closed.ts index cd09ed89..6ea180fb 100644 --- a/tests/test-graph/src/features/test_rust_hir_client_restores_retries_and_fails_closed.ts +++ b/tests/test-graph/src/features/test_rust_hir_client_restores_retries_and_fails_closed.ts @@ -14,11 +14,16 @@ import { GraphPaths } from "../internal/GraphPaths.js"; * A resident producer answers before it is ready and restarts underneath a live * session, and neither condition is an error the caller may see as a fallback. * This pins the client's side of that: a cancelled or content-modified response - * is retried until the ready deadline rather than published, a rejected restart - * checkpoint discards the persisted generation instead of reusing it, an + * is retried until the ready deadline rather than published, a no-op returns + * the exact resident object rather than an equal copy, a rejected restart + * checkpoint discards the persisted generation instead of reusing it, and an * unpersistable checkpoint degrades to a warning while the validated snapshot - * stays resident, and a refused commit leaves the previous good generation - * exactly where it was. + * stays resident. + * + * The refusal cases assert the strictest available form of atomicity, which is + * why they run on fresh clients: a producer response the adapter rejects, and + * a generation the product validator refuses, must each leave `current` + * undefined. Nothing partially applied, not merely nothing published. */ export const test_rust_hir_client_restores_retries_and_fails_closed = async () => { const root = GraphPaths.createTempDirectory("samchon-graph-rust-client-"); diff --git a/tests/test-graph/src/features/test_shipped_source_does_not_leak_benchmark_corpus_names.ts b/tests/test-graph/src/features/test_shipped_source_does_not_leak_benchmark_corpus_names.ts index 39b7ca40..1fa03bac 100644 --- a/tests/test-graph/src/features/test_shipped_source_does_not_leak_benchmark_corpus_names.ts +++ b/tests/test-graph/src/features/test_shipped_source_does_not_leak_benchmark_corpus_names.ts @@ -22,13 +22,16 @@ const CORPUS_NAMES = [ ] as const; /** - * Every corpus repository below has, at some point, explained a real bug in a - * source comment — a package that imports its own `testdata`, a package with - * several `func init()`. That is exactly how a fixture name becomes product - * behaviour: the next reader treats the named project as the specification and - * special-cases it. This pins the prohibition mechanically over the shipped - * source and sidecars, so a corpus name cannot reach a published artifact even - * as prose. + * A corpus name in product source is how a fixture becomes a specification: + * the next reader treats the named project as the thing the code must satisfy + * and special-cases it. The Go sidecar carried exactly that shape until this + * campaign — two comments explaining real defects by naming the corpus that + * exhibited them — and both were rewritten to describe the condition instead. + * + * This is the mechanical guard for the class. Its reach is deliberate rather + * than total: it walks `src` and `sidecars` for the text extensions the + * product ships as code, so the README's benchmark tables — which name every + * corpus on purpose, as published measurement evidence — stay outside it. */ export const test_shipped_source_does_not_leak_benchmark_corpus_names = () => { const roots = [ diff --git a/tests/test-graph/src/features/test_workflows_use_current_core_action_runtimes.ts b/tests/test-graph/src/features/test_workflows_use_current_core_action_runtimes.ts index 57b5acca..d066fd29 100644 --- a/tests/test-graph/src/features/test_workflows_use_current_core_action_runtimes.ts +++ b/tests/test-graph/src/features/test_workflows_use_current_core_action_runtimes.ts @@ -28,11 +28,14 @@ const MAINTAINED: Record = { }; /** - * A deprecated `actions/*` major keeps working long after GitHub announces its - * retirement, so the workflows stay green right up to the day every lane goes - * red at once — and nothing in the product suite reads them. This pins the - * pinned majors across every workflow file, and reads the directory itself so - * a renamed or newly added workflow cannot opt out by not being listed. + * Nothing else in the suite reads a workflow, so every claim CI makes about + * itself is unchecked by default: a retired action major, a release lane that + * publishes before it audits, a producer pin that drifts, a hang boundary + * quietly moved off the matrix job, or a classifier that fails open and skips + * the matrix on the very heads it was meant to cover. Each of those stays + * green until the day it matters. This reads the workflow and release-script + * sources directly and holds them to {@link MAINTAINED} and to the ordering + * and scoping each lane depends on. */ export const test_workflows_use_current_core_action_runtimes = () => { const directory = path.join(GraphPaths.repositoryRoot, ".github", "workflows"); From f4b699527c4e356086d92e0074dfc31aeed60116 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Tue, 4 Aug 2026 04:53:33 +0900 Subject: [PATCH 41/52] Bound the producer build by memory and stop overclaiming its evidence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two corrections the commit early-warning pass on 53e70f1 earned, both about the difference between what the failed run measured and what the repair asserted. The comment stated a ~55-minute and a ~109-minute build as fact. Neither was observed: the build never finished at any parallelism, and the documentation skill asks that measured evidence be distinguished from extrapolation. What was measured is 2,431 of 3,125 steps in 85 minutes with half the runner idle, and that is now all the comment claims. The comment also read the absence of allocation failures in that log as proof of memory headroom. `Linking CXX executable bin/clangd` appears zero times in it — the job was cancelled before the link, which is exactly where an LLVM build peaks — so the log is silent rather than reassuring. Parallelism is now bounded by total memory as well as by core count, at LLVM's own rule of thumb of two GiB per compile job. On the hosted runner the memory bound is inert and the core bound decides; on the many-core, modest-memory workstation the language-support skill documents for local `setup`, it is the one that keeps an uncapped job count from becoming an out-of-memory kill. The decision is pinned rather than left to the next edit. A fixed literal here already cost two whole CI lanes, and the workflow deliberately refuses to widen the timeout for one language, so the size of this build is what has to stay correct. --- tests/experiment/src/setup-language.mjs | 31 ++++++++++++++----- ...st_experiment_corpora_are_commit_pinned.ts | 16 ++++++++++ 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/tests/experiment/src/setup-language.mjs b/tests/experiment/src/setup-language.mjs index 0c856ee3..082e1ffc 100644 --- a/tests/experiment/src/setup-language.mjs +++ b/tests/experiment/src/setup-language.mjs @@ -453,18 +453,33 @@ const installClangGraphProducer = () => { `-DLLVM_FORCE_VC_REVISION=${experiment.producerCommit}`, `-DLLVM_FORCE_VC_REPOSITORY=${experiment.producerRepository}`, ]); - // Build with the machine, not with a number. A fixed `2` left half of a - // four-core hosted runner idle and turned a ~55-minute build into a - // ~109-minute one, which is how the C and C++ lanes reached 78 percent of - // 3,125 steps and were killed at the job timeout. Nothing here needs the - // build serialized: the run carries no memory pressure — `clangd` is the one - // linked target and the log recorded no allocation failure — so the only - // thing the constant bought was a longer wall clock. + // Build with the machine, not with a number. What was measured: a fixed `2` + // on a four-vCPU hosted runner reached step 2,431 of 3,125 in 85 minutes and + // was then killed at the job timeout. How much of the remaining gap width + // recovers is not measured and should not be written down as though it were; + // what is certain is only that half the runner sat idle for all 85 of those + // minutes. + // + // Capped by memory as well as by cores, because those are different limits + // and only one of them is visible here. The cancelled run proves nothing + // about the second: it stopped before `clangd` was linked, which is exactly + // where an LLVM build peaks, so its silence about allocation failure is + // absence of evidence rather than evidence of headroom. Two GiB per job is + // LLVM's own rule of thumb for compiling, and it keeps a many-core, modest + // memory workstation — the local `setup` path the language-support skill + // documents — from turning this into an out-of-memory kill. + const jobs = Math.max( + 1, + Math.min( + os.availableParallelism(), + Math.floor(os.totalmem() / (2 * 1024 * 1024 * 1024)), + ), + ); run("cmake", [ "--build", build, "--parallel", - String(os.availableParallelism()), + String(jobs), "--target", "clangd", ]); diff --git a/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts b/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts index e0498d57..eeaad845 100644 --- a/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts +++ b/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts @@ -224,6 +224,22 @@ export const test_experiment_corpora_are_commit_pinned = () => { setup.includes('tool: "samchon-clangd"') && !cppSetup.includes('apt(["clangd"'), ); + // A fixed parallelism here already cost two whole CI lanes: the build ran on + // half a four-vCPU runner and was killed at the job timeout with 694 of + // 3,125 steps left. The workflow refuses to widen that timeout for one + // language, so the size of this build is the thing that has to stay correct, + // and a literal is exactly how it silently stops being correct again. Pin + // both halves: sized by the machine, and bounded by its memory rather than + // by its core count alone, since the run that failed stopped before `clangd` + // was linked and therefore proved nothing about the memory peak. + TestValidator.predicate( + "the native Clang build is sized by the machine and bounded by its memory", + setup.includes("os.availableParallelism()") && + setup.includes("os.totalmem()") && + setup.includes('"--parallel",') && + setup.includes("String(jobs),") && + !/"--parallel",\s*\n\s*"\d+"/u.test(setup), + ); // scip-python 0.6.6 recovers from a malformed `pyproject.toml`, falls back to // Pyright defaults and emits no SCIP diagnostics. On the pinned Click // fixture, the source and semantic fact planes stay unchanged. The aggregate From 748b00f1c839c0f7430160de95b666136b2e6c90 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Tue, 4 Aug 2026 10:51:54 +0900 Subject: [PATCH 42/52] Restore the pinned Clang producer instead of rebuilding it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widening the runner did not make the lane fit, and the second run says why. The same 2,431 of 3,125 steps took 85.1 minutes at two jobs and 81.2 at four: a 4.8 percent gain for twice the job count, because the runner's four vCPUs are two physical cores. Roughly 110 minutes is this build's floor and no job count moves it. Serialization was real waste and worth removing, but it was never the constraint, so this is the second patch aimed at one failure — which is where the owning design has to be repaired rather than patched again. The design is that two matrix rows rebuild an identical pinned commit from scratch on every push, about two CPU-hours per workflow to reproduce bytes that cannot have changed. They are restored now, keyed by the producer commit and the build recipe so a bump or a recipe edit misses by construction. A restored install is still untrusted input and is admitted only by the evidence a fresh build must also produce: both installed names report the pinned commit and the resource-header tree resolves exactly once. A missing file, an unreadable tree, an unexpected version or any thrown error falls back to building, because reuse is an optimisation and may only be taken on complete evidence. The job bound moves to 150 minutes for exactly the two rows that build a compiler and stays at 90 for the other fourteen. The contract that refused this refused it for a cause that measurement has now retired; it did not refuse exceptions as such, it refused preserving a defect by paying for it. Building a compiler is a property of those rows rather than a defect inside them, and after the restore above the ordinary push never reaches the wider bound at all. The assertion is now the exact expression rather than a single number, so a third language cannot join it and a different number cannot appear without editing that line and answering for it. `actions/cache` is added to the maintained-major map. It was written from memory two majors behind and nothing caught it, because a map that lists only the actions it was born with has the same hole this file already documents one level up. --- .github/workflows/experiment.yml | 28 ++++++- tests/experiment/src/setup-language.mjs | 79 +++++++++++++++++-- ...st_experiment_corpora_are_commit_pinned.ts | 22 ++++++ ...kflows_use_current_core_action_runtimes.ts | 44 +++++++++-- 4 files changed, 160 insertions(+), 13 deletions(-) diff --git a/.github/workflows/experiment.yml b/.github/workflows/experiment.yml index fcf38537..7f4f33dc 100644 --- a/.github/workflows/experiment.yml +++ b/.github/workflows/experiment.yml @@ -74,7 +74,19 @@ jobs: if: ${{ needs.latest_update.outputs.run == 'true' }} name: ${{ matrix.language }} LSP runs-on: ubuntu-latest - timeout-minutes: 90 + # Fourteen rows install a released producer and finish in minutes; the + # ninety-minute bound is theirs and stays exactly where it is. C and C++ + # build a compiler from source, and that is a property of the row rather + # than a defect inside it. The bound was first raised from forty-five on + # the theory that the build was serialized, which was the wrong reason and + # is why raising it did not work; the measurement is that the same 2,431 of + # 3,125 steps take 85.1 minutes at two jobs and 81.2 at four, because four + # vCPUs are two physical cores. Roughly 110 minutes is the floor, no job + # count moves it, and a cold build plus its corpus run needs more than + # ninety. The wider bound is granted only to the two rows that build, and + # only on the run that has to build: the cache above means the ordinary + # push restores the binary and never reaches it. + timeout-minutes: ${{ (matrix.language == 'c' || matrix.language == 'cpp') && 150 || 90 }} strategy: fail-fast: false matrix: @@ -138,6 +150,20 @@ jobs: if: ${{ github.event_name != 'workflow_dispatch' || inputs.language == 'all' || inputs.language == matrix.language }} run: pnpm build + # C and C++ are the only rows whose producer is built rather than + # downloaded, and it is a pinned commit: the same bytes, reproduced from + # scratch, on every push. Restoring them instead is not a shortcut around + # the build but a removal of work that had no reason to happen twice. The + # key names the exact commit and the exact build recipe, so a producer + # bump or a recipe edit misses by construction, and `setup` re-checks the + # restored binary's own `--version` against the pin before using it. + - name: Restore the pinned Clang producer + if: ${{ (matrix.language == 'c' || matrix.language == 'cpp') && (github.event_name != 'workflow_dispatch' || inputs.language == 'all' || inputs.language == matrix.language) }} + uses: actions/cache@v6 + with: + path: tests/experiment/.work/tools + key: clang-producer-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('packages/graph/src/provider/cpp/CPP_CLANG_PRODUCER_COMMIT.ts', 'tests/experiment/src/setup-language.mjs') }} + - name: Install language server if: ${{ github.event_name != 'workflow_dispatch' || inputs.language == 'all' || inputs.language == matrix.language }} run: pnpm --filter @samchon/graph-experiment run setup -- --language ${{ matrix.language }} diff --git a/tests/experiment/src/setup-language.mjs b/tests/experiment/src/setup-language.mjs index 082e1ffc..a7bd60cd 100644 --- a/tests/experiment/src/setup-language.mjs +++ b/tests/experiment/src/setup-language.mjs @@ -399,6 +399,61 @@ const installScipClang = () => "06fd18c576f979a726c651594644ec4a35db4f471f2160b3f72eb89fa6001784", }); +/** + * Accept an already-installed pinned producer, or report that there is none. + * + * Deliberately total: any missing file, any unreadable resource tree, any + * version string that does not name the pinned commit, and any error at all + * means "build it". Reuse is an optimisation, so it may only ever be taken + * when the evidence for it is complete. + */ +const installedClangGraphProducer = () => { + try { + const installed = path.join(binRoot, "samchon-clangd"); + const alias = path.join(binRoot, "clangd"); + if ( + !fs.statSync(installed, { throwIfNoEntry: false })?.isFile() || + !fs.statSync(alias, { throwIfNoEntry: false })?.isFile() + ) { + return false; + } + const resources = path.join(toolsRoot, "lib", "clang"); + const versions = fs + .readdirSync(resources, { withFileTypes: true }) + .filter( + (entry) => + entry.isDirectory() && + fs + .statSync(path.join(resources, entry.name, "include", "stddef.h"), { + throwIfNoEntry: false, + }) + ?.isFile(), + ); + if (versions.length !== 1) return false; + for (const binary of [installed, alias]) { + const reported = run(binary, ["--version"], { stdio: "pipe" }); + if (!String(reported.stdout).includes(experiment.producerCommit)) { + return false; + } + } + } catch { + return false; + } + record({ + tool: "samchon-clangd", + version: experiment.producerCommit, + source: `${experiment.producerRepository}@${experiment.producerCommit}`, + digest: `git:${experiment.producerCommit}`, + }); + record({ + tool: "clangd", + version: experiment.producerCommit, + source: "alias of samchon-clangd", + digest: `git:${experiment.producerCommit}`, + }); + return true; +}; + const installClangGraphProducer = () => { if ( typeof experiment.producerRepository !== "string" || @@ -408,6 +463,15 @@ const installClangGraphProducer = () => { `${experiment.language}: native Clang setup requires an exact producer repository and commit`, ); } + // The producer is a pinned commit, so its binary is a pure function of that + // commit and this toolchain. Rebuilding it on every push was the actual + // waste: roughly two CPU-hours per workflow to reproduce bytes that cannot + // have changed. A restored install is therefore reused rather than rebuilt — + // but only after it says, itself, that it is the pinned producer. A cache is + // untrusted input, and the same `--version` check the fresh build has to + // pass is what admits a restored one, so a stale or foreign artifact fails + // closed here instead of quietly indexing a corpus with the wrong compiler. + if (installedClangGraphProducer()) return; const source = path.join(toolsRoot, "samchon-clangd-source"); const build = path.join(source, "build"); fs.rmSync(source, { force: true, recursive: true }); @@ -453,12 +517,15 @@ const installClangGraphProducer = () => { `-DLLVM_FORCE_VC_REVISION=${experiment.producerCommit}`, `-DLLVM_FORCE_VC_REPOSITORY=${experiment.producerRepository}`, ]); - // Build with the machine, not with a number. What was measured: a fixed `2` - // on a four-vCPU hosted runner reached step 2,431 of 3,125 in 85 minutes and - // was then killed at the job timeout. How much of the remaining gap width - // recovers is not measured and should not be written down as though it were; - // what is certain is only that half the runner sat idle for all 85 of those - // minutes. + // Build with the machine, not with a number — but do not expect that to be + // worth much here, because it was measured and it was not. The same 2,431 of + // 3,125 steps took 85.1 minutes at a fixed `2` and 81.2 minutes at the four + // the hosted runner advertises: a 4.8 percent gain for twice the job count. + // Four vCPUs are two physical cores behind SMT, and an LLVM compile + // saturates them well before the job count runs out. Sizing by the machine + // is still right — a constant that half-idles a wider machine is a defect + // wherever this runs — but on this runner it is not the constraint, and the + // build's roughly 110-minute floor is not something a job count moves. // // Capped by memory as well as by cores, because those are different limits // and only one of them is visible here. The cancelled run proves nothing diff --git a/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts b/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts index eeaad845..bc7f3e2b 100644 --- a/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts +++ b/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts @@ -240,6 +240,28 @@ export const test_experiment_corpora_are_commit_pinned = () => { setup.includes("String(jobs),") && !/"--parallel",\s*\n\s*"\d+"/u.test(setup), ); + // A restored producer is untrusted input, and the whole point of restoring + // it is to skip the build that would otherwise have proved what it is. So + // reuse is admitted by the same evidence a fresh build must produce: both + // installed names report the pinned commit, and the resource headers the + // adapter resolves relative to the binary are present exactly once. Anything + // short of that — a missing file, an unreadable tree, an unexpected version, + // any thrown error — falls back to building, because reuse is an + // optimisation and may only be taken on complete evidence. + TestValidator.predicate( + "a restored native Clang producer is re-proved against the pin before reuse", + setup.includes("const installedClangGraphProducer = () =>") && + setup.includes("if (installedClangGraphProducer()) return;") && + cppSetup.includes("installClangGraphProducer()") && + /installedClangGraphProducer[\s\S]*?String\(reported\.stdout\)\.includes\(\s*experiment\.producerCommit,?\s*\)/u.test( + setup, + ) && + /installedClangGraphProducer[\s\S]*?"stddef\.h"/u.test(setup) && + /installedClangGraphProducer[\s\S]*?versions\.length !== 1/u.test(setup) && + /installedClangGraphProducer[\s\S]*?\} catch \{\s*\n\s*return false;/u.test( + setup, + ), + ); // scip-python 0.6.6 recovers from a malformed `pyproject.toml`, falls back to // Pyright defaults and emits no SCIP diagnostics. On the pinned Click // fixture, the source and semantic fact planes stay unchanged. The aggregate diff --git a/tests/test-graph/src/features/test_workflows_use_current_core_action_runtimes.ts b/tests/test-graph/src/features/test_workflows_use_current_core_action_runtimes.ts index d066fd29..98f91c08 100644 --- a/tests/test-graph/src/features/test_workflows_use_current_core_action_runtimes.ts +++ b/tests/test-graph/src/features/test_workflows_use_current_core_action_runtimes.ts @@ -20,6 +20,7 @@ import { GraphPaths } from "../internal/GraphPaths"; * once instead of being counted per file. */ const MAINTAINED: Record = { + cache: 6, checkout: 7, "setup-go": 7, "setup-node": 7, @@ -128,10 +129,24 @@ export const test_workflows_use_current_core_action_runtimes = () => { path.join(directory, "experiment.yml"), "utf8", ); - // One hang boundary for every language. A per-language exception is how a - // budget stops being a boundary: the one lane that needed 90 minutes needed - // it because the provider had been serialized, so raising the budget was - // preserving the cause rather than bounding it. + // One boundary declaration for the whole matrix, and the exception set + // written into it rather than left to a reader. + // + // This originally refused any per-language exception, and the reason it gave + // was a cause: the lane that wanted more than ninety minutes wanted it + // because its provider had been serialized, so raising the budget preserved + // that cause instead of bounding it. Measurement retired the reason. The + // same 2,431 of 3,125 build steps take 85.1 minutes at two jobs and 81.2 at + // four, because the runner's four vCPUs are two physical cores; roughly 110 + // minutes is the floor and no job count moves it. C and C++ build a compiler + // from source and the other fourteen rows install a released producer, so + // the difference is a property of those two rows and not a defect inside + // them. + // + // Asserted as the exact expression, which makes this stricter than the + // single number it replaces: the ninety-minute bound still governs every + // other row, and a third language cannot reach the wider one — or a fourth + // number appear — without editing this line and answering for it. // // Scoped to the matrix job, not to the file. Counting `timeout-minutes:` // lines across the whole workflow passes just as well when the only one has @@ -144,9 +159,26 @@ export const test_workflows_use_current_core_action_runtimes = () => { .filter((line) => line.trim().startsWith("timeout-minutes:")) .map((line) => line.trim()); TestValidator.equals( - "every real-tool language lane shares one hang boundary", + "one bound governs the matrix and only the compiler-building rows widen it", experimentTimeouts, - ["timeout-minutes: 90"], + [ + "timeout-minutes: ${{ (matrix.language == 'c' || matrix.language == 'cpp') && 150 || 90 }}", + ], + ); + // The wider bound is only defensible because the ordinary push never reaches + // it. Pin the restore that makes that true, including the key: a producer + // bump or a build-recipe edit has to miss the cache, or a stale binary would + // outlive the commit it was built from. + TestValidator.predicate( + "the built producer is restored by pinned commit rather than rebuilt", + experimentJob.includes("uses: actions/cache@v6") && + experimentJob.includes("path: tests/experiment/.work/tools") && + experimentJob.includes( + "hashFiles('packages/graph/src/provider/cpp/CPP_CLANG_PRODUCER_COMMIT.ts', 'tests/experiment/src/setup-language.mjs')", + ) && + experimentJob.includes( + "if: ${{ (matrix.language == 'c' || matrix.language == 'cpp')", + ), ); TestValidator.predicate( "the Rust experiment launches the exact binary provisioned by setup", From 0bb642ca6ab9b247bd977a2a4e2c5c58173d16f3 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Tue, 4 Aug 2026 11:33:39 +0900 Subject: [PATCH 43/52] Make the guards this cycle added actually hold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three commit early-warning passes read the safety mechanisms added earlier in this cycle and found that two of them did not work. The parallelism pin was satisfiable by prose. Its clauses were `includes` checks over the whole file, so a comment explaining why `os.availableParallelism()` and `os.totalmem()` had been abandoned, with `const jobs = 2` beneath it, passed every one — the exact regression the pin exists to refuse. It now binds the computed expression and the argument the build receives, inside a region bounded to the Clang installer. The cache key hashed a file the script never reads. `setup` takes the commit from `catalog.mjs`; the key hashed only the adapter's constant, leaving the two bound by a text assertion in another workflow rather than by construction. A divergence would hit the key, fail the version check, rebuild in full, and then never re-save, because an exact hit has nothing to write: permanent silent full-cost rebuilding, with the widened bound as the normal path. `catalog.mjs` is in the key now. Restore and save are split so a correct build survives a later failure. The combined form saves only on success, and a campaign iterating on these lanes is exactly the case where the producer builds and the corpus run does not. The job count is logged, because a memory-halved count was otherwise indistinguishable from a slow build — the confusion that already cost this lane two CI runs. Splitting the cache then opened a hole one step behind: the maintained-major pattern reads `actions/@v` and cannot see `actions/cache/restore`, so the entry added moments earlier was dead and both steps unwatched. A sub-action is attributed to its parent repository now. Twelve further comment claims were wrong or overbroad and are corrected. One of them was not a wording problem: the corpus-name guard did not read `.java` while the package ships a `.java` sidecar, so the shipped language likeliest to name a JVM corpus was the one it never opened. That gap is closed in the walk rather than described in the prose. --- .github/workflows/experiment.yml | 48 ++++++++-- tests/experiment/src/setup-language.mjs | 29 ++++-- ...lication_exercises_every_request_branch.ts | 2 +- ...e_covers_every_graph_node_and_edge_kind.ts | 16 ++-- ...st_experiment_corpora_are_commit_pinned.ts | 68 ++++++++------ ...rvers_that_break_the_shutdown_handshake.ts | 23 ++--- ...t_mcp_server_exposes_inspect_code_graph.ts | 12 +-- ..._adapters_preserve_authoritative_models.ts | 13 +-- ..._context_protocol_commits_atomic_shards.ts | 9 +- ...lient_restores_retries_and_fails_closed.ts | 8 +- ...ce_does_not_leak_benchmark_corpus_names.ts | 13 +-- ...kflows_use_current_core_action_runtimes.ts | 90 ++++++++++++++----- 12 files changed, 217 insertions(+), 114 deletions(-) diff --git a/.github/workflows/experiment.yml b/.github/workflows/experiment.yml index 7f4f33dc..2fcb56a8 100644 --- a/.github/workflows/experiment.yml +++ b/.github/workflows/experiment.yml @@ -83,9 +83,14 @@ jobs: # 3,125 steps take 85.1 minutes at two jobs and 81.2 at four, because four # vCPUs are two physical cores. Roughly 110 minutes is the floor, no job # count moves it, and a cold build plus its corpus run needs more than - # ninety. The wider bound is granted only to the two rows that build, and - # only on the run that has to build: the cache above means the ordinary - # push restores the binary and never reaches it. + # ninety. The wider bound is granted only to the two rows that build. + # + # It is a bound, not a prediction: 150 is the extrapolated ~110-minute + # build with room for the setup and corpus work around it, and neither of + # those has its own measurement yet. The restore below is what should keep + # an ordinary push far away from it, though that too is an expectation + # rather than a property — Actions caches are branch-scoped and evicted, so + # a first push on a fresh branch can still pay the full build. timeout-minutes: ${{ (matrix.language == 'c' || matrix.language == 'cpp') && 150 || 90 }} strategy: fail-fast: false @@ -153,16 +158,28 @@ jobs: # C and C++ are the only rows whose producer is built rather than # downloaded, and it is a pinned commit: the same bytes, reproduced from # scratch, on every push. Restoring them instead is not a shortcut around - # the build but a removal of work that had no reason to happen twice. The - # key names the exact commit and the exact build recipe, so a producer - # bump or a recipe edit misses by construction, and `setup` re-checks the - # restored binary's own `--version` against the pin before using it. + # the build but a removal of work that had no reason to happen twice. + # + # The key hashes every file the built bytes depend on. `catalog.mjs` is + # the one `setup` actually reads its commit from, so it has to be here — + # keying on the adapter's constant alone would leave the two bound only + # by a text assertion in a different workflow, and a divergence would hit + # the key, fail the `--version` check, rebuild, and then not re-save, + # because an exact hit has nothing to write. That is silent, permanent, + # full-cost rebuilding, so the binding is made structural instead. + # + # Restore and save are split so the save runs even when a later step + # fails. A campaign iterating on these lanes is exactly the case where + # the producer builds and the experiment does not, and `cache`'s combined + # form would discard a correct 110-minute build because a corpus + # assertion afterwards went red. - name: Restore the pinned Clang producer + id: clang_producer if: ${{ (matrix.language == 'c' || matrix.language == 'cpp') && (github.event_name != 'workflow_dispatch' || inputs.language == 'all' || inputs.language == matrix.language) }} - uses: actions/cache@v6 + uses: actions/cache/restore@v6 with: path: tests/experiment/.work/tools - key: clang-producer-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('packages/graph/src/provider/cpp/CPP_CLANG_PRODUCER_COMMIT.ts', 'tests/experiment/src/setup-language.mjs') }} + key: clang-producer-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('packages/graph/src/provider/cpp/CPP_CLANG_PRODUCER_COMMIT.ts', 'tests/experiment/src/catalog.mjs', 'tests/experiment/src/setup-language.mjs') }} - name: Install language server if: ${{ github.event_name != 'workflow_dispatch' || inputs.language == 'all' || inputs.language == matrix.language }} @@ -170,6 +187,19 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Saved before the experiment runs, not after it, and only on a miss. + # The producer is proved by this point — `setup` refuses to finish unless + # the installed binary reports the pinned commit — and what follows is a + # corpus run whose failure says nothing about the compiler that was + # built. Waiting until the end would tie a correct build's survival to an + # unrelated assertion. + - name: Save the pinned Clang producer + if: ${{ (matrix.language == 'c' || matrix.language == 'cpp') && steps.clang_producer.outputs.cache-hit != 'true' && (github.event_name != 'workflow_dispatch' || inputs.language == 'all' || inputs.language == matrix.language) }} + uses: actions/cache/save@v6 + with: + path: tests/experiment/.work/tools + key: ${{ steps.clang_producer.outputs.cache-primary-key }} + - name: Run LSP experiment if: ${{ github.event_name != 'workflow_dispatch' || inputs.language == 'all' || inputs.language == matrix.language }} run: pnpm --filter @samchon/graph-experiment start -- --language ${{ matrix.language }} diff --git a/tests/experiment/src/setup-language.mjs b/tests/experiment/src/setup-language.mjs index a7bd60cd..360fbdaf 100644 --- a/tests/experiment/src/setup-language.mjs +++ b/tests/experiment/src/setup-language.mjs @@ -527,14 +527,22 @@ const installClangGraphProducer = () => { // wherever this runs — but on this runner it is not the constraint, and the // build's roughly 110-minute floor is not something a job count moves. // - // Capped by memory as well as by cores, because those are different limits - // and only one of them is visible here. The cancelled run proves nothing - // about the second: it stopped before `clangd` was linked, which is exactly - // where an LLVM build peaks, so its silence about allocation failure is - // absence of evidence rather than evidence of headroom. Two GiB per job is - // LLVM's own rule of thumb for compiling, and it keeps a many-core, modest - // memory workstation — the local `setup` path the language-support skill - // documents — from turning this into an out-of-memory kill. + // Also bounded by installed memory, which is a machine-class bound and not + // an out-of-memory guard — worth being exact about, because the two are easy + // to confuse and only the first is what this computes. It reads total rather + // than free memory, so it says "this machine should not run more than N + // concurrent compiles", not "this machine has room right now". It bounds + // compile concurrency only; the `clangd` link is a single build edge that + // runs whatever this number is, and LLVM's own controls for that + // (`LLVM_PARALLEL_LINK_JOBS` and friends) are deliberately not set here + // because no run has yet reached the link to measure it. Two GiB per compile + // job is this repository's figure, chosen as a conventional one; it is not + // quoted from LLVM. + // + // Logged because it is otherwise invisible. Ninja does not print its job + // count and `run` does not echo argv, so a machine whose memory quietly + // halves the count would look exactly like a slow build, which is the + // confusion that cost this lane two CI runs already. const jobs = Math.max( 1, Math.min( @@ -542,6 +550,11 @@ const installClangGraphProducer = () => { Math.floor(os.totalmem() / (2 * 1024 * 1024 * 1024)), ), ); + console.log( + `${experiment.language}: building the pinned Clang producer with ${String(jobs)} jobs ` + + `(cores ${String(os.availableParallelism())}, ` + + `memory ${String(Math.round(os.totalmem() / (1024 * 1024 * 1024)))} GiB)`, + ); run("cmake", [ "--build", build, diff --git a/tests/test-graph/src/features/test_application_exercises_every_request_branch.ts b/tests/test-graph/src/features/test_application_exercises_every_request_branch.ts index 3a8e8414..b19c9643 100644 --- a/tests/test-graph/src/features/test_application_exercises_every_request_branch.ts +++ b/tests/test-graph/src/features/test_application_exercises_every_request_branch.ts @@ -5,7 +5,7 @@ import { GraphFixtures } from "../internal/GraphFixtures"; /** * Every request member is driven through the real application and its result - * discriminator is compared, in order, against the advertised request list. + * discriminator is compared, in order, against this suite's own request list. * The property that buys is narrow but not otherwise held anywhere: no arm may * answer as another arm. Each one selects its own result union member, and a * mis-wired `switch` that returned an overview for a trace would satisfy the diff --git a/tests/test-graph/src/features/test_contract_fixture_covers_every_graph_node_and_edge_kind.ts b/tests/test-graph/src/features/test_contract_fixture_covers_every_graph_node_and_edge_kind.ts index c8114d4a..d424ffb8 100644 --- a/tests/test-graph/src/features/test_contract_fixture_covers_every_graph_node_and_edge_kind.ts +++ b/tests/test-graph/src/features/test_contract_fixture_covers_every_graph_node_and_edge_kind.ts @@ -5,13 +5,15 @@ import { GraphFixtures } from "../internal/GraphFixtures"; /** * The contract fixture is the shared corpus every operation test reasons from, - * so a node or edge kind it happens not to contain is a kind nothing tests — - * silently, and more so after each new family is added. This is the mechanical - * completeness gate for that: every public node kind is realized, and every - * edge kind an index can store is realized. The split matters as much as the - * count. Traversal-only kinds are asserted to be stored by nothing, so the - * fixture cannot quietly satisfy this gate by persisting a derived edge that - * the graph is supposed to compute rather than keep. + * so a kind it happens not to contain is a kind nothing tests — silently, and + * more so after each new family is added. This is the completeness gate for + * that. + * + * The two halves are not equally strong, which is worth knowing before relying + * on either. The edge half is anchored: it compares the fixture against the + * package's own exported `GRAPH_EDGE_KINDS`, so a new family reaches it by + * existing. The node half compares against a list this suite maintains by + * hand, so a new node kind reaches it only when someone adds it there too. */ export const test_contract_fixture_covers_every_graph_node_and_edge_kind = () => { const { dump } = GraphFixtures.createContractFixture(); diff --git a/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts b/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts index bc7f3e2b..33d18028 100644 --- a/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts +++ b/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts @@ -224,21 +224,30 @@ export const test_experiment_corpora_are_commit_pinned = () => { setup.includes('tool: "samchon-clangd"') && !cppSetup.includes('apt(["clangd"'), ); - // A fixed parallelism here already cost two whole CI lanes: the build ran on - // half a four-vCPU runner and was killed at the job timeout with 694 of - // 3,125 steps left. The workflow refuses to widen that timeout for one - // language, so the size of this build is the thing that has to stay correct, - // and a literal is exactly how it silently stops being correct again. Pin - // both halves: sized by the machine, and bounded by its memory rather than - // by its core count alone, since the run that failed stopped before `clangd` - // was linked and therefore proved nothing about the memory peak. - TestValidator.predicate( + // A fixed parallelism here already cost CI lanes, and the size of this build + // is the thing that has to stay correct, so pin the decision rather than its + // vocabulary. Naming `os.availableParallelism()` and `os.totalmem()` proves + // nothing on its own: a comment saying why they were abandoned contains both + // names, and a `const jobs = 2` under it would satisfy every such check — + // which is precisely the regression this exists to refuse. So the binding is + // asserted end to end instead: the expression that computes the count, and + // the argument that hands that same count to the build. + const clangBuild = region( + setup, + "const installClangGraphProducer", + "const installScipPython", + ); + TestValidator.equals( "the native Clang build is sized by the machine and bounded by its memory", - setup.includes("os.availableParallelism()") && - setup.includes("os.totalmem()") && - setup.includes('"--parallel",') && - setup.includes("String(jobs),") && - !/"--parallel",\s*\n\s*"\d+"/u.test(setup), + [ + /const jobs = Math\.max\(\s*1,\s*Math\.min\(\s*os\.availableParallelism\(\),\s*Math\.floor\(os\.totalmem\(\) \/ \(2 \* 1024 \* 1024 \* 1024\)\),\s*\),\s*\);/u.test( + clangBuild, + ), + /"--parallel",\s*String\(jobs\),/u.test(clangBuild), + /"--parallel",\s*(?:"|`|'|\d)/u.test(clangBuild), + clangBuild.includes("console.log("), + ], + [true, true, false, true], ); // A restored producer is untrusted input, and the whole point of restoring // it is to skip the build that would otherwise have proved what it is. So @@ -248,19 +257,28 @@ export const test_experiment_corpora_are_commit_pinned = () => { // short of that — a missing file, an unreadable tree, an unexpected version, // any thrown error — falls back to building, because reuse is an // optimisation and may only be taken on complete evidence. - TestValidator.predicate( + // + // Bounded to the predicate's own body. An unbounded `[\s\S]*?` would let a + // deleted check pass by matching the identical text in the build path below + // it, so the region is what makes a deletion visible. + const restoredProducer = region( + setup, + "const installedClangGraphProducer", + "const installClangGraphProducer", + ); + TestValidator.equals( "a restored native Clang producer is re-proved against the pin before reuse", - setup.includes("const installedClangGraphProducer = () =>") && - setup.includes("if (installedClangGraphProducer()) return;") && - cppSetup.includes("installClangGraphProducer()") && - /installedClangGraphProducer[\s\S]*?String\(reported\.stdout\)\.includes\(\s*experiment\.producerCommit,?\s*\)/u.test( - setup, - ) && - /installedClangGraphProducer[\s\S]*?"stddef\.h"/u.test(setup) && - /installedClangGraphProducer[\s\S]*?versions\.length !== 1/u.test(setup) && - /installedClangGraphProducer[\s\S]*?\} catch \{\s*\n\s*return false;/u.test( - setup, + [ + setup.includes("if (installedClangGraphProducer()) return;"), + cppSetup.includes("installClangGraphProducer()"), + /String\(reported\.stdout\)\.includes\(\s*experiment\.producerCommit,?\s*\)/u.test( + restoredProducer, ), + restoredProducer.includes('"stddef.h"'), + restoredProducer.includes("versions.length !== 1"), + /\} catch \{\s*\n\s*return false;/u.test(restoredProducer), + ], + [true, true, true, true, true, true], ); // scip-python 0.6.6 recovers from a malformed `pyproject.toml`, falls back to // Pyright defaults and emits no SCIP diagnostics. On the pinned Click diff --git a/tests/test-graph/src/features/test_lsp_client_closes_servers_that_break_the_shutdown_handshake.ts b/tests/test-graph/src/features/test_lsp_client_closes_servers_that_break_the_shutdown_handshake.ts index ca1cf25a..800c3e39 100644 --- a/tests/test-graph/src/features/test_lsp_client_closes_servers_that_break_the_shutdown_handshake.ts +++ b/tests/test-graph/src/features/test_lsp_client_closes_servers_that_break_the_shutdown_handshake.ts @@ -100,23 +100,16 @@ const importLib = (relative: string): Promise => /** * A language server that misbehaves during teardown leaves nothing behind in * the graph, so no result-shaped assertion can notice it; the evidence is a - * process that outlives its session, or a second of wall clock nobody can - * account for. + * process that outlives its session, or wall clock nobody can account for. * - * The two servers here break the handshake in opposite directions, and the - * correct response to each is the opposite of the other. One acknowledges - * `shutdown` and then ignores `exit`, so the client must escalate: wait, kill, - * and reject the in-flight request with the signal it had to send rather than - * leaving it pending forever. The other exits on `shutdown` instead of - * replying, so the client must *not* escalate — it is already gone, and - * waiting out its grace period would cost every teardown a full second for - * nothing. A client that only handled the first would pass a test that only - * asked about leaks. + * The two servers below break the handshake in opposite directions, and each + * inline comment states its own case. What is worth saying once, here, is why + * both are needed: the correct response to one is escalation and to the other + * is refusing to escalate, so a client that handled only the first would still + * pass a suite that only asked about leaks. * - * The case continues past teardown into the rest of the client's process and - * transport surface, including the server-initiated request path, where a - * request left unanswered deadlocks servers that withhold their own replies - * until it is acknowledged. + * The case then continues into the rest of the client's process and transport + * surface. */ export const test_lsp_client_closes_servers_that_break_the_shutdown_handshake = async () => { diff --git a/tests/test-graph/src/features/test_mcp_server_exposes_inspect_code_graph.ts b/tests/test-graph/src/features/test_mcp_server_exposes_inspect_code_graph.ts index 22324605..5384e9ff 100644 --- a/tests/test-graph/src/features/test_mcp_server_exposes_inspect_code_graph.ts +++ b/tests/test-graph/src/features/test_mcp_server_exposes_inspect_code_graph.ts @@ -75,12 +75,12 @@ const overview = async (args: string[]) => { /** * Everything else about the graph is tested through the TypeScript API, which * cannot see the one boundary an agent actually uses: a spawned process, one - * registered tool, and a structured result arriving over stdio. This drives - * that boundary end to end. The second half is the reason it runs twice — a - * `--graph-file` server must answer from the dump the benchmark pre-warms it - * with, and it is held to the same node count as the lane that indexed the - * project itself, so serving a stale or partial graph is not a smaller answer - * but a failure. + * registered tool, and a structured result arriving over stdio. + * + * It runs twice because there are two ways to reach that boundary, and only + * one of them indexes anything. The `--graph-file` server is held to the same + * node count as the lane that indexed the project, so a graph file served + * stale or in part is a failure rather than a smaller answer. */ export const test_mcp_server_exposes_inspect_code_graph = async () => { const root = GraphFixtures.createOrderFixture(); diff --git a/tests/test-graph/src/features/test_repository_context_adapters_preserve_authoritative_models.ts b/tests/test-graph/src/features/test_repository_context_adapters_preserve_authoritative_models.ts index 73b78e89..4f316e52 100644 --- a/tests/test-graph/src/features/test_repository_context_adapters_preserve_authoritative_models.ts +++ b/tests/test-graph/src/features/test_repository_context_adapters_preserve_authoritative_models.ts @@ -19,12 +19,13 @@ import { parseGradleRepositoryContextModel } from "../../../../packages/graph/sr * reconstruct the topology from directory layout and publish it as though the * tool had said it. This pins the opposite behaviour per ecosystem. Detection * is keyed to the owning manifest rather than to any repository that happens - * to contain a folder; `declared` and `tool-resolved` authority stay distinct - * per node and edge; an absent Tooling API classpath, a failed tool, malformed - * JSON, a stale CMake reply and a missing File API query each make the adapter - * throw rather than answer; and a Gradle module name that resolves ambiguously - * degrades `depends-on` to partial coverage with a warning instead of emitting - * the edge it cannot prove. + * to contain a folder. An absent Tooling API classpath, a failed tool, + * malformed JSON, a stale CMake reply and a missing File API query each make + * the adapter throw rather than answer. A Gradle module name that resolves + * ambiguously degrades `depends-on` to partial coverage with a warning instead + * of emitting the edge it cannot prove. And pnpm — the one ecosystem here that + * mixes both grades in a single model — keeps `declared` and `tool-resolved` + * counted apart across its nodes and edges. */ export const test_repository_context_adapters_preserve_authoritative_models = async () => { diff --git a/tests/test-graph/src/features/test_repository_context_protocol_commits_atomic_shards.ts b/tests/test-graph/src/features/test_repository_context_protocol_commits_atomic_shards.ts index 4f68b87c..262c1426 100644 --- a/tests/test-graph/src/features/test_repository_context_protocol_commits_atomic_shards.ts +++ b/tests/test-graph/src/features/test_repository_context_protocol_commits_atomic_shards.ts @@ -241,11 +241,10 @@ export const test_repository_context_protocol_commits_atomic_shards = const upsert = changedUpsert(frames); upsert.shard.edges[0]!.from = ""; }), - // `joins-file` is the one relation whose target is a file identity rather - // than a node identity, so it is the one endpoint the ordinary node - // closure check cannot cover. A join to a file the shard never declared - // is how the topology plane would start naming code the code generation - // has no record of. + // The endpoint arm the mutation above cannot reach: that one edits a + // `contains` edge and so takes the node-closure branch. A join naming a + // file the shard never declared is how the topology plane would start + // pointing at code the code generation has no record of. mutate(transaction(3, unchanged, changedShard()), (frames) => { const upsert = changedUpsert(frames); upsert.shard.edges.find((edge) => edge.kind === "joins-file")!.to = diff --git a/tests/test-graph/src/features/test_rust_hir_client_restores_retries_and_fails_closed.ts b/tests/test-graph/src/features/test_rust_hir_client_restores_retries_and_fails_closed.ts index 6ea180fb..6c153b5f 100644 --- a/tests/test-graph/src/features/test_rust_hir_client_restores_retries_and_fails_closed.ts +++ b/tests/test-graph/src/features/test_rust_hir_client_restores_retries_and_fails_closed.ts @@ -16,13 +16,13 @@ import { GraphPaths } from "../internal/GraphPaths.js"; * This pins the client's side of that: a cancelled or content-modified response * is retried until the ready deadline rather than published, a no-op returns * the exact resident object rather than an equal copy, a rejected restart - * checkpoint discards the persisted generation instead of reusing it, and an - * unpersistable checkpoint degrades to a warning while the validated snapshot - * stays resident. + * checkpoint discards the persisted generation instead of reusing it, and a + * checkpoint that cannot be written reaches the caller as a warning on a + * snapshot that was still published. * * The refusal cases assert the strictest available form of atomicity, which is * why they run on fresh clients: a producer response the adapter rejects, and - * a generation the product validator refuses, must each leave `current` + * a generation the consumer contract refuses, must each leave `current` * undefined. Nothing partially applied, not merely nothing published. */ export const test_rust_hir_client_restores_retries_and_fails_closed = async () => { diff --git a/tests/test-graph/src/features/test_shipped_source_does_not_leak_benchmark_corpus_names.ts b/tests/test-graph/src/features/test_shipped_source_does_not_leak_benchmark_corpus_names.ts index 1fa03bac..0a235f4f 100644 --- a/tests/test-graph/src/features/test_shipped_source_does_not_leak_benchmark_corpus_names.ts +++ b/tests/test-graph/src/features/test_shipped_source_does_not_leak_benchmark_corpus_names.ts @@ -28,10 +28,9 @@ const CORPUS_NAMES = [ * campaign — two comments explaining real defects by naming the corpus that * exhibited them — and both were rewritten to describe the condition instead. * - * This is the mechanical guard for the class. Its reach is deliberate rather - * than total: it walks `src` and `sidecars` for the text extensions the - * product ships as code, so the README's benchmark tables — which name every - * corpus on purpose, as published measurement evidence — stay outside it. + * Scope is `packages/graph/src` and `packages/graph/sidecars`. The README is + * outside it on purpose: its benchmark tables name every corpus as published + * measurement evidence. */ export const test_shipped_source_does_not_leak_benchmark_corpus_names = () => { const roots = [ @@ -63,6 +62,10 @@ function walk(directory: string): string[] { return entry.isDirectory() ? walk(file) : [file]; }) .filter((file) => - /\.(?:ts|js|mjs|cjs|json|html|go|lua|mod|sum)$/u.test(file), + // Every extension `copy-sidecars.mjs` ships, plus the package's own + // source. `.java` was missing while `sidecars/gradle` shipped a `.java` + // file, so the one shipped language most likely to name a JVM corpus was + // the one language this never read. + /\.(?:ts|js|mjs|cjs|json|html|go|java|lua|mod|sum)$/u.test(file), ); } diff --git a/tests/test-graph/src/features/test_workflows_use_current_core_action_runtimes.ts b/tests/test-graph/src/features/test_workflows_use_current_core_action_runtimes.ts index 98f91c08..b6fa4c69 100644 --- a/tests/test-graph/src/features/test_workflows_use_current_core_action_runtimes.ts +++ b/tests/test-graph/src/features/test_workflows_use_current_core_action_runtimes.ts @@ -18,6 +18,15 @@ import { GraphPaths } from "../internal/GraphPaths"; * the files it was born with is not a policy. Enumerating the directory means a * new workflow is held to it by existing, and the maintained majors are named * once instead of being counted per file. + * + * Reconciled with upstream by hand, and only by hand: the deterministic suite + * may not reach the network, so nothing here can ask GitHub what the current + * major is. The check that runs is workflow-against-map, which makes this map + * an oracle only as good as the last time someone read the releases. Every + * entry was checked when `cache` was added — and `cache` is why the caveat is + * written down, because it was first added two majors behind from memory and + * nothing caught it: an action absent from this map is an action nobody + * watches. */ const MAINTAINED: Record = { cache: 6, @@ -29,14 +38,14 @@ const MAINTAINED: Record = { }; /** - * Nothing else in the suite reads a workflow, so every claim CI makes about - * itself is unchecked by default: a retired action major, a release lane that + * Nothing in the product suite reads a workflow, so what CI claims about + * itself is unchecked here by default, and each way that goes wrong stays + * green until the day it matters: a retired action major, a release lane that * publishes before it audits, a producer pin that drifts, a hang boundary - * quietly moved off the matrix job, or a classifier that fails open and skips - * the matrix on the very heads it was meant to cover. Each of those stays - * green until the day it matters. This reads the workflow and release-script - * sources directly and holds them to {@link MAINTAINED} and to the ordering - * and scoping each lane depends on. + * moved off the matrix job onto GitHub's six-hour default. + * + * This reads the workflow and release-script sources directly and holds them + * to {@link MAINTAINED} and to the ordering and scoping each lane depends on. */ export const test_workflows_use_current_core_action_runtimes = () => { const directory = path.join(GraphPaths.repositoryRoot, ".github", "workflows"); @@ -51,8 +60,14 @@ export const test_workflows_use_current_core_action_runtimes = () => { const stale: string[] = []; for (const file of files) { const text = fs.readFileSync(path.join(directory, file), "utf8"); + // Sub-actions count as their parent. `actions/cache/restore` ships from + // the `actions/cache` repository and carries its major, so a pattern that + // stopped at the first path segment would have watched `actions/cache@v6` + // and silently ignored `actions/cache/restore@v6` — which is the form the + // experiment workflow actually uses, and would have made this map's entry + // for it dead on arrival. for (const match of text.matchAll( - /uses:\s+actions\/([\w-]+)@v(\d+)/g, + /uses:\s+actions\/([\w-]+)(?:\/[\w-]+)*@v(\d+)/g, )) { const maintained = MAINTAINED[match[1]!]; if (maintained !== undefined && Number(match[2]) !== maintained) @@ -143,10 +158,12 @@ export const test_workflows_use_current_core_action_runtimes = () => { // the difference is a property of those two rows and not a defect inside // them. // - // Asserted as the exact expression, which makes this stricter than the - // single number it replaces: the ninety-minute bound still governs every - // other row, and a third language cannot reach the wider one — or a fourth - // number appear — without editing this line and answering for it. + // Asserted as the exact expression. That is the same shape of assertion as + // the single number it replaces, not a tighter one — what changed is the + // policy, not the grip. The grip is what matters here: the ninety-minute + // bound still governs every other row, and a third language cannot reach the + // wider one, nor a fourth number appear, without editing this line and + // answering for it. // // Scoped to the matrix job, not to the file. Counting `timeout-minutes:` // lines across the whole workflow passes just as well when the only one has @@ -165,20 +182,47 @@ export const test_workflows_use_current_core_action_runtimes = () => { "timeout-minutes: ${{ (matrix.language == 'c' || matrix.language == 'cpp') && 150 || 90 }}", ], ); - // The wider bound is only defensible because the ordinary push never reaches - // it. Pin the restore that makes that true, including the key: a producer - // bump or a build-recipe edit has to miss the cache, or a stale binary would - // outlive the commit it was built from. + // The wider bound is only defensible while an ordinary push does not reach + // it, and that depends entirely on the restore below. Pinned per step rather + // than as loose substrings over the job: four independent `includes` calls + // are satisfied by four unrelated steps, which would let the key, the path + // and the condition drift apart while the assertion stayed green. + // + // The key is pinned by its exact file list because that list is the whole + // claim. `catalog.mjs` is where the commit is actually read from, so leaving + // it out would bind the cache to the producer only by convention; the key + // would then survive a bump, the restored binary would fail its version + // check, the build would run in full, and — having hit an exact key — never + // re-save. Permanent silent full-cost rebuilding, with the widened bound as + // the normal path. + const cacheSteps = experimentJob + .split(/\n - name: /u) + .filter((step) => step.includes("uses: actions/cache")); + TestValidator.equals( + "the built producer is restored and saved per pinned commit, not rebuilt", + cacheSteps.map((step) => + [ + /uses: actions\/cache\/(restore|save)@v6/u.exec(step)?.[1], + step.includes("path: tests/experiment/.work/tools"), + step.includes( + "(matrix.language == 'c' || matrix.language == 'cpp')", + ), + ].join(" "), + ), + ["restore true true", "save true true"], + ); TestValidator.predicate( - "the built producer is restored by pinned commit rather than rebuilt", - experimentJob.includes("uses: actions/cache@v6") && - experimentJob.includes("path: tests/experiment/.work/tools") && + "the restore key names every input the built bytes depend on", + experimentJob.includes( + "hashFiles('packages/graph/src/provider/cpp/CPP_CLANG_PRODUCER_COMMIT.ts', 'tests/experiment/src/catalog.mjs', 'tests/experiment/src/setup-language.mjs')", + ) && + // Saved on a miss and before the corpus run, so a correct build is not + // discarded because an unrelated later assertion went red. experimentJob.includes( - "hashFiles('packages/graph/src/provider/cpp/CPP_CLANG_PRODUCER_COMMIT.ts', 'tests/experiment/src/setup-language.mjs')", + "steps.clang_producer.outputs.cache-hit != 'true'", ) && - experimentJob.includes( - "if: ${{ (matrix.language == 'c' || matrix.language == 'cpp')", - ), + experimentJob.indexOf("actions/cache/save") < + experimentJob.indexOf("Run LSP experiment"), ); TestValidator.predicate( "the Rust experiment launches the exact binary provisioned by setup", From 919a81715bced58c435a7f94baf3e4a38aa1b9eb Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Tue, 4 Aug 2026 12:38:39 +0900 Subject: [PATCH 44/52] Give the C/C++ producer the time its corpus index needs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `c` lane failed at 63.5 minutes — inside the widened bound, and not by timing out. The build was never the remaining problem: C/C++ clang graph: producer did not become ready within 180000 ms: graph snapshot is not ready: 62 translation units are still indexing A whole-compilation-database producer is not ready when it starts; it is ready when clangd has background-indexed every unit the database registers. The runner's 180-second default expired with 62 of libuv's units outstanding, the client fell back exactly as it should, and the row lost the strict provenance it exists to prove. Both rows now state bounds sized for that work rather than for a per-file server — far above the few minutes observed, far below the job timeout, and still bounds, so a producer that never becomes ready fails the row instead of hanging it. The same run also refutes what the last two commits said about parallelism. The completed build ran `[1/3125]` to a linked `clangd` in 56.1 minutes at four jobs, against a two-job run that had not finished at 85. The earlier "4.8 percent gain, ~110-minute floor no job count moves" compared 2,431 steps to 2,431 steps, and ninja steps are not equal work: an LLVM build front-loads its heavy translation units and its tail is cheap, so a prefix comparison measures the slow part against itself. Sizing by the machine roughly halved the build. The 150-minute bound stands — 56 minutes of build, nine of setup, and a real-corpus lifecycle run do not fit in ninety — but on the measurement rather than on the retired claim, and every copy of that claim is rewritten. Neither the cache nor the widened bound could have prevented this failure: it happens after the build, and no run had ever reached that step before. --- .github/workflows/experiment.yml | 23 ++++++++++------------- tests/experiment/src/catalog.mjs | 20 ++++++++++++++++++++ tests/experiment/src/setup-language.mjs | 20 +++++++++++--------- 3 files changed, 41 insertions(+), 22 deletions(-) diff --git a/.github/workflows/experiment.yml b/.github/workflows/experiment.yml index 2fcb56a8..52109a22 100644 --- a/.github/workflows/experiment.yml +++ b/.github/workflows/experiment.yml @@ -77,20 +77,17 @@ jobs: # Fourteen rows install a released producer and finish in minutes; the # ninety-minute bound is theirs and stays exactly where it is. C and C++ # build a compiler from source, and that is a property of the row rather - # than a defect inside it. The bound was first raised from forty-five on - # the theory that the build was serialized, which was the wrong reason and - # is why raising it did not work; the measurement is that the same 2,431 of - # 3,125 steps take 85.1 minutes at two jobs and 81.2 at four, because four - # vCPUs are two physical cores. Roughly 110 minutes is the floor, no job - # count moves it, and a cold build plus its corpus run needs more than - # ninety. The wider bound is granted only to the two rows that build. + # than a defect inside it. Measured on this runner: the build takes 56 + # minutes from its first step to a linked `clangd`, after nine minutes of + # checkout, install, toolchain and configure, and the real-corpus lifecycle + # run follows that. Ninety did not fit; 150 leaves room without pretending + # to predict, and it is still a bound, so a lane that hangs is still + # caught. The wider bound is granted only to the two rows that build. # - # It is a bound, not a prediction: 150 is the extrapolated ~110-minute - # build with room for the setup and corpus work around it, and neither of - # those has its own measurement yet. The restore below is what should keep - # an ordinary push far away from it, though that too is an expectation - # rather than a property — Actions caches are branch-scoped and evicted, so - # a first push on a fresh branch can still pay the full build. + # The restore below should keep an ordinary push far away from it, though + # that is an expectation rather than a property: Actions caches are + # branch-scoped and evicted, so a first push on a fresh branch still pays + # the whole build. timeout-minutes: ${{ (matrix.language == 'c' || matrix.language == 'cpp') && 150 || 90 }} strategy: fail-fast: false diff --git a/tests/experiment/src/catalog.mjs b/tests/experiment/src/catalog.mjs index 999a1ee7..15f34468 100644 --- a/tests/experiment/src/catalog.mjs +++ b/tests/experiment/src/catalog.mjs @@ -152,6 +152,16 @@ export const LANGUAGE_EXPERIMENTS = [ strictTool: "samchon-clangd", producerRepository: "https://github.com/samchon/llvm-project.git", producerCommit: "dcc73b6579ebb8b71f6080302a9444f237b7abb8", + // A whole-compilation-database producer is not ready when it starts; it + // is ready when clangd has background-indexed every translation unit the + // database registers. The 180-second default expired on libuv with 62 + // units still indexing, and the client did exactly what it should — it + // fell back, and the row lost the strict provenance it exists to prove. + // These bounds are sized for that work rather than for a per-file server: + // far above the few minutes observed, far below the job timeout, and + // still a bound, so a producer that never becomes ready still fails. + readyTimeoutMs: 1_200_000, + timeoutMs: 600_000, requiredCapabilities: [ "coverage", "diagnostics", @@ -216,6 +226,16 @@ export const LANGUAGE_EXPERIMENTS = [ strictTool: "samchon-clangd", producerRepository: "https://github.com/samchon/llvm-project.git", producerCommit: "dcc73b6579ebb8b71f6080302a9444f237b7abb8", + // A whole-compilation-database producer is not ready when it starts; it + // is ready when clangd has background-indexed every translation unit the + // database registers. The 180-second default expired on libuv with 62 + // units still indexing, and the client did exactly what it should — it + // fell back, and the row lost the strict provenance it exists to prove. + // These bounds are sized for that work rather than for a per-file server: + // far above the few minutes observed, far below the job timeout, and + // still a bound, so a producer that never becomes ready still fails. + readyTimeoutMs: 1_200_000, + timeoutMs: 600_000, requiredCapabilities: [ "coverage", "diagnostics", diff --git a/tests/experiment/src/setup-language.mjs b/tests/experiment/src/setup-language.mjs index 360fbdaf..f0d6046c 100644 --- a/tests/experiment/src/setup-language.mjs +++ b/tests/experiment/src/setup-language.mjs @@ -517,15 +517,17 @@ const installClangGraphProducer = () => { `-DLLVM_FORCE_VC_REVISION=${experiment.producerCommit}`, `-DLLVM_FORCE_VC_REPOSITORY=${experiment.producerRepository}`, ]); - // Build with the machine, not with a number — but do not expect that to be - // worth much here, because it was measured and it was not. The same 2,431 of - // 3,125 steps took 85.1 minutes at a fixed `2` and 81.2 minutes at the four - // the hosted runner advertises: a 4.8 percent gain for twice the job count. - // Four vCPUs are two physical cores behind SMT, and an LLVM compile - // saturates them well before the job count runs out. Sizing by the machine - // is still right — a constant that half-idles a wider machine is a defect - // wherever this runs — but on this runner it is not the constraint, and the - // build's roughly 110-minute floor is not something a job count moves. + // Build with the machine, not with a number. At a fixed `2` this reached + // step 2,431 of 3,125 in 85 minutes and was killed unfinished; at the four + // the hosted runner advertises it completed, start to linked `clangd`, in + // 56. Roughly half, which is what four vCPUs against two ought to buy. + // + // An interim reading of the same runs said otherwise — 2,431 steps took 85.1 + // minutes at two jobs and 81.2 at four, so barely five percent — and that + // reading was wrong, because ninja steps are not equal work. The first + // three-quarters of an LLVM build are its heaviest translation units and its + // tail is mostly cheap; comparing a prefix compares the slow part to itself. + // Only the completed build measures the build. // // Also bounded by installed memory, which is a machine-class bound and not // an out-of-memory guard — worth being exact about, because the two are easy From cda5a4a82ac5d08ac522798725cc80f732d7218b Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Tue, 4 Aug 2026 12:38:59 +0900 Subject: [PATCH 45/52] Stop the topology plane claiming more than it holds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fresh Self-Review round over the integrated head, hunting in product code for the defect the commit passes kept finding in tests: a sentence that claims more than the code establishes. The product contract forbids exactly that, so three findings, all in the arm this cycle added. The join-compatibility reason named the wrong cause. Three conditions withhold file joins and only two had a sentence, so a caller whose two planes describe different repositories was told the code generation had moved while topology loaded — sending them to look for a race that never happened, in the field a reader consults precisely when the joins they expected are missing. Each condition states itself now, and the project-mismatch branch has the negative twin it never had. `next` answered `answer` over an empty result. The contract defines `answer` as "the result carries the evidence; stop", and every other operation calls an empty match `outside` — `lookup` says so in as many words. An empty topology carries no evidence, and `answer` would end a caller's search on the strength of a repository model that never mentioned what they asked about. The public MCP contract documented `provenance`, `coverage` and `unresolved` as "absent only for `escape`". The topology arm never builds a trust envelope, so all three are absent there too. This is the worst place in the repository for a false sentence: the tool description an agent reads is generated from these comments. Corrected in the structure, in the README excerpt that embeds it, and in the parity rule that holds the two together. Two guards are tightened where the fourth commit pass showed them still open. The parallelism pin watched only the build argv while the configure call could cap concurrency from the same region; the cache save's key was asserted nowhere, so saving under a key the restore can never hit passed everything. Both match comment-stripped sources now, and the workflow is read as ordered steps so restore and save are pinned by identity, key, guard and position around the build. They are tripwires refusing the regressions that have happened here and their nearest spellings, and are written down as that rather than as proofs. Four contract comments corrected again, including one whose stated mechanism was wrong: the sibling mutation it described is refused by shard validation, and an edge's `from` resolves through nodes for every kind. --- README.md | 10 +- packages/graph/src/SamchonGraphApplication.ts | 38 +++++-- .../structures/ISamchonGraphApplication.ts | 10 +- ...e_covers_every_graph_node_and_edge_kind.ts | 12 +- ...st_experiment_corpora_are_commit_pinned.ts | 46 ++++++-- ...gy_fences_file_joins_by_code_generation.ts | 37 ++++++ ..._context_protocol_commits_atomic_shards.ts | 11 +- ...lient_restores_retries_and_fails_closed.ts | 4 +- ...kflows_use_current_core_action_runtimes.ts | 107 ++++++++++++------ .../test-graph/src/internal/ContractParity.ts | 6 +- 10 files changed, 205 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index f9bc5c7c..3f64c4ee 100644 --- a/README.md +++ b/README.md @@ -438,20 +438,22 @@ export namespace ISamchonGraphApplication { /** * Strict producer, authority, compiler and build-universe identity for the - * synchronized graph. Absent only for `escape` or a legacy/fallback-only - * dump with no strict producer. + * synchronized graph. Absent for `escape`, for `topology` whose facts come + * from the repository plane and carry their own provenance, and for a + * legacy or fallback-only dump with no strict producer. */ provenance?: ISamchonGraphDump.IProvenance[]; /** * Machine-readable completeness for the relationship families relevant to - * this operation. Absent only for `escape`. + * this operation. Absent for `escape` and for `topology`, which reports + * its own relation coverage inside the result. */ coverage?: ISamchonGraphCoverageSummary; /** * Bounded structured uncertainty for the same operation-scoped families. - * Absent only for `escape`. + * Absent for `escape` and for `topology`, whose plane publishes none. */ unresolved?: ISamchonGraphUnresolvedSummary; diff --git a/packages/graph/src/SamchonGraphApplication.ts b/packages/graph/src/SamchonGraphApplication.ts index 43c6a6a0..1cb2e62d 100644 --- a/packages/graph/src/SamchonGraphApplication.ts +++ b/packages/graph/src/SamchonGraphApplication.ts @@ -155,10 +155,18 @@ export class SamchonGraphApplication implements ISamchonGraphApplication { ...(graph.inputGeneration !== undefined ? { codeInputGeneration: graph.inputGeneration } : {}), + // One reason per condition, because a reason is a claim like any + // other. Collapsing these would report a generation that moved + // to a caller whose two planes describe different repositories + // and never had a generation in common — a sentence the evidence + // does not support, in the field a reader consults precisely + // when the joins they expected are missing. reason: - topology.dump.provenance.length === 0 - ? "No repository-context provider produced a compatible current generation." - : "The code generation moved while topology was loading, or the code dump predates cross-plane generation fencing.", + graph.project !== topology.dump.project + ? "The code graph and the repository-context model describe different projects, so their file identities are not comparable." + : topology.dump.provenance.length === 0 + ? "No repository-context provider produced a compatible current generation." + : "The code generation moved while topology was loading, or the code dump predates cross-plane generation fencing.", }; const result = topology.inspect( props.request, @@ -172,14 +180,24 @@ export class SamchonGraphApplication implements ISamchonGraphApplication { return { audit: "Repository topology is returned from declared or owning-tool models; file joins are included only when the code generation stayed stable across the topology load.", - next: resultNext( - "answer", + // A topology result that matched nothing is `outside`, the same + // answer `lookup` gives a name it could not resolve. `answer` states + // that the result carries the evidence and the caller should stop; + // an empty one carries none, and saying otherwise would end the + // caller's search on the strength of a repository model that never + // mentioned what they asked about. + next: result.nodes.length === 0 - ? "No repository topology node matched the requested query or available provider facts." - : result.truncated - ? "The requested repository orientation is present, and the result states that its configured bounds truncated additional facts." - : "The requested repository orientation is present in this topology result.", - ), + ? resultNext( + "outside", + "No repository topology node matched the requested query or available provider facts.", + ) + : resultNext( + "answer", + result.truncated + ? "The requested repository orientation is present, and the result states that its configured bounds truncated additional facts." + : "The requested repository orientation is present in this topology result.", + ), result, }; } diff --git a/packages/graph/src/structures/ISamchonGraphApplication.ts b/packages/graph/src/structures/ISamchonGraphApplication.ts index 9f10a827..69e5e3f0 100644 --- a/packages/graph/src/structures/ISamchonGraphApplication.ts +++ b/packages/graph/src/structures/ISamchonGraphApplication.ts @@ -186,20 +186,22 @@ export namespace ISamchonGraphApplication { /** * Strict producer, authority, compiler and build-universe identity for the - * synchronized graph. Absent only for `escape` or a legacy/fallback-only - * dump with no strict producer. + * synchronized graph. Absent for `escape`, for `topology` whose facts come + * from the repository plane and carry their own provenance, and for a + * legacy or fallback-only dump with no strict producer. */ provenance?: ISamchonGraphDump.IProvenance[]; /** * Machine-readable completeness for the relationship families relevant to - * this operation. Absent only for `escape`. + * this operation. Absent for `escape` and for `topology`, which reports + * its own relation coverage inside the result. */ coverage?: ISamchonGraphCoverageSummary; /** * Bounded structured uncertainty for the same operation-scoped families. - * Absent only for `escape`. + * Absent for `escape` and for `topology`, whose plane publishes none. */ unresolved?: ISamchonGraphUnresolvedSummary; diff --git a/tests/test-graph/src/features/test_contract_fixture_covers_every_graph_node_and_edge_kind.ts b/tests/test-graph/src/features/test_contract_fixture_covers_every_graph_node_and_edge_kind.ts index d424ffb8..2fca5bd4 100644 --- a/tests/test-graph/src/features/test_contract_fixture_covers_every_graph_node_and_edge_kind.ts +++ b/tests/test-graph/src/features/test_contract_fixture_covers_every_graph_node_and_edge_kind.ts @@ -9,11 +9,13 @@ import { GraphFixtures } from "../internal/GraphFixtures"; * more so after each new family is added. This is the completeness gate for * that. * - * The two halves are not equally strong, which is worth knowing before relying - * on either. The edge half is anchored: it compares the fixture against the - * package's own exported `GRAPH_EDGE_KINDS`, so a new family reaches it by - * existing. The node half compares against a list this suite maintains by - * hand, so a new node kind reaches it only when someone adds it there too. + * How much each half is worth is worth knowing before relying on either. The + * edge half is anchored through one more step: the fixture is compared to a + * suite-local list, and a separate assertion holds that list to the package's + * exported `GRAPH_EDGE_KINDS`, so a new family does reach it by existing — + * except through the traversal-only exemption, which is a second hand-kept + * list. The node half has no such anchor at all: it compares the fixture to a + * hand-kept list and nothing ties that list to the public union. */ export const test_contract_fixture_covers_every_graph_node_and_edge_kind = () => { const { dump } = GraphFixtures.createContractFixture(); diff --git a/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts b/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts index 33d18028..0940245e 100644 --- a/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts +++ b/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts @@ -228,14 +228,23 @@ export const test_experiment_corpora_are_commit_pinned = () => { // is the thing that has to stay correct, so pin the decision rather than its // vocabulary. Naming `os.availableParallelism()` and `os.totalmem()` proves // nothing on its own: a comment saying why they were abandoned contains both - // names, and a `const jobs = 2` under it would satisfy every such check — - // which is precisely the regression this exists to refuse. So the binding is - // asserted end to end instead: the expression that computes the count, and - // the argument that hands that same count to the build. - const clangBuild = region( - setup, - "const installClangGraphProducer", - "const installScipPython", + // names, and a `const jobs = 2` under it would satisfy every such check. + // Comments are therefore stripped before anything is matched, and the + // binding is asserted end to end: the expression that computes the count, + // the argument that hands that same count to the build, and the log that + // makes it visible in a run. + // + // The configure call is inside this region too, and `-DLLVM_PARALLEL_*_JOBS` + // caps concurrency from there without touching `--build` at all. Watching + // only the build argv would leave that door open, so the region must set no + // such flag; if one is ever needed it has to be derived from `jobs` and this + // line has to change with it. + // + // This is a tripwire, not a proof. It refuses the regressions that have + // actually happened here and the nearest spellings of them; it cannot + // enumerate every way to reintroduce a constant. + const clangBuild = withoutLineComments( + region(setup, "const installClangGraphProducer", "const installScipPython"), ); TestValidator.equals( "the native Clang build is sized by the machine and bounded by its memory", @@ -245,9 +254,10 @@ export const test_experiment_corpora_are_commit_pinned = () => { ), /"--parallel",\s*String\(jobs\),/u.test(clangBuild), /"--parallel",\s*(?:"|`|'|\d)/u.test(clangBuild), - clangBuild.includes("console.log("), + /LLVM_PARALLEL_[A-Z_]*JOBS/u.test(clangBuild), + /console\.log\([\s\S]*?String\(jobs\)/u.test(clangBuild), ], - [true, true, false, true], + [true, true, false, false, true], ); // A restored producer is untrusted input, and the whole point of restoring // it is to skip the build that would otherwise have proved what it is. So @@ -519,6 +529,22 @@ export const test_experiment_corpora_are_commit_pinned = () => { ); }; +/** + * One source region with its line comments removed. + * + * These files explain themselves at length, and every identifier an assertion + * looks for is also written in the prose around the code that uses it. Without + * this, `includes` and even a careful regex are satisfied by a comment + * describing the very thing that was deleted — which is how the first version + * of the parallelism pin passed against a hard-coded job count. + */ +function withoutLineComments(source: string): string { + return source + .split("\n") + .filter((line) => !/^\s*\/\//u.test(line)) + .join("\n"); +} + function experimentSource(file: string): string { return fs.readFileSync( path.join(GraphPaths.repositoryRoot, "tests", "experiment", "src", file), diff --git a/tests/test-graph/src/features/test_mcp_topology_fences_file_joins_by_code_generation.ts b/tests/test-graph/src/features/test_mcp_topology_fences_file_joins_by_code_generation.ts index 4f41463b..8b471d2e 100644 --- a/tests/test-graph/src/features/test_mcp_topology_fences_file_joins_by_code_generation.ts +++ b/tests/test-graph/src/features/test_mcp_topology_fences_file_joins_by_code_generation.ts @@ -149,6 +149,37 @@ export const test_mcp_topology_fences_file_joins_by_code_generation = ["unavailable", false, true], ); + // Two planes describing different repositories reach the same + // `unavailable` state as a moved generation, which is why the reason has + // to distinguish them: these two never had a generation in common, and + // reporting one as having moved would send a reader looking for a race + // that did not happen. + const foreign = await new SamchonGraphApplication(graph, () => + new SamchonRepositoryContextMemory( + topologyDump(`${fixture.dump.project}-elsewhere`), + ), + ).inspect_code_graph({ + question: "show repository topology", + draft: { reason: "repository orientation", type: "topology" }, + review: "topology is correct", + request: { type: "topology", limit: 1 }, + }); + TestValidator.equals( + "a topology model for another project names that as the reason", + foreign.result.type === "topology" + ? [ + foreign.result.join.state, + foreign.result.join.reason, + foreign.result.edges.some((edge) => edge.kind === "joins-file"), + ] + : [], + [ + "unavailable", + "The code graph and the repository-context model describe different projects, so their file identities are not comparable.", + false, + ], + ); + let loads = 0; const moved = SamchonGraphMemory.from({ ...fixture.dump, @@ -204,6 +235,11 @@ export const test_mcp_topology_fences_file_joins_by_code_generation = ? providerUnavailable.result.join : undefined, providerUnavailable.next.reason, + // The action, not only the sentence beside it. `answer` tells the + // caller to stop because the result carries the evidence, and an + // empty topology carries none — the same judgement `lookup` makes + // for a name it could not resolve. + providerUnavailable.next.action, ], [ { @@ -214,6 +250,7 @@ export const test_mcp_topology_fences_file_joins_by_code_generation = "No repository-context provider produced a compatible current generation.", }, "No repository topology node matched the requested query or available provider facts.", + "outside", ], ); diff --git a/tests/test-graph/src/features/test_repository_context_protocol_commits_atomic_shards.ts b/tests/test-graph/src/features/test_repository_context_protocol_commits_atomic_shards.ts index 262c1426..7c5de2bf 100644 --- a/tests/test-graph/src/features/test_repository_context_protocol_commits_atomic_shards.ts +++ b/tests/test-graph/src/features/test_repository_context_protocol_commits_atomic_shards.ts @@ -241,10 +241,13 @@ export const test_repository_context_protocol_commits_atomic_shards = const upsert = changedUpsert(frames); upsert.shard.edges[0]!.from = ""; }), - // The endpoint arm the mutation above cannot reach: that one edits a - // `contains` edge and so takes the node-closure branch. A join naming a - // file the shard never declared is how the topology plane would start - // pointing at code the code generation has no record of. + // The only edge endpoint that is checked against the file list rather + // than the node list. `from` always resolves through nodes whatever the + // kind, and the sibling mutation above blanks a `from` and is refused + // earlier still, by shard validation. So this arm — a `joins-file` + // naming a file the shard never declared — is reached by nothing else, + // and it is how the topology plane would start pointing at code the code + // generation has no record of. mutate(transaction(3, unchanged, changedShard()), (frames) => { const upsert = changedUpsert(frames); upsert.shard.edges.find((edge) => edge.kind === "joins-file")!.to = diff --git a/tests/test-graph/src/features/test_rust_hir_client_restores_retries_and_fails_closed.ts b/tests/test-graph/src/features/test_rust_hir_client_restores_retries_and_fails_closed.ts index 6c153b5f..525a8304 100644 --- a/tests/test-graph/src/features/test_rust_hir_client_restores_retries_and_fails_closed.ts +++ b/tests/test-graph/src/features/test_rust_hir_client_restores_retries_and_fails_closed.ts @@ -17,8 +17,8 @@ import { GraphPaths } from "../internal/GraphPaths.js"; * is retried until the ready deadline rather than published, a no-op returns * the exact resident object rather than an equal copy, a rejected restart * checkpoint discards the persisted generation instead of reusing it, and a - * checkpoint that cannot be written reaches the caller as a warning on a - * snapshot that was still published. + * checkpoint that cannot be written surfaces as a warning on the returned + * snapshot rather than as a failed refresh. * * The refusal cases assert the strictest available form of atomicity, which is * why they run on fresh clients: a producer response the adapter rejects, and diff --git a/tests/test-graph/src/features/test_workflows_use_current_core_action_runtimes.ts b/tests/test-graph/src/features/test_workflows_use_current_core_action_runtimes.ts index b6fa4c69..91a387cc 100644 --- a/tests/test-graph/src/features/test_workflows_use_current_core_action_runtimes.ts +++ b/tests/test-graph/src/features/test_workflows_use_current_core_action_runtimes.ts @@ -150,13 +150,12 @@ export const test_workflows_use_current_core_action_runtimes = () => { // This originally refused any per-language exception, and the reason it gave // was a cause: the lane that wanted more than ninety minutes wanted it // because its provider had been serialized, so raising the budget preserved - // that cause instead of bounding it. Measurement retired the reason. The - // same 2,431 of 3,125 build steps take 85.1 minutes at two jobs and 81.2 at - // four, because the runner's four vCPUs are two physical cores; roughly 110 - // minutes is the floor and no job count moves it. C and C++ build a compiler - // from source and the other fourteen rows install a released producer, so - // the difference is a property of those two rows and not a defect inside - // them. + // that cause instead of bounding it. The serialization was real and was + // removed — the build went from unfinished at 85 minutes to complete in 56 — + // and ninety still does not fit, because nine minutes of setup and a + // real-corpus lifecycle run sit around it. C and C++ build a compiler from + // source and the other fourteen rows install a released producer, so the + // difference is a property of those two rows and not a defect inside them. // // Asserted as the exact expression. That is the same shape of assertion as // the single number it replaces, not a tighter one — what changed is the @@ -195,34 +194,43 @@ export const test_workflows_use_current_core_action_runtimes = () => { // check, the build would run in full, and — having hit an exact key — never // re-save. Permanent silent full-cost rebuilding, with the widened bound as // the normal path. - const cacheSteps = experimentJob - .split(/\n - name: /u) - .filter((step) => step.includes("uses: actions/cache")); - TestValidator.equals( - "the built producer is restored and saved per pinned commit, not rebuilt", - cacheSteps.map((step) => - [ - /uses: actions\/cache\/(restore|save)@v6/u.exec(step)?.[1], - step.includes("path: tests/experiment/.work/tools"), - step.includes( - "(matrix.language == 'c' || matrix.language == 'cpp')", - ), - ].join(" "), - ), - ["restore true true", "save true true"], + const steps = experimentSteps(experimentJob); + const restore = steps.find((step) => + step.body.includes("uses: actions/cache/restore@v6"), ); - TestValidator.predicate( - "the restore key names every input the built bytes depend on", - experimentJob.includes( - "hashFiles('packages/graph/src/provider/cpp/CPP_CLANG_PRODUCER_COMMIT.ts', 'tests/experiment/src/catalog.mjs', 'tests/experiment/src/setup-language.mjs')", - ) && - // Saved on a miss and before the corpus run, so a correct build is not - // discarded because an unrelated later assertion went red. - experimentJob.includes( - "steps.clang_producer.outputs.cache-hit != 'true'", - ) && - experimentJob.indexOf("actions/cache/save") < - experimentJob.indexOf("Run LSP experiment"), + const save = steps.find((step) => + step.body.includes("uses: actions/cache/save@v6"), + ); + TestValidator.equals( + "the producer is restored and saved around the build, on the same key", + [ + restore?.body.includes("id: clang_producer"), + restore?.body.includes("path: tests/experiment/.work/tools"), + restore?.body.includes( + "key: clang-producer-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('packages/graph/src/provider/cpp/CPP_CLANG_PRODUCER_COMMIT.ts', 'tests/experiment/src/catalog.mjs', 'tests/experiment/src/setup-language.mjs') }}", + ), + save?.body.includes("path: tests/experiment/.work/tools"), + save?.body.includes( + "key: ${{ steps.clang_producer.outputs.cache-primary-key }}", + ), + save?.body.includes("steps.clang_producer.outputs.cache-hit != 'true'"), + [restore, save].every((step) => + step?.body.includes("(matrix.language == 'c' || matrix.language == 'cpp')"), + ), + // Order is the whole safety argument. Saving before the build writes an + // empty tree under the exact primary key, which then restores as a hit + // forever, fails `setup`'s version check, rebuilds, and never re-saves — + // the same terminal state as saving under a key the restore cannot hit. + // Saving after the corpus run instead loses a correct build to an + // unrelated assertion. + [restore, "Install language server", save, "Run LSP experiment"].map( + (entry) => + typeof entry === "string" + ? steps.findIndex((step) => step.name === entry) + : (entry?.index ?? -1), + ), + ], + [true, true, true, true, true, true, true, [8, 9, 10, 11]], ); TestValidator.predicate( "the Rust experiment launches the exact binary provisioned by setup", @@ -299,6 +307,37 @@ function occurrences(text: string, needle: string): number { return text.split(needle).length - 1; } +/** + * The matrix job's steps, in order, with their comments removed. + * + * Two properties this file needs and cannot get from a substring search over + * the whole job. Order, because the cache save has to happen after the step + * that builds and before the step that can fail for unrelated reasons, and a + * job-wide `indexOf` cannot tell those apart from a save at the top. And + * comment removal, because this workflow explains itself at length: every + * string these assertions look for also appears in prose a few lines above the + * step that implements it, so an `includes` over raw text is satisfied by the + * explanation of a step that was deleted. + */ +function experimentSteps( + job: string, +): { name: string; body: string; index: number }[] { + return job + .split(/\n - name: /u) + .slice(1) + .map((chunk, index) => { + const body = chunk + .split("\n") + .filter((line) => !/^\s*#/u.test(line)) + .join("\n"); + return { + name: body.split("\n", 1)[0]!.trim(), + body, + index, + }; + }); +} + /** * The `latest_update` job alone, so an assertion about it cannot be satisfied * by the same text sitting in a different job. diff --git a/tests/test-graph/src/internal/ContractParity.ts b/tests/test-graph/src/internal/ContractParity.ts index 520d8ef2..7db1750c 100644 --- a/tests/test-graph/src/internal/ContractParity.ts +++ b/tests/test-graph/src/internal/ContractParity.ts @@ -630,11 +630,11 @@ export namespace ContractParity { ].join("\n"), to: [ "audit: string;", - "/** Strict producer, authority, compiler and build-universe identity for the synchronized graph. Absent only for `escape` or a legacy/fallback-only dump with no strict producer. */", + "/** Strict producer, authority, compiler and build-universe identity for the synchronized graph. Absent for `escape`, for `topology` whose facts come from the repository plane and carry their own provenance, and for a legacy or fallback-only dump with no strict producer. */", "provenance?: ISamchonGraphDump.IProvenance[];", - "/** Machine-readable completeness for the relationship families relevant to this operation. Absent only for `escape`. */", + "/** Machine-readable completeness for the relationship families relevant to this operation. Absent for `escape` and for `topology`, which reports its own relation coverage inside the result. */", "coverage?: ISamchonGraphCoverageSummary;", - "/** Bounded structured uncertainty for the same operation-scoped families. Absent only for `escape`. */", + "/** Bounded structured uncertainty for the same operation-scoped families. Absent for `escape` and for `topology`, whose plane publishes none. */", "unresolved?: ISamchonGraphUnresolvedSummary;", ].join("\n"), }, From 66ca4d97b175e01d808dcc2c2ebc334cd81a854c Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Tue, 4 Aug 2026 14:17:10 +0900 Subject: [PATCH 46/52] Tell a topology caller which of four things went wrong MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reason split landed one commit ago covered the two conditions a test reached and merged the two it did not. The commit pass found that the merged pair contains the branch production takes most: `startServer` strips the generation token from a `--graph-file` dump on purpose, because nothing revalidates it against the current checkout, so `inputGeneration` is always undefined there and every topology call on a static server was told the code generation had moved while topology was loading. Nothing moved. The token was withheld deliberately, and that case now says so. It was also the one reason no test asserted; all four are pinned now. The empty-result verdict was likewise too coarse. Topology matches by exact equality against an id, a name or a coordinate — no scoring, no near miss — so a query that misses against a model full of nodes is a spelling, not an absence. `outside` tells a caller to escape and read source; the same call without the query would have listed everything the model holds. That case is `clarify` now, and `outside` is kept for the one case that earns it: a repository plane with no nodes at all, where the answer really is elsewhere. The comment that justified the previous verdict was also wrong that an empty result "carries none" of the evidence. It still carries provenance, coverage, the generation and the join state — a caller asking whether any provider models this repository is answered by exactly that payload. --- packages/graph/src/SamchonGraphApplication.ts | 55 +++++++++++++------ ...gy_fences_file_joins_by_code_generation.ts | 55 +++++++++++++++++-- 2 files changed, 86 insertions(+), 24 deletions(-) diff --git a/packages/graph/src/SamchonGraphApplication.ts b/packages/graph/src/SamchonGraphApplication.ts index 1cb2e62d..3de9e884 100644 --- a/packages/graph/src/SamchonGraphApplication.ts +++ b/packages/graph/src/SamchonGraphApplication.ts @@ -156,17 +156,25 @@ export class SamchonGraphApplication implements ISamchonGraphApplication { ? { codeInputGeneration: graph.inputGeneration } : {}), // One reason per condition, because a reason is a claim like any - // other. Collapsing these would report a generation that moved - // to a caller whose two planes describe different repositories - // and never had a generation in common — a sentence the evidence - // does not support, in the field a reader consults precisely - // when the joins they expected are missing. + // other, and this is the field a reader consults precisely when + // the joins they expected are missing. Four conditions withhold + // joins and the code can tell all four apart, so merging any two + // of them reports a cause that did not happen. + // + // The generation-absent case is the one a static server takes on + // every call: `startServer` strips the token from a `--graph-file` + // dump on purpose, because nothing revalidates it against the + // current checkout. Folding that into "the generation moved" + // would tell every such caller about a race that cannot occur + // there, for a token that was withheld deliberately. reason: graph.project !== topology.dump.project ? "The code graph and the repository-context model describe different projects, so their file identities are not comparable." : topology.dump.provenance.length === 0 ? "No repository-context provider produced a compatible current generation." - : "The code generation moved while topology was loading, or the code dump predates cross-plane generation fencing.", + : graph.inputGeneration === undefined + ? "This code graph carries no input generation to fence against: a graph file served without revalidation withholds one, and dumps written before cross-plane fencing never had one." + : "The code generation moved while topology was loading.", }; const result = topology.inspect( props.request, @@ -180,24 +188,35 @@ export class SamchonGraphApplication implements ISamchonGraphApplication { return { audit: "Repository topology is returned from declared or owning-tool models; file joins are included only when the code generation stayed stable across the topology load.", - // A topology result that matched nothing is `outside`, the same - // answer `lookup` gives a name it could not resolve. `answer` states - // that the result carries the evidence and the caller should stop; - // an empty one carries none, and saying otherwise would end the - // caller's search on the strength of a repository model that never - // mentioned what they asked about. + // `answer` states that the result carries what the caller asked for + // and they should stop, so an empty one may not claim it. Which of + // the other two verdicts applies depends on why it is empty, and + // topology can tell: its query is exact equality against an id, a + // name or a coordinate, with no scoring and no near miss. + // + // So a query that matched nothing against a model that does hold + // nodes is `clarify` — the same call without it lists what exists, + // which is a restatement rather than an escape. Only a model with no + // nodes at all is `outside`, and then the repository plane really + // has nothing to say and the answer is elsewhere. Calling the first + // case `outside` would send a caller to read source over a spelling. next: - result.nodes.length === 0 + result.nodes.length !== 0 ? resultNext( - "outside", - "No repository topology node matched the requested query or available provider facts.", - ) - : resultNext( "answer", result.truncated ? "The requested repository orientation is present, and the result states that its configured bounds truncated additional facts." : "The requested repository orientation is present in this topology result.", - ), + ) + : topology.dump.nodes.length !== 0 + ? resultNext( + "clarify", + "No repository topology node has that exact id, name or coordinate; this plane matches exactly, so restate the request or drop the query to list what it holds.", + ) + : resultNext( + "outside", + "No repository-context provider published any topology node for this project, so the repository plane has nothing to answer from.", + ), result, }; } diff --git a/tests/test-graph/src/features/test_mcp_topology_fences_file_joins_by_code_generation.ts b/tests/test-graph/src/features/test_mcp_topology_fences_file_joins_by_code_generation.ts index 8b471d2e..0aa7021c 100644 --- a/tests/test-graph/src/features/test_mcp_topology_fences_file_joins_by_code_generation.ts +++ b/tests/test-graph/src/features/test_mcp_topology_fences_file_joins_by_code_generation.ts @@ -145,8 +145,20 @@ export const test_mcp_topology_fences_file_joins_by_code_generation = unavailable.result.type === "topology" ? unavailable.result.truncated : false, + // The reason, not only the state. A graph file served without + // revalidation reaches this branch on every call, so it is the + // sentence most callers actually read, and it must not tell them a + // generation moved when one was withheld on purpose. + unavailable.result.type === "topology" + ? unavailable.result.join.reason + : undefined, + ], + [ + "unavailable", + false, + true, + "This code graph carries no input generation to fence against: a graph file served without revalidation withholds one, and dumps written before cross-plane fencing never had one.", ], - ["unavailable", false, true], ); // Two planes describing different repositories reach the same @@ -203,8 +215,39 @@ export const test_mcp_topology_fences_file_joins_by_code_generation = stale.result.type === "topology" ? stale.result.edges.some((edge) => edge.kind === "joins-file") : true, + stale.result.type === "topology" + ? stale.result.join.reason + : undefined, + // A model that holds nodes but matched none is a restatement, not an + // escape: the same call without `query` lists what it holds. Only a + // model holding nothing at all sends the caller elsewhere. + stale.next.action, + ], + [ + "unavailable", + false, + "The code generation moved while topology was loading.", + "answer", + ], + ); + + const misspelled = await new SamchonGraphApplication(graph, () => + topology, + ).inspect_code_graph({ + question: "show the lib package", + draft: { reason: "repository orientation", type: "topology" }, + review: "topology is correct", + request: { type: "topology", query: "sorce" }, + }); + TestValidator.equals( + "an exact-match miss against a populated model is a restatement", + [ + misspelled.next.action, + misspelled.result.type === "topology" + ? misspelled.result.nodes.length + : -1, ], - ["unavailable", false], + ["clarify", 0], ); const emptyTopology = new SamchonRepositoryContextMemory({ @@ -236,9 +279,9 @@ export const test_mcp_topology_fences_file_joins_by_code_generation = : undefined, providerUnavailable.next.reason, // The action, not only the sentence beside it. `answer` tells the - // caller to stop because the result carries the evidence, and an - // empty topology carries none — the same judgement `lookup` makes - // for a name it could not resolve. + // caller to stop because the result carries what they asked for, and + // a plane holding no nodes at all carries nothing — this is the one + // empty case where the answer really is elsewhere. providerUnavailable.next.action, ], [ @@ -249,7 +292,7 @@ export const test_mcp_topology_fences_file_joins_by_code_generation = reason: "No repository-context provider produced a compatible current generation.", }, - "No repository topology node matched the requested query or available provider facts.", + "No repository-context provider published any topology node for this project, so the repository plane has nothing to answer from.", "outside", ], ); From 48fb9bfe3ff00530b278cbaaf38bddff815e7382 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Tue, 4 Aug 2026 14:17:40 +0900 Subject: [PATCH 47/52] Size the C/C++ waits for nine refreshes, not one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ready timeout raised two commits ago was a per-refresh ceiling treated as a per-row budget. `CppGraphClient` re-arms it on every `refresh()`, and the strict lifecycle issues nine — cold, unchanged, edit, create, rename, delete, build config, failure, retry. Nine cold waits at twenty minutes exceed the job bound on their own, and a row that dies there dies without a diagnosis. Ten minutes and five, and the comment now says plainly that these bound a refresh and not the row, that the job timeout is the only thing bounding the sum, and why that is remote rather than likely: only the first refresh indexes from nothing. The same comment claimed the wait was "far above the few minutes observed". No C or C++ row has ever observed this producer becoming ready. The only datum is a lower bound — 180 seconds was not enough, with 62 units still indexing — and the rate that implies puts the remainder near two minutes. Ten is that with room, and the comment says so instead of citing an observation that does not exist. The parallelism claim is retracted properly this time. Two commits ago it said "roughly half"; before that, "barely five percent". Both were read out of numbers that cannot support either: 2,431 steps in 81.2 minutes and a complete 3,125-step build in 56.1 cannot both describe four jobs, and the two-job run was killed unfinished so its total is unknown. What the data does show is 56 minutes and 107 minutes for the same build, same commit, same job count, in one workflow on two runners — hosted-runner variance of about a factor of two, which swamps whatever this line is worth. Sizing by the machine is justified on the principle that a constant leaving half a runner idle is wrong wherever it runs, and the effect size is recorded as unmeasured. Also corrected: a surviving "110-minute build" in the workflow, a "nine minutes of setup" that was 2.8, and a link-jobs justification claiming no run had reached the link in the same block that now measures to a linked binary. Two guard repairs ride along, both from the same pass. The workflow ordering assertion pinned absolute step positions while arguing about relative order, so an unrelated step inserted above would fail it; it compares positions now. And the restored-producer region was matched with its comments intact, unlike its sibling. --- .github/workflows/experiment.yml | 20 +++---- tests/experiment/src/catalog.mjs | 56 +++++++++++++------ tests/experiment/src/setup-language.mjs | 28 ++++++---- ...st_experiment_corpora_are_commit_pinned.ts | 10 ++-- ...kflows_use_current_core_action_runtimes.ts | 32 ++++++++--- 5 files changed, 96 insertions(+), 50 deletions(-) diff --git a/.github/workflows/experiment.yml b/.github/workflows/experiment.yml index 52109a22..9574fa17 100644 --- a/.github/workflows/experiment.yml +++ b/.github/workflows/experiment.yml @@ -77,17 +77,17 @@ jobs: # Fourteen rows install a released producer and finish in minutes; the # ninety-minute bound is theirs and stays exactly where it is. C and C++ # build a compiler from source, and that is a property of the row rather - # than a defect inside it. Measured on this runner: the build takes 56 - # minutes from its first step to a linked `clangd`, after nine minutes of - # checkout, install, toolchain and configure, and the real-corpus lifecycle - # run follows that. Ninety did not fit; 150 leaves room without pretending - # to predict, and it is still a bound, so a lane that hangs is still - # caught. The wider bound is granted only to the two rows that build. + # than a defect inside it. Measured: three minutes of checkout, install and + # package build, then 56 minutes to a linked `clangd` on one runner and 107 + # on another in the same workflow, then the real-corpus lifecycle run. + # Ninety did not fit. 150 covers the fast runner comfortably and the slow + # one barely, and it is still a bound, so a lane that hangs is caught. Only + # the two rows that build a compiler get it. # - # The restore below should keep an ordinary push far away from it, though - # that is an expectation rather than a property: Actions caches are + # The restore below is what should keep an ordinary push away from that + # cost, and it is an expectation rather than a property: Actions caches are # branch-scoped and evicted, so a first push on a fresh branch still pays - # the whole build. + # the whole build, on whichever runner it draws. timeout-minutes: ${{ (matrix.language == 'c' || matrix.language == 'cpp') && 150 || 90 }} strategy: fail-fast: false @@ -168,7 +168,7 @@ jobs: # Restore and save are split so the save runs even when a later step # fails. A campaign iterating on these lanes is exactly the case where # the producer builds and the experiment does not, and `cache`'s combined - # form would discard a correct 110-minute build because a corpus + # form would discard an hour or more of correct build because a corpus # assertion afterwards went red. - name: Restore the pinned Clang producer id: clang_producer diff --git a/tests/experiment/src/catalog.mjs b/tests/experiment/src/catalog.mjs index 15f34468..c0f2a4f5 100644 --- a/tests/experiment/src/catalog.mjs +++ b/tests/experiment/src/catalog.mjs @@ -154,14 +154,26 @@ export const LANGUAGE_EXPERIMENTS = [ producerCommit: "dcc73b6579ebb8b71f6080302a9444f237b7abb8", // A whole-compilation-database producer is not ready when it starts; it // is ready when clangd has background-indexed every translation unit the - // database registers. The 180-second default expired on libuv with 62 - // units still indexing, and the client did exactly what it should — it - // fell back, and the row lost the strict provenance it exists to prove. - // These bounds are sized for that work rather than for a per-file server: - // far above the few minutes observed, far below the job timeout, and - // still a bound, so a producer that never becomes ready still fails. - readyTimeoutMs: 1_200_000, - timeoutMs: 600_000, + // database registers. The 180-second default expired on libuv with 62 of + // them still indexing, the routing layer fell back as it should, and the + // row lost the strict provenance it exists to prove. + // + // How long readiness actually takes has never been observed: no C or C++ + // row has ever reached it. The only datum is a lower bound — 180 seconds + // was not enough, with 62 units left — and the rate it implies puts the + // remainder near two more minutes. Ten is that with room, not a measured + // requirement. + // + // These are per-refresh ceilings, and the strict lifecycle issues nine + // refreshes, so they do not bound the row: nine cold waits would exceed + // the job timeout on their own and be killed by it without a diagnosis. + // What makes that remote rather than likely is that only the first + // refresh indexes from nothing; the rest are incremental against a warm + // database. The numbers are chosen so one cold index fits comfortably and + // a producer that never becomes ready still fails its row rather than + // hanging it — not so that every pathological path stays inside the job. + readyTimeoutMs: 600_000, + timeoutMs: 300_000, requiredCapabilities: [ "coverage", "diagnostics", @@ -228,14 +240,26 @@ export const LANGUAGE_EXPERIMENTS = [ producerCommit: "dcc73b6579ebb8b71f6080302a9444f237b7abb8", // A whole-compilation-database producer is not ready when it starts; it // is ready when clangd has background-indexed every translation unit the - // database registers. The 180-second default expired on libuv with 62 - // units still indexing, and the client did exactly what it should — it - // fell back, and the row lost the strict provenance it exists to prove. - // These bounds are sized for that work rather than for a per-file server: - // far above the few minutes observed, far below the job timeout, and - // still a bound, so a producer that never becomes ready still fails. - readyTimeoutMs: 1_200_000, - timeoutMs: 600_000, + // database registers. The 180-second default expired on libuv with 62 of + // them still indexing, the routing layer fell back as it should, and the + // row lost the strict provenance it exists to prove. + // + // How long readiness actually takes has never been observed: no C or C++ + // row has ever reached it. The only datum is a lower bound — 180 seconds + // was not enough, with 62 units left — and the rate it implies puts the + // remainder near two more minutes. Ten is that with room, not a measured + // requirement. + // + // These are per-refresh ceilings, and the strict lifecycle issues nine + // refreshes, so they do not bound the row: nine cold waits would exceed + // the job timeout on their own and be killed by it without a diagnosis. + // What makes that remote rather than likely is that only the first + // refresh indexes from nothing; the rest are incremental against a warm + // database. The numbers are chosen so one cold index fits comfortably and + // a producer that never becomes ready still fails its row rather than + // hanging it — not so that every pathological path stays inside the job. + readyTimeoutMs: 600_000, + timeoutMs: 300_000, requiredCapabilities: [ "coverage", "diagnostics", diff --git a/tests/experiment/src/setup-language.mjs b/tests/experiment/src/setup-language.mjs index f0d6046c..9595023e 100644 --- a/tests/experiment/src/setup-language.mjs +++ b/tests/experiment/src/setup-language.mjs @@ -517,17 +517,22 @@ const installClangGraphProducer = () => { `-DLLVM_FORCE_VC_REVISION=${experiment.producerCommit}`, `-DLLVM_FORCE_VC_REPOSITORY=${experiment.producerRepository}`, ]); - // Build with the machine, not with a number. At a fixed `2` this reached - // step 2,431 of 3,125 in 85 minutes and was killed unfinished; at the four - // the hosted runner advertises it completed, start to linked `clangd`, in - // 56. Roughly half, which is what four vCPUs against two ought to buy. + // Build with the machine, not with a number. Note what that is and is not + // claiming, because two earlier versions of this comment claimed more. // - // An interim reading of the same runs said otherwise — 2,431 steps took 85.1 - // minutes at two jobs and 81.2 at four, so barely five percent — and that - // reading was wrong, because ninja steps are not equal work. The first - // three-quarters of an LLVM build are its heaviest translation units and its - // tail is mostly cheap; comparing a prefix compares the slow part to itself. - // Only the completed build measures the build. + // Every recorded build of this producer, all at the advertised job count + // except the first: 2,431 of 3,125 steps in 85 minutes and killed unfinished + // at a fixed `2`; 2,431 steps in 81.2 minutes; a complete build in 56.1; a + // complete build in 107. The last two are the same commit and the same job + // count, in one workflow, on two runners. Hosted-runner performance varies + // by roughly a factor of two, which swamps the difference this line makes + // and leaves no clean two-against-four comparison in the data at all. + // + // So the reason for sizing by the machine is the principle, not a measured + // speedup: a constant that leaves half a runner idle is wrong wherever it + // runs, and the effect size here is unmeasured. An earlier comment reported + // "roughly half" and another "barely five percent"; both read a difference + // out of numbers that could not support one. // // Also bounded by installed memory, which is a machine-class bound and not // an out-of-memory guard — worth being exact about, because the two are easy @@ -537,7 +542,8 @@ const installClangGraphProducer = () => { // compile concurrency only; the `clangd` link is a single build edge that // runs whatever this number is, and LLVM's own controls for that // (`LLVM_PARALLEL_LINK_JOBS` and friends) are deliberately not set here - // because no run has yet reached the link to measure it. Two GiB per compile + // because the runs that reached the link reached it without trouble, so + // there is nothing yet to size them against. Two GiB per compile // job is this repository's figure, chosen as a conventional one; it is not // quoted from LLVM. // diff --git a/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts b/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts index 0940245e..1a2b9b8b 100644 --- a/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts +++ b/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts @@ -271,10 +271,12 @@ export const test_experiment_corpora_are_commit_pinned = () => { // Bounded to the predicate's own body. An unbounded `[\s\S]*?` would let a // deleted check pass by matching the identical text in the build path below // it, so the region is what makes a deletion visible. - const restoredProducer = region( - setup, - "const installedClangGraphProducer", - "const installClangGraphProducer", + const restoredProducer = withoutLineComments( + region( + setup, + "const installedClangGraphProducer", + "const installClangGraphProducer", + ), ); TestValidator.equals( "a restored native Clang producer is re-proved against the pin before reuse", diff --git a/tests/test-graph/src/features/test_workflows_use_current_core_action_runtimes.ts b/tests/test-graph/src/features/test_workflows_use_current_core_action_runtimes.ts index 91a387cc..39c1b8f3 100644 --- a/tests/test-graph/src/features/test_workflows_use_current_core_action_runtimes.ts +++ b/tests/test-graph/src/features/test_workflows_use_current_core_action_runtimes.ts @@ -151,9 +151,9 @@ export const test_workflows_use_current_core_action_runtimes = () => { // was a cause: the lane that wanted more than ninety minutes wanted it // because its provider had been serialized, so raising the budget preserved // that cause instead of bounding it. The serialization was real and was - // removed — the build went from unfinished at 85 minutes to complete in 56 — - // and ninety still does not fit, because nine minutes of setup and a - // real-corpus lifecycle run sit around it. C and C++ build a compiler from + // removed, and ninety still does not fit — the same build completed in 56 + // minutes on one runner and 107 on another in one workflow, with setup and a + // real-corpus lifecycle run around it. C and C++ build a compiler from // source and the other fourteen rows install a released producer, so the // difference is a property of those two rows and not a defect inside them. // @@ -223,14 +223,20 @@ export const test_workflows_use_current_core_action_runtimes = () => { // the same terminal state as saving under a key the restore cannot hit. // Saving after the corpus run instead loses a correct build to an // unrelated assertion. - [restore, "Install language server", save, "Run LSP experiment"].map( - (entry) => - typeof entry === "string" - ? steps.findIndex((step) => step.name === entry) - : (entry?.index ?? -1), + // + // Relative, not absolute. The argument is about what comes before what, + // so pinning positions would make an unrelated step inserted anywhere + // above fail an assertion that has nothing to say about it. + isStrictlyOrdered( + [restore, "Install language server", save, "Run LSP experiment"].map( + (entry) => + typeof entry === "string" + ? steps.findIndex((step) => step.name === entry) + : (entry?.index ?? -1), + ), ), ], - [true, true, true, true, true, true, true, [8, 9, 10, 11]], + [true, true, true, true, true, true, true, true], ); TestValidator.predicate( "the Rust experiment launches the exact binary provisioned by setup", @@ -307,6 +313,14 @@ function occurrences(text: string, needle: string): number { return text.split(needle).length - 1; } +/** Whether every position was found and each one follows the last. */ +function isStrictlyOrdered(positions: readonly number[]): boolean { + return positions.every( + (position, index) => + position >= 0 && (index === 0 || position > positions[index - 1]!), + ); +} + /** * The matrix job's steps, in order, with their comments removed. * From 587c6f18b73061e7efb1c40ab46c776bc4e5df0a Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Tue, 4 Aug 2026 17:24:39 +0900 Subject: [PATCH 48/52] Back off when a producer says it is not ready MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four C/C++ experiment runs died with the runner shutting down, and the reading this campaign carried — that long jobs get reclaimed — was wrong. The re-run restored the producer from cache, finished its install step in 21 seconds, and died the same way five minutes in. Lined up against the only event the four share: cda5a4a c 4m47s after clangd began indexing 48fb9bf c 4m37s re-run c 4m46s re-run cpp 4m23s Always within seconds of the same offset from the same event, which is not what infrastructure variance looks like. `requestSnapshot` polls every 50 ms with no backoff, and a producer answering "not ready" is a producer indexing the whole compilation database. Every retry asks that same busy process to assemble a paged snapshot again — roughly 5,400 requests over four and a half minutes, aimed squarely at whatever is already using the machine. The campaign is what surfaced it. At the old 180-second ready timeout the lane gave up before the pressure mattered; raising the timeout so the producer could finish turned "fall back at three minutes" into "poll until the host dies at four and a half". The commit pass on 919a817 named this and it was waved through as pre-existing. It was pre-existing, and it was also about to become the failure. Both clients back off geometrically now, 50 ms to a five-second cap: about sixty requests over that window instead of 5,400. A short first wait keeps a producer that is ready-in-a-moment fast, and the cap keeps a long index cheap. Rust has never shown this — its producer becomes ready quickly on the pinned corpus — but the loop is the same shape and should not be the one left to find out on a larger workspace. --- packages/graph/src/provider/cpp/CppGraphClient.ts | 15 ++++++++++++++- .../graph/src/provider/rust/RustGraphClient.ts | 12 +++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/graph/src/provider/cpp/CppGraphClient.ts b/packages/graph/src/provider/cpp/CppGraphClient.ts index 9e07646f..a2812367 100644 --- a/packages/graph/src/provider/cpp/CppGraphClient.ts +++ b/packages/graph/src/provider/cpp/CppGraphClient.ts @@ -17,6 +17,7 @@ const SERVER_CANCELLED = -32802; const CONTENT_MODIFIED = -32801; const DEFAULT_READY_TIMEOUT_MS = 300_000; const RETRY_DELAY_MS = 50; +const MAX_RETRY_DELAY_MS = 5_000; const PAGE_SHARDS = 32; /** Resident LSP client for the pinned clangd graph-snapshot producer. */ @@ -183,6 +184,7 @@ export class CppGraphClient implements IBulkGraphSession { signal: AbortSignal, ): Promise { const deadline = performance.now() + this.readyTimeoutMs; + let backoff = RETRY_DELAY_MS; for (;;) { throwIfAborted(signal); try { @@ -200,7 +202,18 @@ export class CppGraphClient implements IBulkGraphSession { `C/C++ clang graph: producer did not become ready within ${String(this.readyTimeoutMs)} ms: ${error.message}`, ); } - await delay(RETRY_DELAY_MS, signal); + await delay(backoff, signal); + // Backing off, because polling a producer that is busy becoming ready + // costs the producer more than it costs this loop. "Not ready" here + // means clangd is indexing the whole compilation database, and every + // retry asks that same busy process to assemble a paged snapshot + // again. At a flat 50 ms this issued roughly 5,400 requests over four + // and a half minutes and took the CI runner down with it, four times, + // always within seconds of the same offset — which is what a fixed + // interval looks like when the thing being polled is the thing under + // load. A short first wait keeps a producer that is ready-in-a-moment + // fast; the cap keeps a long index cheap. + backoff = Math.min(backoff * 2, MAX_RETRY_DELAY_MS); } } } diff --git a/packages/graph/src/provider/rust/RustGraphClient.ts b/packages/graph/src/provider/rust/RustGraphClient.ts index ad732880..69d5b72d 100644 --- a/packages/graph/src/provider/rust/RustGraphClient.ts +++ b/packages/graph/src/provider/rust/RustGraphClient.ts @@ -15,6 +15,7 @@ const SERVER_CANCELLED = -32802; const CONTENT_MODIFIED = -32801; const DEFAULT_READY_TIMEOUT_MS = 300_000; const RETRY_DELAY_MS = 50; +const MAX_RETRY_DELAY_MS = 5_000; /** Resident LSP client for the pinned HIR graphSnapshot producer. */ export class RustGraphClient implements IBulkGraphSession { @@ -194,6 +195,7 @@ export class RustGraphClient implements IBulkGraphSession { ? this.adapter.persistedCheckpoint : undefined; this.checkpointPending = false; + let backoff = RETRY_DELAY_MS; for (;;) { throwIfAborted(signal); const params: IRustGraphSnapshotParams = { @@ -235,7 +237,15 @@ export class RustGraphClient implements IBulkGraphSession { `rust HIR graph: producer did not become ready within ${String(this.readyTimeoutMs)} ms: ${error.message}`, ); } - await delay(RETRY_DELAY_MS, signal); + await delay(backoff, signal); + // Same backoff, same reason as the Clang client: a producer answering + // "not ready" is a producer doing the work that will make it ready, + // and a fixed short interval spends that producer's time on answering + // instead. This lane has never been the one to demonstrate it — + // rust-analyzer becomes ready quickly on the pinned corpus — but the + // loop is the same shape, so it should not be the one left to find out + // on a larger workspace. + backoff = Math.min(backoff * 2, MAX_RETRY_DELAY_MS); } } } From bed491b0a5d791356db3a777d4b1f4b65b964ecd Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Tue, 4 Aug 2026 17:47:44 +0900 Subject: [PATCH 49/52] Measure the host instead of proposing a fifth theory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backoff did not stop the C/C++ runners dying, and it refuted its own justification on the way. With ninety times fewer requests the cpp row died sooner than before — 1m15s after indexing began, against 4m23s to 4m47s previously — so polling pressure is not the cause either. That is four explanations retired by measurement: build serialization, the job bound, host preemption of long jobs, and now polling pressure. Rather than offer a fifth, the experiment runner prints what the host has left every ten seconds. A shutdown with no diagnostic is what an out-of-memory kill of the runner agent looks like from inside the job, and the one thing never observed across eight runs is the free memory beside it. The trace costs nothing and says nothing about a lane that does not fail. The backoff itself stays, corrected on three counts the commit pass found. Its stated mechanism was wrong: `requestSnapshot` catches the error thrown by the first request inside `requestSnapshotPages`, before any page is assembled, so a retry is one round trip and one refusal — not an instruction to assemble a paged snapshot again. The sentence also contradicted its own arithmetic, since 5,400 over four and a half minutes already counts one request per retry. What survives is the plain argument: polling twenty times a second for a condition that takes minutes is wrong on its own terms, whatever ends the host. The sleep is clamped to the time remaining before the deadline. A five-second cap entered just before it overshot the bound the error message quotes by up to that cap — a stated bound silently widened, which is what the previous commit had gone and corrected elsewhere. And `CONTENT_MODIFIED` no longer inherits the not-ready backoff. It means the inputs moved rather than the producer being busy, and an edit should not wait out a delay a slow index inflated. --- .../graph/src/provider/cpp/CppGraphClient.ts | 43 +++++++++++++------ .../src/provider/rust/RustGraphClient.ts | 23 ++++++---- tests/experiment/src/run-language.mjs | 21 +++++++++ 3 files changed, 66 insertions(+), 21 deletions(-) diff --git a/packages/graph/src/provider/cpp/CppGraphClient.ts b/packages/graph/src/provider/cpp/CppGraphClient.ts index a2812367..11434462 100644 --- a/packages/graph/src/provider/cpp/CppGraphClient.ts +++ b/packages/graph/src/provider/cpp/CppGraphClient.ts @@ -202,18 +202,37 @@ export class CppGraphClient implements IBulkGraphSession { `C/C++ clang graph: producer did not become ready within ${String(this.readyTimeoutMs)} ms: ${error.message}`, ); } - await delay(backoff, signal); - // Backing off, because polling a producer that is busy becoming ready - // costs the producer more than it costs this loop. "Not ready" here - // means clangd is indexing the whole compilation database, and every - // retry asks that same busy process to assemble a paged snapshot - // again. At a flat 50 ms this issued roughly 5,400 requests over four - // and a half minutes and took the CI runner down with it, four times, - // always within seconds of the same offset — which is what a fixed - // interval looks like when the thing being polled is the thing under - // load. A short first wait keeps a producer that is ready-in-a-moment - // fast; the cap keeps a long index cheap. - backoff = Math.min(backoff * 2, MAX_RETRY_DELAY_MS); + // Clamped to what is left, so the wait cannot outlive the bound the + // error message quotes. Sleeping a flat cap from just before the + // deadline would overshoot it by up to that cap, which is a stated + // bound quietly widened — the thing this provider keeps having to + // correct elsewhere. + await delay( + Math.min(backoff, Math.max(0, deadline - performance.now())), + signal, + ); + // Backing off, because polling twenty times a second for a condition + // that takes minutes is wrong on its own terms. Each retry is one + // round trip and one refusal — the producer rejects before assembling + // anything — but at a flat 50 ms that is still about 5,400 of them + // over four and a half minutes, aimed at a process that is indexing + // the whole compilation database. + // + // Four CI runs died there, the host reporting a shutdown 4m23s to + // 4m47s after indexing began, every one of them past the 180-second + // timeout that used to end the wait first. That the polling caused it + // is not established — this is a correlation and nothing here has + // measured the host — but it is the only thing this repository does at + // that cadence, and the change is cheap enough not to need the proof. + // + // Reset for content movement, which is a different condition: the + // inputs changed rather than the producer being busy, `notifyInputChanges` + // has already told it so, and an edit should not wait out a backoff + // that a previous slow index inflated. + backoff = + error.code === CONTENT_MODIFIED + ? RETRY_DELAY_MS + : Math.min(backoff * 2, MAX_RETRY_DELAY_MS); } } } diff --git a/packages/graph/src/provider/rust/RustGraphClient.ts b/packages/graph/src/provider/rust/RustGraphClient.ts index 69d5b72d..d3156e9d 100644 --- a/packages/graph/src/provider/rust/RustGraphClient.ts +++ b/packages/graph/src/provider/rust/RustGraphClient.ts @@ -237,15 +237,20 @@ export class RustGraphClient implements IBulkGraphSession { `rust HIR graph: producer did not become ready within ${String(this.readyTimeoutMs)} ms: ${error.message}`, ); } - await delay(backoff, signal); - // Same backoff, same reason as the Clang client: a producer answering - // "not ready" is a producer doing the work that will make it ready, - // and a fixed short interval spends that producer's time on answering - // instead. This lane has never been the one to demonstrate it — - // rust-analyzer becomes ready quickly on the pinned corpus — but the - // loop is the same shape, so it should not be the one left to find out - // on a larger workspace. - backoff = Math.min(backoff * 2, MAX_RETRY_DELAY_MS); + await delay( + Math.min(backoff, Math.max(0, deadline - performance.now())), + signal, + ); + // Same backoff, same clamp and same reset as the Clang client, for the + // same reasons written out there. This lane has never demonstrated the + // problem — rust-analyzer becomes ready quickly on the pinned corpus, + // and its row runs on the default timeout — but the loop is the same + // shape, so it should not be the one left to find out on a larger + // workspace. + backoff = + error.code === CONTENT_MODIFIED + ? RETRY_DELAY_MS + : Math.min(backoff * 2, MAX_RETRY_DELAY_MS); } } } diff --git a/tests/experiment/src/run-language.mjs b/tests/experiment/src/run-language.mjs index 487ef721..a1425f41 100644 --- a/tests/experiment/src/run-language.mjs +++ b/tests/experiment/src/run-language.mjs @@ -1,4 +1,5 @@ import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import { buildGraphDump } from "@samchon/graph"; @@ -18,6 +19,26 @@ import { import { runStrictLifecycle } from "./strict-lifecycle.mjs"; activateProvisionedTools(); + +// A host memory trace, because four C/C++ runs have now ended with the runner +// reporting a shutdown while the producer was indexing, and four explanations +// for it have been wrong: build serialization, the job bound, host preemption +// of long jobs, and polling pressure. Each was retired by a measurement, and +// the last one by a run that died sooner with ninety times fewer requests. +// +// A shutdown with no diagnostic is what an out-of-memory kill of the runner +// agent looks like from inside the job, so the one thing never observed is +// what the host had left. This prints it rather than reasoning about it. It is +// free, it says nothing about any lane that does not fail, and it is the +// difference between a fifth theory and evidence. +const memoryTrace = setInterval(() => { + const free = Math.round(os.freemem() / (1024 * 1024)); + const total = Math.round(os.totalmem() / (1024 * 1024)); + console.log( + `experiment host memory: ${String(free)} MiB free of ${String(total)} MiB`, + ); +}, 10_000); +memoryTrace.unref?.(); const args = parseArgs(process.argv.slice(2)); const experiment = findExperiment(args.language); const pinned = cloneRepository(experiment, { refresh: args.refresh === "true" }); From 175bfeb48c0d30fcde688f018139c060968dc424 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Tue, 4 Aug 2026 18:39:57 +0900 Subject: [PATCH 50/52] Bound the Clang producer's indexing width by host memory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The host trace answered what four theories could not. In the last samples before the runner went down, free memory was 173 MiB of 15,990 on the c lane and 35 MiB on cpp, and the samples before them are a sawtooth — 311, 814, 1022, 1873, down again — which is what repeated out-of-memory kills look like from outside the process being killed. The runner agent was the last victim, which is why the job only ever reported a shutdown with no diagnosis of its own. So the lane was never failing for build serialization, the job bound, host preemption, or polling pressure. The pinned clangd producer exhausts a 16 GiB host while background-indexing these corpora. `--background-index` is what makes a whole-database snapshot possible, and clangd's `-j` bounds the workers it uses for it; left unset it takes the core count, and each worker holds a translation unit's AST. The provider sizes that the way it already sizes the producer build — by the machine — at eight GiB per worker. That figure is chosen against the one measurement that exists rather than quoted from clangd: sixteen was not enough at four, so a rule that shaved off a single worker would have failed again and explained nothing. On this host it resolves to one worker, which makes the next run a measurement rather than a hopeful adjustment. Worker width cannot bound what the producer retains for the whole compilation database, because that is a function of the project and not of concurrency. If one worker still exhausts the host, then concurrency was never the term that mattered and the remaining cause lies in samchon/llvm-project rather than in this repository — which would mean #73's route is not demonstrable on a hosted runner as designed, and that is a conclusion worth reaching plainly. Away from this host the rule stays ordinary: 8 cores with 32 GiB gets four workers, 64 with 128 GiB gets sixteen, small machines get one. --- .../src/provider/cpp/cppGraphProvider.ts | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/graph/src/provider/cpp/cppGraphProvider.ts b/packages/graph/src/provider/cpp/cppGraphProvider.ts index 6ccaf4e1..4653e9e2 100644 --- a/packages/graph/src/provider/cpp/cppGraphProvider.ts +++ b/packages/graph/src/provider/cpp/cppGraphProvider.ts @@ -1,5 +1,6 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import { spawnableCommand } from "../../utils/spawnableCommand"; @@ -62,9 +63,36 @@ export const cppGraphProvider: IGraphProvider = { } }, open: (props) => { + // Sized for the machine, like every other producer this repository + // launches. `--background-index` is what makes a whole-compilation-database + // snapshot possible at all, and clangd's `-j` bounds the workers it uses + // for it; left unset it takes the core count, and each worker holds a + // translation unit's AST while it runs. + // + // The bound is measured rather than assumed. A 16 GiB CI host indexing + // libuv and fmt at the default width ran out of memory — a trace of the + // host recorded free memory collapsing to 173 MiB and then 35 MiB, with + // the sawtooth of repeated kills before it — and took the runner agent + // with it. Eight GiB per worker is this repository's figure, chosen + // against that observation and not quoted from clangd: sixteen was not + // enough at four, so the rule has to land below two there rather than + // shave a worker off and call it sized. + // + // What this cannot do is bound what the producer retains for the whole + // database, which is a function of the project rather than of the worker + // count. That makes the narrow width a measurement as much as a fix: if + // one worker still exhausts the host, concurrency was never the term that + // mattered, and the answer lies in the producer rather than here. + const workers = Math.max( + 1, + Math.min( + os.availableParallelism(), + Math.floor(os.totalmem() / (8 * 1024 * 1024 * 1024)), + ), + ); const command = spawnableCommand.append( { ...props.command, args: [...props.command.args] }, - ["--background-index"], + ["--background-index", `-j=${String(workers)}`], ); return new CppGraphClient({ root: props.root, From 40ebafd1b9bf52e45f04d2bec52653ca81001370 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Tue, 4 Aug 2026 22:35:48 +0900 Subject: [PATCH 51/52] Pin the Clang producer that bounds its resident views The pinned producer held every completed graph view in memory for the process's life, so its footprint tracked the compilation database rather than the indexer width. A 16 GiB runner traced free memory from 14,308 MiB when indexing announced itself to 200 MiB, at which point the runner agent died; the trace was taken at one worker, so worker count was never the term that mattered and no consumer-side bound could have fixed it. samchon/llvm-project#2 retains view metadata and loads the bodies from the main-file shards that already persist them, one snapshot page at a time. It passes that repository's CI on ubuntu-24.04 and macos-14, which proves it builds and leaves clangd behaving as before, not that it bounds anything: the memory claim is what this pin exists to measure. The commit is the head of that pull request rather than a merged revision, because merging a fix before its effect is measured puts the two in the wrong order. Once the c/cpp lanes read the trace back, the pin moves to whatever revision the merge produces. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01J5bPQGQMwEeEY57rUZTsgr --- README.md | 2 +- docs/provider-support.json | 2 +- packages/graph/src/provider/cpp/CPP_CLANG_PRODUCER_COMMIT.ts | 2 +- tests/experiment/src/catalog.mjs | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 3f64c4ee..1c6e5b97 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ The troubleshooting table names the ordinary language-server/static fallback for | `samchon-graph-go` | Go 1.25+; the package ships the Go exporter source. Install corroboration with `go install github.com/scip-code/scip-go/cmd/scip-go@v0.2.7`. | [Go downloads](https://go.dev/dl/), [scip-go 0.2.7 source](https://github.com/scip-code/scip-go/tree/v0.2.7) | `samchon-graph-go`, `go`, `scip-go` | — | `SAMCHON_GRAPH_GO`, `SAMCHON_GRAPH_GO_TOOLCHAIN`, `SAMCHON_GRAPH_SCIP_GO` | Project/PATH `samchon-graph-go`, then the shipped source runner through Go; absolute environment overrides take precedence. | Go workspace/module inputs, selected GOOS/GOARCH/cgo environment, embedded files and vendored inputs. | `linux`, `macos`, `windows` | | `samchon-graph-lua` | Install `lua-language-server`; the package ships `sidecars/lua/export.lua`. | [LuaLS releases](https://github.com/LuaLS/lua-language-server/releases) | `lua-language-server` | — | `SAMCHON_GRAPH_LUA`, `SAMCHON_GRAPH_LUA_EXPORTER` | Absolute `SAMCHON_GRAPH_LUA`, then project/PATH LuaLS; the shipped exporter may be replaced by `SAMCHON_GRAPH_LUA_EXPORTER`. | LuaLS workspace configuration and the shipped readable exporter. | `linux`, `macos`, `windows` | | `samchon-rust-analyzer-hir` | Build the `samchon/rust-analyzer` graph-snapshot fork at commit `2850ecba80311bebd4cdaa9fedc5321533b5b1e7`; point `SAMCHON_GRAPH_RUST_ANALYZER_HIR` at that binary or install it as `samchon-rust-analyzer`. | [native HIR graph producer PR](https://github.com/samchon/rust-analyzer/pull/1), [rust-analyzer build instructions](https://rust-analyzer.github.io/book/contributing.html) | `samchon-rust-analyzer`, `rust-analyzer` | — | `SAMCHON_GRAPH_RUST_ANALYZER_HIR` | Absolute `SAMCHON_GRAPH_RUST_ANALYZER_HIR`, then project/PATH `samchon-rust-analyzer`, then a project/PATH `rust-analyzer` only when its version reports the pinned producer commit. | Cargo metadata/config, lock/toolchain inputs, target/features/cfg and build-script/proc-macro universe. | `linux`, `macos`, `windows` | -| `clangd-snapshot` | Build the `samchon/llvm-project` graph-snapshot fork at commit `dcc73b6579ebb8b71f6080302a9444f237b7abb8`; point `SAMCHON_GRAPH_CLANGD_SNAPSHOT` at `clangd` or install it as `samchon-clangd`, and provide a compilation database. | [native Clang graph producer PR](https://github.com/samchon/llvm-project/pull/1), [LLVM build instructions](https://llvm.org/docs/CMake.html) | `samchon-clangd`, `clangd` | `compile_commands.json`, `build/compile_commands.json` | `SAMCHON_GRAPH_CLANGD_SNAPSHOT` | Absolute `SAMCHON_GRAPH_CLANGD_SNAPSHOT`, then project/PATH `samchon-clangd`, then a project/PATH `clangd` only when its version reports the pinned producer commit. | A valid compilation database plus every source, header, generated input, command, target and working-directory identity used by its translation units. | `linux`, `macos`, `windows` | +| `clangd-snapshot` | Build the `samchon/llvm-project` graph-snapshot fork at commit `ae904413566b54aca08e46ebee1769c110601e6b`; point `SAMCHON_GRAPH_CLANGD_SNAPSHOT` at `clangd` or install it as `samchon-clangd`, and provide a compilation database. | [native Clang graph producer PR](https://github.com/samchon/llvm-project/pull/1), [LLVM build instructions](https://llvm.org/docs/CMake.html) | `samchon-clangd`, `clangd` | `compile_commands.json`, `build/compile_commands.json` | `SAMCHON_GRAPH_CLANGD_SNAPSHOT` | Absolute `SAMCHON_GRAPH_CLANGD_SNAPSHOT`, then project/PATH `samchon-clangd`, then a project/PATH `clangd` only when its version reports the pinned producer commit. | A valid compilation database plus every source, header, generated input, command, target and working-directory identity used by its translation units. | `linux`, `macos`, `windows` | | `scip-java` | Install `scip-java` 0.13.1, the `scip` decoder and a compatible JDK; Kotlin experiments pin a compatible source build. | [scip-java 0.13.1 release](https://github.com/scip-code/scip-java/releases/tag/v0.13.1), [SCIP releases](https://github.com/sourcegraph/scip/releases) | `scip-java`, `scip`, `java` | — | `SAMCHON_GRAPH_SCIP_JAVA`, `SAMCHON_GRAPH_SCIP`, `SAMCHON_GRAPH_JAVA_TOOLCHAIN` | Project-local producer/decoder/JDK precede PATH; absolute environment overrides select each tool. | Maven or Gradle project metadata, dependency/classpath state and Java/Kotlin compiler inputs. | `linux`, `macos`, `windows` | | `scip-dotnet` | `dotnet tool install --global scip-dotnet`; install the `scip` decoder and matching .NET SDK. | [scip-dotnet on NuGet](https://www.nuget.org/packages/scip-dotnet), [SCIP releases](https://github.com/sourcegraph/scip/releases) | `scip-dotnet`, `scip`, `dotnet` | — | `SAMCHON_GRAPH_SCIP_DOTNET`, `SAMCHON_GRAPH_SCIP`, `SAMCHON_GRAPH_DOTNET_TOOLCHAIN` | Project-local producer/decoder/toolchain precede PATH; absolute environment overrides select each tool. | Solution/project/TFM/NuGet/MSBuild inputs and a resolvable SDK. | `linux`, `macos`, `windows` | | `scip-python` | `npm install -g @sourcegraph/scip-python@0.6.6`; install the `scip` decoder and select Python. | [scip-python 0.6.6 on npm](https://www.npmjs.com/package/@sourcegraph/scip-python/v/0.6.6), [SCIP releases](https://github.com/sourcegraph/scip/releases) | `scip-python`, `scip`, `python3`, `python`, `py` | — | `SAMCHON_GRAPH_SCIP_PYTHON`, `SAMCHON_GRAPH_SCIP`, `SAMCHON_GRAPH_PYTHON_TOOLCHAIN` | Project-local producer/decoder/interpreter precede PATH; Python aliases are tried in order and absolute overrides select each tool. | Python project/config/environment/import/stub inputs. | `linux`, `macos`, `windows` | diff --git a/docs/provider-support.json b/docs/provider-support.json index 22cef058..b200e2fb 100644 --- a/docs/provider-support.json +++ b/docs/provider-support.json @@ -133,7 +133,7 @@ "commands": ["samchon-clangd", "clangd"], "projectCommandSources": ["compile_commands.json", "build/compile_commands.json"], "environmentOverrides": ["SAMCHON_GRAPH_CLANGD_SNAPSHOT"], - "install": "Build the `samchon/llvm-project` graph-snapshot fork at commit `dcc73b6579ebb8b71f6080302a9444f237b7abb8`; point `SAMCHON_GRAPH_CLANGD_SNAPSHOT` at `clangd` or install it as `samchon-clangd`, and provide a compilation database.", + "install": "Build the `samchon/llvm-project` graph-snapshot fork at commit `ae904413566b54aca08e46ebee1769c110601e6b`; point `SAMCHON_GRAPH_CLANGD_SNAPSHOT` at `clangd` or install it as `samchon-clangd`, and provide a compilation database.", "installSources": [ {"label": "native Clang graph producer PR", "url": "https://github.com/samchon/llvm-project/pull/1"}, {"label": "LLVM build instructions", "url": "https://llvm.org/docs/CMake.html"} diff --git a/packages/graph/src/provider/cpp/CPP_CLANG_PRODUCER_COMMIT.ts b/packages/graph/src/provider/cpp/CPP_CLANG_PRODUCER_COMMIT.ts index c5352567..955bdf49 100644 --- a/packages/graph/src/provider/cpp/CPP_CLANG_PRODUCER_COMMIT.ts +++ b/packages/graph/src/provider/cpp/CPP_CLANG_PRODUCER_COMMIT.ts @@ -1,3 +1,3 @@ /** Exact samchon/llvm-project producer revision required by this adapter. */ export const CPP_CLANG_PRODUCER_COMMIT = - "dcc73b6579ebb8b71f6080302a9444f237b7abb8"; + "ae904413566b54aca08e46ebee1769c110601e6b"; diff --git a/tests/experiment/src/catalog.mjs b/tests/experiment/src/catalog.mjs index c0f2a4f5..9d384d0f 100644 --- a/tests/experiment/src/catalog.mjs +++ b/tests/experiment/src/catalog.mjs @@ -151,7 +151,7 @@ export const LANGUAGE_EXPERIMENTS = [ strictAuthority: "compiler", strictTool: "samchon-clangd", producerRepository: "https://github.com/samchon/llvm-project.git", - producerCommit: "dcc73b6579ebb8b71f6080302a9444f237b7abb8", + producerCommit: "ae904413566b54aca08e46ebee1769c110601e6b", // A whole-compilation-database producer is not ready when it starts; it // is ready when clangd has background-indexed every translation unit the // database registers. The 180-second default expired on libuv with 62 of @@ -237,7 +237,7 @@ export const LANGUAGE_EXPERIMENTS = [ strictAuthority: "compiler", strictTool: "samchon-clangd", producerRepository: "https://github.com/samchon/llvm-project.git", - producerCommit: "dcc73b6579ebb8b71f6080302a9444f237b7abb8", + producerCommit: "ae904413566b54aca08e46ebee1769c110601e6b", // A whole-compilation-database producer is not ready when it starts; it // is ready when clangd has background-indexed every translation unit the // database registers. The 180-second default expired on libuv with 62 of From 4d24f5088e6088ef103a8bd9a0717caa17daa598 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Sun, 9 Aug 2026 01:37:53 +0900 Subject: [PATCH 52/52] bump up ttsc --- pnpm-lock.yaml | 106 ++++++++++++++++++++++---------------------- pnpm-workspace.yaml | 2 +- 2 files changed, 54 insertions(+), 54 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3b9a21bf..cc56f174 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,11 +14,11 @@ catalogs: specifier: ^12.1.0 version: 12.1.0 '@ttsc/lint': - specifier: ^0.23.0 - version: 0.23.0 + specifier: ^0.25.0 + version: 0.25.0 '@ttsc/unplugin': - specifier: ^0.23.0 - version: 0.23.0 + specifier: ^0.25.0 + version: 0.25.0 '@typia/interface': specifier: ^13.2.0 version: 13.2.0 @@ -26,8 +26,8 @@ catalogs: specifier: ^13.2.0 version: 13.2.0 ttsc: - specifier: ^0.23.0 - version: 0.23.0 + specifier: ^0.25.0 + version: 0.25.0 typia: specifier: ^13.2.0 version: 13.2.0 @@ -73,7 +73,7 @@ importers: devDependencies: '@ttsc/lint': specifier: catalog:samchon - version: 0.23.0 + version: 0.25.0 packages/graph: dependencies: @@ -97,7 +97,7 @@ importers: version: 3.1.2 typia: specifier: catalog:samchon - version: 13.2.0(@types/node@22.20.0)(ttsc@0.23.0) + version: 13.2.0(@types/node@22.20.0)(ttsc@0.25.0) devDependencies: '@types/node': specifier: catalog:utils @@ -119,7 +119,7 @@ importers: version: 1.43.4(three@0.184.0) ttsc: specifier: catalog:samchon - version: 0.23.0 + version: 0.25.0 typescript: specifier: catalog:typescript version: 7.0.2 @@ -134,7 +134,7 @@ importers: version: 6.1.3 ttsc: specifier: catalog:samchon - version: 0.23.0 + version: 0.25.0 typescript: specifier: catalog:typescript version: 7.0.2 @@ -157,7 +157,7 @@ importers: devDependencies: ttsc: specifier: catalog:samchon - version: 0.23.0 + version: 0.25.0 typescript: specifier: catalog:typescript version: 7.0.2 @@ -178,7 +178,7 @@ importers: version: link:../../packages/graph-sitter '@ttsc/unplugin': specifier: catalog:samchon - version: 0.23.0(ttsc@0.23.0) + version: 0.25.0(ttsc@0.25.0) '@types/node': specifier: catalog:utils version: 22.20.0 @@ -581,46 +581,46 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@ttsc/darwin-arm64@0.23.0': - resolution: {integrity: sha512-JNkV0/qApeccnSNN+k96xo9+UphFQp5l6nDU16PhkspTckRzFb5P7wSaeCIHTM2xZ7gBhXDOnc3dxB+wfXm+2Q==} + '@ttsc/darwin-arm64@0.25.0': + resolution: {integrity: sha512-DrqbSBDRHPfCtYTWVuUMde4p+1dvfMHlBy430t3ckrQu2t8YzTzJRHKQiX858LaFjh3tyIF/1z55rjkXqyg/Ww==} cpu: [arm64] os: [darwin] - '@ttsc/darwin-x64@0.23.0': - resolution: {integrity: sha512-OvfO8U7p+7w862Hp2B0G/PRtQJDq6jH+zGjEKM+MCF0N667fAI3xgICzKF6tlhMJOUMD15GxwPDpaiIeilqOoQ==} + '@ttsc/darwin-x64@0.25.0': + resolution: {integrity: sha512-CX1B4HPxZsEQbMFTLAH2C2GHUl+a1Hs23Lht8iizqMNnWCTY5WG5CrvZhA6WYUwARzDLdvjdcvlECz4oHOwpgA==} cpu: [x64] os: [darwin] - '@ttsc/lint@0.23.0': - resolution: {integrity: sha512-9cJWMoW/VIJc3Ct+yA0/IEwsrGinelPesYLUfvnyclov5brctqFQF8Kl3zEgyVPRsyNWrxIVRW99hayjOfFHNw==} + '@ttsc/lint@0.25.0': + resolution: {integrity: sha512-Y0rGBnjvqBvxKNkgwfRcH1sOzRZJQIrol3TQdyLNawQJSj0eBP6OvpQWSecperX2LRDsJmw6k2sw2bZr8zTErw==} - '@ttsc/linux-arm64@0.23.0': - resolution: {integrity: sha512-eFS1k1Xtk3lEf4Pp7hTSTiXp4+u7mv6DgoxtiYDTwvWZ69s91cukIPZRaA0W06EUsWMe0J+nIqoc8/uJ6JpILQ==} + '@ttsc/linux-arm64@0.25.0': + resolution: {integrity: sha512-Vs3ELSCgHCPbEh2Mxgb4LjLTimK9f/MsGmDsy6nix5QIg+48l48F9qqpW452jdjTABWshd41CpnNqzWYIUqtZg==} cpu: [arm64] os: [linux] - '@ttsc/linux-arm@0.23.0': - resolution: {integrity: sha512-tHmejG6thGqSdQk1PUhzJ+C004cbrDgFmuGZ6bXsJj0zIuTikMnRJaw/gq8GMYI1jujtr3XV0cL+BaxS3oUz4A==} + '@ttsc/linux-arm@0.25.0': + resolution: {integrity: sha512-Dv4RANjL9/qwbBThXQ746uXasLw0okIc2YkjwbaNBUXGFN2Jusp7SQsXvfbNsr6Bbj/udyz3mmAAkCe3tnB9nw==} cpu: [arm] os: [linux] - '@ttsc/linux-x64@0.23.0': - resolution: {integrity: sha512-fcvWLP/f3vaXsaYTvwR03KPjWPhesGrJbJ0vpVSErnsbnjAYQkg7+3bdzdGhpUTILayd7TPX62weSAhG888FXA==} + '@ttsc/linux-x64@0.25.0': + resolution: {integrity: sha512-WkWQzPMJY2oo6SY+rtPjALvLNF3GiGV+POPhp96/R6NLLiwqEv2ou6noxp9v3bGAct6//nUNvzw+VfxRPi64Ww==} cpu: [x64] os: [linux] - '@ttsc/unplugin@0.23.0': - resolution: {integrity: sha512-hyLQlHHUp6eJjEOyLN1ARqqNHc2CkqBslNj+/xAUJp/yWRZ+kRBDumQde5tbywj/3VWZLU8SoeyuPlc53fbHqA==} + '@ttsc/unplugin@0.25.0': + resolution: {integrity: sha512-J9R3jWgafTbadeCupbGArAEnnRLxFgA4Y+g1v3IUjTtAX1Saq3wnjYvbYfyB/gzmAoPuQFb2/tMzIwatEsWQpQ==} peerDependencies: - ttsc: ^0.23.0 + ttsc: ^0.25.0 - '@ttsc/win32-arm64@0.23.0': - resolution: {integrity: sha512-4a7XBrjW/vNG/TdlCnZnGI3a/YandBM6t8XN/vdZGU1FYVxADkBed2egTKSC/PTEJbCtdnqrtsosZxmf73VXeg==} + '@ttsc/win32-arm64@0.25.0': + resolution: {integrity: sha512-mWHslRlxwLR5Sm7Pcq5/A/nH35kYnqrAqufkL8w32L6xoYbXHIQetdRBqf22djQNDqJ7xPqNBoq7ET22WO2e1g==} cpu: [arm64] os: [win32] - '@ttsc/win32-x64@0.23.0': - resolution: {integrity: sha512-sePUCdYny5/6fXHtxvoodumUWXv8lyGPTJleMAf6N5Dur+dW/bXEVVcEZmskTldzaHgX08KQKisKcmOLf1G5OA==} + '@ttsc/win32-x64@0.25.0': + resolution: {integrity: sha512-HLwKmIX3kZiL8CINhIzKFTttFOkU3Z9crXwzHvWH3ihsHn7UqlgvjWQgoLNXgyqZzBo1fAz3IDupLgTMUCHbWw==} cpu: [x64] os: [win32] @@ -1898,8 +1898,8 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - ttsc@0.23.0: - resolution: {integrity: sha512-OZmG/lrpi+neKmtYVeSpyVjWiyXA7c4rGwdRNWsRLEQglJ4yNc+Vp1IQemUEtPgowf/nlsNSe5H8bJ4KutJp2w==} + ttsc@0.25.0: + resolution: {integrity: sha512-f5FZg7TZb4BgMBAWfRWcd0KcLG5uVNNyzIMOHZLAk/0f4sQHUucNm6a6SDMd34WKhVsRmbCNk9BVoTvqytUpfQ==} engines: {node: '>=22.15.0'} hasBin: true @@ -2279,32 +2279,32 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@ttsc/darwin-arm64@0.23.0': + '@ttsc/darwin-arm64@0.25.0': optional: true - '@ttsc/darwin-x64@0.23.0': + '@ttsc/darwin-x64@0.25.0': optional: true - '@ttsc/lint@0.23.0': {} + '@ttsc/lint@0.25.0': {} - '@ttsc/linux-arm64@0.23.0': + '@ttsc/linux-arm64@0.25.0': optional: true - '@ttsc/linux-arm@0.23.0': + '@ttsc/linux-arm@0.25.0': optional: true - '@ttsc/linux-x64@0.23.0': + '@ttsc/linux-x64@0.25.0': optional: true - '@ttsc/unplugin@0.23.0(ttsc@0.23.0)': + '@ttsc/unplugin@0.25.0(ttsc@0.25.0)': dependencies: - ttsc: 0.23.0 + ttsc: 0.25.0 unplugin: 2.3.11 - '@ttsc/win32-arm64@0.23.0': + '@ttsc/win32-arm64@0.25.0': optional: true - '@ttsc/win32-x64@0.23.0': + '@ttsc/win32-x64@0.25.0': optional: true '@tweenjs/tween.js@23.1.3': {} @@ -3595,15 +3595,15 @@ snapshots: tslib@2.8.1: {} - ttsc@0.23.0: + ttsc@0.25.0: optionalDependencies: - '@ttsc/darwin-arm64': 0.23.0 - '@ttsc/darwin-x64': 0.23.0 - '@ttsc/linux-arm': 0.23.0 - '@ttsc/linux-arm64': 0.23.0 - '@ttsc/linux-x64': 0.23.0 - '@ttsc/win32-arm64': 0.23.0 - '@ttsc/win32-x64': 0.23.0 + '@ttsc/darwin-arm64': 0.25.0 + '@ttsc/darwin-x64': 0.25.0 + '@ttsc/linux-arm': 0.25.0 + '@ttsc/linux-arm64': 0.25.0 + '@ttsc/linux-x64': 0.25.0 + '@ttsc/win32-arm64': 0.25.0 + '@ttsc/win32-x64': 0.25.0 type-fest@0.21.3: {} @@ -3636,7 +3636,7 @@ snapshots: '@typescript/typescript-win32-arm64': 7.0.2 '@typescript/typescript-win32-x64': 7.0.2 - typia@13.2.0(@types/node@22.20.0)(ttsc@0.23.0): + typia@13.2.0(@types/node@22.20.0)(ttsc@0.25.0): dependencies: '@standard-schema/spec': 1.1.0 '@typia/interface': 13.2.0 @@ -3646,7 +3646,7 @@ snapshots: randexp: 0.5.3 tinyglobby: 0.2.17 optionalDependencies: - ttsc: 0.23.0 + ttsc: 0.25.0 transitivePeerDependencies: - '@types/node' diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index fb306310..f4f7b461 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,7 +7,7 @@ catalogs: typescript: ^7.0.2 samchon: "@nestia/e2e": ^12.1.0 - ttsc: &ttsc ^0.23.0 + ttsc: &ttsc ^0.25.0 "@ttsc/lint": *ttsc "@ttsc/unplugin": *ttsc typia: &typia ^13.2.0