From 0619eb7cadb43b1dc55714d7eae6cbc9ea8bdcf0 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Sun, 19 Jul 2026 13:48:03 +0900 Subject: [PATCH 01/16] Keep graph behavior anchored to its compiler-owned reference The canonical ttsc graph advanced across fact ownership, resident recovery, handle ranking, trace completeness, viewer paths, and snapshot-owned detail facts. Reserve one dependency batch so semantic identity work does not preserve superseded reader behavior. Constraint: ttsc origin/master 2b724664e is the user-designated structural reference. Rejected: Fold the synchronization into #65 | it would mix canonical parity with new multi-language identity policy. Confidence: high Scope-risk: broad Directive: Port reference ownership and algorithms before reconciling PR #95. Tested: Branch starts exactly at samchon/graph origin/master 84f5b6f. Not-tested: Implementation and regression verification are pending. From ee1e2ea7464ac71ad954c0428b3de24d6f8187dc Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Sun, 19 Jul 2026 14:39:00 +0900 Subject: [PATCH 02/16] Keep graph consumers aligned with the canonical compiler snapshot Port the current ttsc-graph snapshot, request lifecycle, ranking, trace, reduction, and source-provenance semantics so the standalone graph cannot reinterpret compiler-owned facts or live disk state. Constraint: D:/github/samchon/ttsc ttsc-graph is the canonical contract and operation reference. Rejected: Continue reconstructing object members and literals from signatures or live source | diverges from the compiler snapshot and races edits. Confidence: high Scope-risk: broad Directive: Regenerate and verify the pinned ttsc contract before changing dump schema or operation semantics. Tested: graph build; test build; 19 focused regression tests; all 18 parity contracts at ttsc 2b724664e; go test ./cmd/ttscgraph/... Not-tested: Full paid agent benchmark requires a separate exact-scope authorization. Related: #96 --- packages/graph/src/SamchonGraphApplication.ts | 3 +- packages/graph/src/SamchonGraphMemory.ts | 67 +-- .../graph/src/SamchonGraphSourceReader.ts | 147 +++++ packages/graph/src/index.ts | 2 + .../graph/src/indexer/IResidentGraphSource.ts | 4 + .../src/indexer/createResidentGraphSource.ts | 23 + .../mcp/createResidentGraphMemorySource.ts | 5 +- packages/graph/src/mcp/startServer.ts | 5 +- .../src/operations/RESULT_AUDIT_DETAILS.ts | 25 + packages/graph/src/operations/common.ts | 1 - packages/graph/src/operations/docOf.ts | 6 +- packages/graph/src/operations/fileLines.ts | 11 - .../src/operations/resolveGraphHandle.ts | 36 +- packages/graph/src/operations/runDetails.ts | 147 ++--- .../graph/src/operations/runEntrypoints.ts | 2 +- packages/graph/src/operations/runLookup.ts | 2 +- packages/graph/src/operations/runTour.ts | 4 +- packages/graph/src/operations/runTrace.ts | 89 ++- packages/graph/src/operations/signatureOf.ts | 6 +- .../provider/ttscgraph/ITtscGraphSnapshot.ts | 2 +- .../src/provider/ttscgraph/TtscGraphClient.ts | 543 +++++++++++------- .../provider/ttscgraph/adaptTtscGraphDump.ts | 52 ++ packages/graph/src/reduce.ts | 64 ++- .../graph/src/structures/ISamchonGraphNode.ts | 26 + .../src/structures/ISamchonGraphTrace.ts | 2 +- tests/benchmark/graph/viewer.mjs | 58 +- ...k_viewer_reduce_matches_package_reducer.ts | 61 ++ ...details_consume_snapshot_identity_facts.ts | 85 +++ ...imits_follow_complete_reference_ranking.ts | 163 ++++++ ...peration_engines_cover_scoring_branches.ts | 37 +- ...tions_without_reprocessing_strict_facts.ts | 2 +- ..._reader_enforces_snapshot_identity_once.ts | 103 ++++ ...est_trace_reports_only_actual_omissions.ts | 73 +++ ...hesizes_properties_and_member_relations.ts | 34 +- ...euses_and_atomically_replaces_snapshots.ts | 12 +- ...raph_bulk_session_stays_alive_when_kept.ts | 6 +- ...ph_dump_adapter_rejects_malformed_facts.ts | 80 ++- ...aph_native_requests_recover_from_stalls.ts | 133 +++++ ...proves_its_program_without_reading_disk.ts | 4 +- ...raph_provider_surfaces_process_failures.ts | 9 +- ...reduce_preserves_the_reference_contract.ts | 22 +- .../src/internal/fake-ttscgraph-server.cjs | 19 +- .../src/internal/ttsc-canonical-contract.json | 8 +- 43 files changed, 1658 insertions(+), 525 deletions(-) create mode 100644 packages/graph/src/SamchonGraphSourceReader.ts create mode 100644 packages/graph/src/operations/RESULT_AUDIT_DETAILS.ts delete mode 100644 packages/graph/src/operations/fileLines.ts create mode 100644 tests/test-graph/src/features/test_benchmark_viewer_reduce_matches_package_reducer.ts create mode 100644 tests/test-graph/src/features/test_details_consume_snapshot_identity_facts.ts create mode 100644 tests/test-graph/src/features/test_graph_handle_limits_follow_complete_reference_ranking.ts create mode 100644 tests/test-graph/src/features/test_source_reader_enforces_snapshot_identity_once.ts create mode 100644 tests/test-graph/src/features/test_trace_reports_only_actual_omissions.ts create mode 100644 tests/test-graph/src/features/test_ttscgraph_native_requests_recover_from_stalls.ts diff --git a/packages/graph/src/SamchonGraphApplication.ts b/packages/graph/src/SamchonGraphApplication.ts index ecfa437..c0e7d00 100644 --- a/packages/graph/src/SamchonGraphApplication.ts +++ b/packages/graph/src/SamchonGraphApplication.ts @@ -1,5 +1,6 @@ import { AsyncSamchonGraphSource } from "./AsyncSamchonGraphSource"; import { RESULT_AUDIT } from "./operations/RESULT_AUDIT"; +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"; @@ -86,7 +87,7 @@ export class SamchonGraphApplication implements ISamchonGraphApplication { case "details": { const r = runDetails(graph, props.request); return { - audit: RESULT_AUDIT(graph.indexer), + audit: RESULT_AUDIT_DETAILS(graph.indexer), next: r.next, result: r.result, }; diff --git a/packages/graph/src/SamchonGraphMemory.ts b/packages/graph/src/SamchonGraphMemory.ts index 2ad3aa8..d53dc40 100644 --- a/packages/graph/src/SamchonGraphMemory.ts +++ b/packages/graph/src/SamchonGraphMemory.ts @@ -8,14 +8,16 @@ import { } from "./structures"; import { GraphLanguage } from "./typings"; import { basename } from "./utils/path"; +import { SamchonGraphSourceReader } from "./SamchonGraphSourceReader"; /** * The in-memory resident graph the MCP tools answer from. * * It loads one dump — the indexer-resolved fact graph — then synthesizes the * structural layer the dump deliberately leaves to this layer: `file` container - * nodes and the `contains` ownership tree, plus member implementation edges and - * class-member property refinement. Every tool call is then a lookup or a + * nodes and the `contains` ownership tree, plus class-member property + * refinement. Export and member implementation relationships are producer + * facts already present in the dump. Every tool call is then a lookup or a * traversal over the indexes built here; nothing re-indexes. */ export class SamchonGraphMemory { @@ -32,17 +34,20 @@ export class SamchonGraphMemory { public readonly indexer: ISamchonGraphDump["indexer"]; /** Every node, raw plus synthesized (file containers). */ public readonly nodes: readonly ISamchonGraphNode[]; - /** Every edge, raw plus synthesized (contains and member relations). */ + /** Every edge, raw plus synthesized containment. */ public readonly edges: readonly ISamchonGraphEdge[]; /** Fused compiler and plugin diagnostics, when the build collected any. */ public readonly diagnostics: readonly ISamchonGraphDiagnostic[]; /** Non-fatal problems encountered while building the graph. */ public readonly warnings: readonly string[]; + /** Provenance-gated source display facts owned by this exact snapshot. */ + public readonly source: SamchonGraphSourceReader; private constructor( dump: ISamchonGraphDump, nodes: ISamchonGraphNode[], edges: ISamchonGraphEdge[], + source: SamchonGraphSourceReader, ) { this.project = dump.project; this.languages = dump.languages; @@ -51,6 +56,7 @@ export class SamchonGraphMemory { this.edges = edges; this.diagnostics = dump.diagnostics ?? []; this.warnings = dump.warnings ?? []; + this.source = source; this.byId = new Map(nodes.map((node) => [node.id, node])); this.outEdges = new Map(); @@ -72,9 +78,14 @@ export class SamchonGraphMemory { } /** Build a model from a parsed dump, synthesizing structural relationships. */ - public static from(dump: ISamchonGraphDump): SamchonGraphMemory { + public static from( + dump: ISamchonGraphDump, + source: SamchonGraphSourceReader = SamchonGraphSourceReader.live( + dump.project, + ), + ): SamchonGraphMemory { const { nodes, edges } = synthesize(dump); - return new SamchonGraphMemory(dump, nodes, edges); + return new SamchonGraphMemory(dump, nodes, edges, source); } /** The node with this id, or undefined. */ @@ -147,7 +158,7 @@ function fileOfNodeId(id: string): string { * Derive the structural layer from a dump's faithful facts: refine class-member * variables to properties, put back the file the indexer left out of every * span, add a `file` node per workspace source, and connect the `contains` - * ownership tree and member implementation relations. + * ownership tree. * * `exports` edges are not synthesized here. The indexer resolves them from the * project's own export syntax and follows them through its barrels (§4k), so @@ -249,11 +260,9 @@ function synthesize(dump: ISamchonGraphDump): { edges.map((edge) => `${edge.kind}\0${edge.from}\0${edge.to}`), ); const structural: ISamchonGraphEdge[] = []; - const membersByOwner = new Map(); for (const node of nodes) { if (node.external || node.file === "" || node.kind === "file") continue; const parent = owner(node); - if (parent !== undefined) push(membersByOwner, parent.id, node); const container = parent?.id ?? node.file; const key = `contains\0${container}\0${node.id}`; if (edgeKeys.has(key)) continue; @@ -265,48 +274,8 @@ function synthesize(dump: ISamchonGraphDump): { }); } - const synthesized = [...edges, ...structural]; - for (const edge of edges) { - const kind: ISamchonGraphEdge["kind"] | undefined = - edge.kind === "implements" - ? "implements" - : edge.kind === "extends" - ? "overrides" - : undefined; - if (kind === undefined) continue; - const derived = byId.get(edge.from); - const base = byId.get(edge.to); - if (derived === undefined || base === undefined) continue; - const derivedMembers = membersByOwner.get(derived.id) ?? []; - const baseMembers = membersByOwner.get(base.id) ?? []; - for (const baseMember of baseMembers) { - const derivedMember = derivedMembers.find( - (member) => - member.name === baseMember.name && - IMPLEMENTATION_MEMBER_KINDS.has(member.kind) && - IMPLEMENTATION_MEMBER_KINDS.has(baseMember.kind), - ); - if (derivedMember === undefined) continue; - const key = `${kind}\0${derivedMember.id}\0${baseMember.id}`; - if (edgeKeys.has(key)) continue; - edgeKeys.add(key); - synthesized.push({ - from: derivedMember.id, - to: baseMember.id, - kind, - evidence: derivedMember.implementation ?? derivedMember.evidence, - }); - } - } - return { nodes: [...nodes, ...files.values()], - edges: synthesized, + edges: [...edges, ...structural], }; } - -// Canonical ttsc emits methods and refines class-owned variables to properties. -// Other supported language servers may already distinguish a field, which has -// the same member-satisfaction semantics. Constructors are intentionally absent: -// declaring a subclass constructor does not override its base constructor. -const IMPLEMENTATION_MEMBER_KINDS = new Set(["method", "property", "field"]); diff --git a/packages/graph/src/SamchonGraphSourceReader.ts b/packages/graph/src/SamchonGraphSourceReader.ts new file mode 100644 index 0000000..3eed8d5 --- /dev/null +++ b/packages/graph/src/SamchonGraphSourceReader.ts @@ -0,0 +1,147 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +import { IBulkGraphSession } from "./provider/IBulkGraphSession"; +import { isSubPath, normalizePath } from "./utils/path"; + +type ReadFile = (file: string) => Buffer; + +/** Immutable source lines adjudicated for one graph snapshot. */ +export class SamchonGraphSourceReader { + private readonly project: string; + private readonly texts: ReadonlyMap; + private readonly checkerDigests: ReadonlyMap; + private readonly allowUnproven: boolean; + private readonly read: ReadFile; + private readonly cache = new Map(); + + public constructor( + project: string, + options: SamchonGraphSourceReader.IOptions = {}, + ) { + this.project = path.resolve(project); + this.texts = normalizeTexts(this.project, options.texts); + this.checkerDigests = normalizeDigests( + this.project, + options.digests, + ); + this.allowUnproven = options.allowUnproven === true; + this.read = options.read ?? ((file) => fs.readFileSync(file)); + } + + /** A compatibility reader that freezes the first in-project disk read. */ + public static live(project: string): SamchonGraphSourceReader { + return new SamchonGraphSourceReader(project, { allowUnproven: true }); + } + + /** A fail-closed reader for dumps that carry no source provenance. */ + public static none(project: string): SamchonGraphSourceReader { + return new SamchonGraphSourceReader(project); + } + + /** + * Return frozen lines only for a confined file belonging to this snapshot. + * Exact consumed text wins; otherwise a disk read must match checkerDigest. + */ + public lines(file: string): readonly string[] | undefined { + const key = graphFileOf(file); + if (this.cache.has(key)) return this.cache.get(key); + const absolute = path.resolve(this.project, key); + if (!isSubPath(this.project, absolute)) { + this.cache.set(key, undefined); + return undefined; + } + + const exact = this.texts.get(key); + if (exact !== undefined) return this.remember(key, exact); + + if (!confinedOnDisk(this.project, absolute)) { + this.cache.set(key, undefined); + return undefined; + } + + const expected = this.checkerDigests.get(key); + if (expected === undefined && !this.allowUnproven) { + this.cache.set(key, undefined); + return undefined; + } + let text: string; + try { + text = this.read(absolute).toString("utf8"); + } catch { + this.cache.set(key, undefined); + return undefined; + } + if (expected !== undefined && sha256(text) !== expected) { + this.cache.set(key, undefined); + return undefined; + } + return this.remember(key, text); + } + + private remember(key: string, text: string): readonly string[] { + const lines: readonly string[] = Object.freeze(text.split(/\r?\n/)); + this.cache.set(key, lines); + return lines; + } +} + +export namespace SamchonGraphSourceReader { + export interface IOptions { + /** Exact texts consumed by generic LSP/static lanes, keyed by any path form. */ + texts?: ReadonlyMap; + /** Compiler snapshot digests, keyed by any path form. */ + digests?: ReadonlyMap; + /** Compatibility mode for direct in-memory API callers only. */ + allowUnproven?: boolean; + /** Test seam for deterministic read/failure/cache coverage. */ + read?: ReadFile; + } +} + +function normalizeTexts( + project: string, + input: ReadonlyMap | undefined, +): ReadonlyMap { + const out = new Map(); + for (const [file, text] of input ?? []) out.set(relativeKey(project, file), text); + return out; +} + +function normalizeDigests( + project: string, + input: + | ReadonlyMap + | undefined, +): ReadonlyMap { + const out = new Map(); + for (const [file, digest] of input ?? []) { + if (digest.checkerDigest !== "") { + out.set(relativeKey(project, file), digest.checkerDigest); + } + } + return out; +} + +function relativeKey(project: string, file: string): string { + return graphFileOf( + path.isAbsolute(file) ? path.relative(project, file) : file, + ); +} + +function graphFileOf(file: string): string { + return normalizePath(file); +} + +function confinedOnDisk(project: string, candidate: string): boolean { + try { + return isSubPath(fs.realpathSync(project), fs.realpathSync(candidate)); + } catch { + return false; + } +} + +function sha256(text: string): string { + return createHash("sha256").update(text, "utf8").digest("hex"); +} diff --git a/packages/graph/src/index.ts b/packages/graph/src/index.ts index e66a532..32f0acb 100644 --- a/packages/graph/src/index.ts +++ b/packages/graph/src/index.ts @@ -2,10 +2,12 @@ export * from "./application"; export * from "./indexer"; /** What a graph result says about where its facts came from. */ export * from "./operations/RESULT_AUDIT"; +export * from "./operations/RESULT_AUDIT_DETAILS"; export * from "./operations/RESULT_AUDIT_SELECTION"; export * from "./operations/RESULT_AUDIT_ESCAPE"; export * from "./provider"; export * from "./SamchonGraphMemory"; +export * from "./SamchonGraphSourceReader"; export * from "./runGraph"; export * from "./reduce"; export * from "./view"; diff --git a/packages/graph/src/indexer/IResidentGraphSource.ts b/packages/graph/src/indexer/IResidentGraphSource.ts index f652264..77fa158 100644 --- a/packages/graph/src/indexer/IResidentGraphSource.ts +++ b/packages/graph/src/indexer/IResidentGraphSource.ts @@ -1,4 +1,5 @@ import { ISamchonGraphDump } from "../structures"; +import { SamchonGraphSourceReader } from "../SamchonGraphSourceReader"; /** * A resident indexer: builds once, keeps every language's LSP connection open, @@ -25,6 +26,9 @@ export interface IResidentGraphSource { */ load(): Promise; + /** Source display reader belonging to the dump returned by the last load. */ + source(): SamchonGraphSourceReader | undefined; + /** * End every language-server connection this source opened. Safe to call on a * source that never loaded -- an MCP server that exits before its first tool diff --git a/packages/graph/src/indexer/createResidentGraphSource.ts b/packages/graph/src/indexer/createResidentGraphSource.ts index 3e25c26..e68a30c 100644 --- a/packages/graph/src/indexer/createResidentGraphSource.ts +++ b/packages/graph/src/indexer/createResidentGraphSource.ts @@ -7,6 +7,7 @@ import { ISamchonGraphNode, } from "../structures"; import { GraphLanguage } from "../typings"; +import { SamchonGraphSourceReader } from "../SamchonGraphSourceReader"; import { IBulkGraphSession } from "../provider/IBulkGraphSession"; import { isBulkGraphSession } from "../provider/isBulkGraphSession"; import { mergeGraphSlices } from "../provider/mergeGraphSlices"; @@ -30,6 +31,7 @@ interface IResidentState { staticLanguages: GraphLanguage[]; languages: GraphLanguage[]; hashes: Map; + source: SamchonGraphSourceReader; } const DEFAULT_DEPENDENCIES = { @@ -57,6 +59,7 @@ export function createResidentGraphSource( }); const sessions = result.sessions ?? new Map(); try { + const texts = result.sources ?? new Map(); return { dump: result.dump, sessions, @@ -67,6 +70,7 @@ export function createResidentGraphSource( result.sources === undefined ? snapshotSources(root, options, bulkLanguagesOf(sessions)) : hashSources(result.sources), + source: sourceReaderOf(root, texts, sessions), }; } catch (error) { // Once the build hands its sessions to this source, every later failure @@ -167,6 +171,7 @@ export function createResidentGraphSource( current.dump = dump; current.hashes = hashSources(sources); current.generations = generations; + current.source = sourceReaderOf(root, sources, current.sessions); } async function replaceLanguages(current: IResidentState): Promise { @@ -220,6 +225,9 @@ export function createResidentGraphSource( return state.dump; }); }, + source(): SamchonGraphSourceReader | undefined { + return state?.source; + }, close(): Promise { // Flip the bit synchronously. A load already inside an await observes it // before publishing a newly-built/refreshed graph, while calls queued @@ -258,6 +266,21 @@ export function createResidentGraphSource( } } +function sourceReaderOf( + root: string, + texts: ReadonlyMap, + sessions: ReadonlyMap, +): SamchonGraphSourceReader { + const digests = new Map(); + for (const session of sessions.values()) { + if (!isBulkGraphSession(session)) continue; + for (const [file, digest] of session.current?.sources ?? []) { + digests.set(file, digest); + } + } + return new SamchonGraphSourceReader(root, { texts, digests }); +} + function languagesOf( sources: ReadonlyMap | undefined, root: string, diff --git a/packages/graph/src/mcp/createResidentGraphMemorySource.ts b/packages/graph/src/mcp/createResidentGraphMemorySource.ts index e75208c..12f278a 100644 --- a/packages/graph/src/mcp/createResidentGraphMemorySource.ts +++ b/packages/graph/src/mcp/createResidentGraphMemorySource.ts @@ -22,7 +22,10 @@ export function createResidentGraphMemorySource( return async () => { const dump = await resident.load(); if (currentMemory === undefined || dump !== currentDump) { - const replacement = SamchonGraphMemory.from(dump); + const replacement = SamchonGraphMemory.from( + dump, + resident.source() ?? undefined, + ); currentDump = dump; currentMemory = replacement; } diff --git a/packages/graph/src/mcp/startServer.ts b/packages/graph/src/mcp/startServer.ts index 4941909..6fda9ee 100644 --- a/packages/graph/src/mcp/startServer.ts +++ b/packages/graph/src/mcp/startServer.ts @@ -7,6 +7,7 @@ import { discoverLanguages } from "../indexer/discoverLanguages"; import { IBuildGraphOptions } from "../indexer/IBuildGraphOptions"; import { IResidentGraphSource } from "../indexer/IResidentGraphSource"; import { SamchonGraphMemory } from "../SamchonGraphMemory"; +import { SamchonGraphSourceReader } from "../SamchonGraphSourceReader"; import { ISamchonGraphDump } from "../structures"; import { GraphLanguage } from "../typings"; import { createResidentCloseHandler } from "./createResidentCloseHandler"; @@ -51,7 +52,9 @@ export async function startServer( fs.readFileSync(options.graphFile, "utf8"), ) as ISamchonGraphDump; languages = dump.languages; - source = once(() => SamchonGraphMemory.from(dump)); + source = once(() => + SamchonGraphMemory.from(dump, SamchonGraphSourceReader.none(dump.project)), + ); } else { const root = path.resolve(options.cwd ?? process.cwd()); languages = options.languages ?? discoverLanguages(root, options); diff --git a/packages/graph/src/operations/RESULT_AUDIT_DETAILS.ts b/packages/graph/src/operations/RESULT_AUDIT_DETAILS.ts new file mode 100644 index 0000000..a45a6e5 --- /dev/null +++ b/packages/graph/src/operations/RESULT_AUDIT_DETAILS.ts @@ -0,0 +1,25 @@ +import { ISamchonGraphDump } from "../structures"; +import { indexOf } from "./indexOf"; + +/** Audit for details, whose identity and fan-out have different bounds. */ +export function RESULT_AUDIT_DETAILS( + indexer: ISamchonGraphDump["indexer"], +): string { + return ` +AUDITED BEFORE RETURNING. READ FIRST. + +The server assembled this \`result\` from ${indexOf(indexer)} for the snapshot this call +synced to, then checked every returned name, span, edge, signature, member, and value +against that same index. Each returned fact is exactly what that index holds. + +This is the structure the graph records for the handles you named. What a symbol is — +its recorded members, values, and signature — is returned whole; this does not claim a +generic fallback proved facts it never indexed. What a symbol reaches or is reached by — +its calls, type references, implementers, and under \`neighbors\` its dependents — is a +short orientation slice because that grows with usage; \`trace\` follows the relationship +graph further. + +Follow \`next\`: answer from this result, and re-call the graph only when it says inspect, +or after you edit the source. +`.trim(); +} diff --git a/packages/graph/src/operations/common.ts b/packages/graph/src/operations/common.ts index 386902e..d969e83 100644 --- a/packages/graph/src/operations/common.ts +++ b/packages/graph/src/operations/common.ts @@ -3,7 +3,6 @@ export * from "./decoratorsOf"; export * from "./docOf"; export * from "./edgeEvidenceOf"; export * from "./exportFanIn"; -export * from "./fileLines"; export * from "./IResolvedGraphHandle"; export * from "./IRunnerOutput"; export * from "./isExecution"; diff --git a/packages/graph/src/operations/docOf.ts b/packages/graph/src/operations/docOf.ts index 4e59e2e..f04f43c 100644 --- a/packages/graph/src/operations/docOf.ts +++ b/packages/graph/src/operations/docOf.ts @@ -1,5 +1,5 @@ +import { SamchonGraphMemory } from "../SamchonGraphMemory"; import { ISamchonGraphNode } from "../structures"; -import { fileLines } from "./fileLines"; // A doc summary is one sentence; the rest of the comment is the file's to keep. const MAX_DOC_CHARS = 200; @@ -17,12 +17,12 @@ const MAX_DOC_CHARS = 200; * symbol with what it is for is doing an index's job. */ export function docOf( - project: string, + graph: SamchonGraphMemory, node: ISamchonGraphNode, ): string | undefined { const evidence = node.evidence; const lines = - evidence === undefined ? undefined : fileLines(project, evidence.file); + evidence === undefined ? undefined : graph.source.lines(evidence.file); if (lines === undefined || evidence === undefined) return undefined; // The lines above the declaration, walked up past the blanks. A span that // points past the end of its file names lines the file does not have; those diff --git a/packages/graph/src/operations/fileLines.ts b/packages/graph/src/operations/fileLines.ts deleted file mode 100644 index f17d3ea..0000000 --- a/packages/graph/src/operations/fileLines.ts +++ /dev/null @@ -1,11 +0,0 @@ -import path from "node:path"; -import { readLines } from "../utils/fs"; - -/** Read a file's lines once, or undefined when it cannot be read. */ -export function fileLines( - project: string, - file: string, -): string[] | undefined { - if (file === "") return undefined; - return readLines(path.join(project, file)); -} diff --git a/packages/graph/src/operations/resolveGraphHandle.ts b/packages/graph/src/operations/resolveGraphHandle.ts index b4b2a30..e64d58c 100644 --- a/packages/graph/src/operations/resolveGraphHandle.ts +++ b/packages/graph/src/operations/resolveGraphHandle.ts @@ -43,13 +43,13 @@ export function resolveGraphHandle( const forms = nativeQualifiedForms(handle); for (const form of forms) { - const byName = resolveGraphName(graph, form, candidateLimit); + const byName = resolveGraphName(graph, form); if (byName.node !== undefined || byName.candidates !== undefined) return rank(graph, byName, candidateLimit); } for (const form of forms) { - const byFile = resolveFileQualified(graph, form, candidateLimit); + const byFile = resolveFileQualified(graph, form); if (byFile.node !== undefined || byFile.candidates !== undefined) return rank(graph, byFile, candidateLimit); } @@ -57,7 +57,7 @@ export function resolveGraphHandle( for (const form of forms) { const symbol = symbolPartOf(form) ?? memberPartOf(form); if (symbol === undefined) continue; - const resolved = resolveGraphName(graph, symbol, candidateLimit); + const resolved = resolveGraphName(graph, symbol); if (resolved.node !== undefined || resolved.candidates !== undefined) return rank(graph, resolved, candidateLimit); } @@ -99,11 +99,10 @@ function symbolPartOf(handle: string): string | undefined { function resolveGraphName( graph: SamchonGraphMemory, name: string, - candidateLimit: number, ): IResolvedGraphHandle { const exact = graph.symbols(name); if (exact.length === 1) return { node: exact[0] }; - if (exact.length > 1) return { candidates: exact.slice(0, candidateLimit) }; + if (exact.length > 1) return { candidates: [...exact] }; // clangd can mix namespace dots with C++ member separators in one identity // (`leveldb.DBImpl::Get`), while callers naturally write either @@ -125,8 +124,7 @@ function resolveGraphName( ); }); if (cppMatches.length === 1) return { node: cppMatches[0] }; - if (cppMatches.length > 1) - return { candidates: cppMatches.slice(0, candidateLimit) }; + if (cppMatches.length > 1) return { candidates: cppMatches }; } if (name.includes(".")) { @@ -136,9 +134,7 @@ function resolveGraphName( node.kind !== "file" && node.qualifiedName?.endsWith(suffix) === true, ); if (suffixMatches.length === 1) return { node: suffixMatches[0] }; - if (suffixMatches.length > 1) { - return { candidates: suffixMatches.slice(0, candidateLimit) }; - } + if (suffixMatches.length > 1) return { candidates: suffixMatches }; } // JDT.LS and csharp-ls decorate callable symbol names with their parameter @@ -156,8 +152,7 @@ function resolveGraphName( return suffix !== undefined && qualified.endsWith(suffix); }); if (callables.length === 1) return { node: callables[0] }; - if (callables.length > 1) - return { candidates: callables.slice(0, candidateLimit) }; + if (callables.length > 1) return { candidates: callables }; } return {}; } @@ -182,7 +177,6 @@ function callableBaseOf(name: string): string { function resolveFileQualified( graph: SamchonGraphMemory, handle: string, - candidateLimit: number, ): IResolvedGraphHandle { const dot = handle.indexOf("."); if (dot <= 0) return {}; @@ -193,8 +187,7 @@ function resolveFileQualified( .symbols(name) .filter((node) => fileStem(node.file) === stem); if (matches.length === 1) return { node: matches[0] }; - if (matches.length > 1) - return { candidates: matches.slice(0, candidateLimit) }; + if (matches.length > 1) return { candidates: matches }; // A language server may preserve a callable's parameter list in both the // simple and qualified names. The file portion is still the caller's @@ -217,8 +210,7 @@ function resolveFileQualified( ); }); if (callables.length === 1) return { node: callables[0] }; - if (callables.length > 1) - return { candidates: callables.slice(0, candidateLimit) }; + if (callables.length > 1) return { candidates: callables }; } return {}; } @@ -244,11 +236,19 @@ function rank( ): IResolvedGraphHandle { if (resolved.candidates === undefined) return resolved; const ranked = [...resolved.candidates] - .sort((a, b) => candidateScore(graph, b) - candidateScore(graph, a)) + .sort((a, b) => { + const score = candidateScore(graph, b) - candidateScore(graph, a); + return score === 0 ? compareIdentity(a.id, b.id) : score; + }) .slice(0, candidateLimit); return { candidates: ranked }; } +/** Stable tie-break for position-invariant ids without locale collation. */ +function compareIdentity(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + function candidateScore( graph: SamchonGraphMemory, node: ISamchonGraphNode, diff --git a/packages/graph/src/operations/runDetails.ts b/packages/graph/src/operations/runDetails.ts index d508b8f..8bfd9c1 100644 --- a/packages/graph/src/operations/runDetails.ts +++ b/packages/graph/src/operations/runDetails.ts @@ -9,7 +9,6 @@ import { bound } from "./bound"; import { decoratorsOf } from "./decoratorsOf"; import { docOf } from "./docOf"; import { edgeEvidenceOf } from "./edgeEvidenceOf"; -import { fileLines } from "./fileLines"; import { isExternalNode } from "./isExternalNode"; import { isStructural } from "./isStructural"; import { isTestPath } from "./isTestPath"; @@ -29,8 +28,6 @@ const DEFAULT_NEIGHBORS = 2; const MAX_NEIGHBORS = 3; const DEFAULT_DEPENDENCIES = 2; const MAX_DEPENDENCIES = 4; -// Object literal outlines are navigation aids, not source excerpts. -const MAX_OBJECT_MEMBER_LINES = 300; // Kinds whose value is their member outline, not implementation text. const CONTAINER_KINDS = new Set([ "class", @@ -106,11 +103,10 @@ export function runDetails( file: node.file, }; if (node.evidence?.startLine) detail.line = node.evidence.startLine; - const sig = signatureOf(graph.project, node); + const sig = signatureOf(graph, node); if (sig !== undefined) detail.signature = sig; - const doc = docOf(graph.project, node); + const doc = docOf(graph, node); if (doc !== undefined) detail.doc = doc; - const signatureLiterals = literalSummaries(sig); const decorators = decoratorsOf(node); if (decorators !== undefined) detail.decorators = decorators; const implementation = evidenceCoordinatesOf(node.implementation); @@ -151,17 +147,17 @@ export function runDetails( const list = members(graph, node, memberLimit); if (list.length > 0) detail.members = list; } - if (node.kind === "variable" && detail.sourceSpan !== undefined) { - const list = objectLiteralMembers( - graph.project, - detail.sourceSpan, - memberLimit, - ); + if (node.kind === "variable") { + const list = objectLiteralMembers(node, memberLimit); + if (list.length > 0) detail.members = list; + } + if (node.kind === "enum") { + const list = enumMembers(node, memberLimit); if (list.length > 0) detail.members = list; } - // A union or enum's value set is part of the symbol's identity, not a - // sample of it, so the literals a signature names are returned whole. - if (signatureLiterals.length > 0) detail.literals = signatureLiterals; + if (node.literals !== undefined && node.literals.length > 0) { + detail.literals = node.literals; + } if (wantNeighbors) { detail.dependsOn = refs( graph, @@ -222,7 +218,7 @@ function members( kind: member.kind, }; if (member.evidence?.startLine) m.line = member.evidence.startLine; - const sig = signatureOf(graph.project, member); + const sig = signatureOf(graph, member); if (sig !== undefined) m.signature = sig; const decorators = decoratorsOf(member); if (decorators !== undefined) m.decorators = decorators; @@ -233,85 +229,30 @@ function members( } function objectLiteralMembers( - project: string, - span: Pick, + node: ISamchonGraphNode, limit: number, ): ISamchonGraphDetails.IMember[] { - if (span.endLine === undefined) return []; - if (span.endLine - span.startLine > MAX_OBJECT_MEMBER_LINES) return []; - const lines = fileLines(project, span.file); - if (lines === undefined) return []; - const start = Math.max(0, span.startLine - 1); - const end = Math.min(lines.length - 1, span.endLine - 1); - const members: ISamchonGraphDetails.IMember[] = []; - let depth = 0; - let entered = false; - for (let i = start; i <= end; i++) { - // `end` is bounded by `lines.length - 1`, so `i` is always in range. - /* c8 ignore next */ - const raw = lines[i] ?? ""; - const text = stripStrings(raw); - const before = depth; - if (entered && before === 1) { - const member = objectMemberOf(raw, i + 1); - if (member !== undefined) { - members.push(member); - if (members.length >= limit) break; - } - } - for (const char of text) { - if (char === "{") { - depth++; - entered = true; - } else if (char === "}") { - depth = Math.max(0, depth - 1); - } - } - } - return members; + return (node.objectMembers ?? []).slice(0, limit).map((member) => ({ + name: member.name, + kind: member.kind, + ...(member.line !== undefined ? { line: member.line } : {}), + ...(member.signature !== undefined + ? { signature: member.signature } + : {}), + })); } -function objectMemberOf( - line: string, - lineNumber: number, -): ISamchonGraphDetails.IMember | undefined { - const text = line.trim(); - if ( - text === "" || - text.startsWith("//") || - text.startsWith("/*") || - text.startsWith("*") - ) { - return undefined; - } - const property = /^(['"]?)([A-Za-z_$][\w$-]*)\1\s*\??\s*:/.exec(text); - if (property !== null) { - return { - name: property[2]!, - kind: "property", - line: lineNumber, - signature: signatureLine(text), - }; - } - const method = - /^(?:async\s+)?(?:get\s+|set\s+)?([A-Za-z_$][\w$-]*)\s*\(/.exec(text); - if (method !== null) { - return { - name: method[1]!, - kind: "method", - line: lineNumber, - signature: signatureLine(text), - }; - } - return undefined; -} - -function signatureLine(text: string): string { - return text.replace(/\s+/g, " ").replace(/,$/, ""); -} - -function stripStrings(line: string): string { - return line.replace(/\/\/.*$/, "").replace(/(['"`])(?:\\.|(?!\1).)*\1/g, ""); +function enumMembers( + node: ISamchonGraphNode, + limit: number, +): ISamchonGraphDetails.IMember[] { + return (node.enumMembers ?? []).slice(0, limit).map((member) => ({ + name: `${node.qualifiedName ?? node.name}.${member.name}`, + kind: "property", + ...(member.value !== undefined + ? { signature: `${member.name} = ${member.value}` } + : {}), + })); } /** Map dependency edges to references on their far endpoint, dropping structure. */ @@ -444,30 +385,6 @@ function incomingDependencyRefs( return out; } -function literalSummaries(text: string | undefined): string[] { - if (text === undefined) return []; - const out: string[] = []; - for (const match of text.matchAll(/(["'`])((?:\\.|(?!\1).){1,80})\1/g)) { - const value = cleanLiteral(match[2]); - if (value !== undefined && !out.includes(value)) out.push(value); - if (out.length >= 20) break; - } - return out; -} - -function cleanLiteral(value: string | undefined): string | undefined { - const text = value?.replace(/\s+/g, " ").trim(); - if ( - text === undefined || - text === "" || - text.length > 40 || - /^[{}()[\],.:;]+$/.test(text) - ) { - return undefined; - } - return text; -} - /** * An identity list's cap: none by default, honored when a caller passes one. * diff --git a/packages/graph/src/operations/runEntrypoints.ts b/packages/graph/src/operations/runEntrypoints.ts index 643b60e..293f293 100644 --- a/packages/graph/src/operations/runEntrypoints.ts +++ b/packages/graph/src/operations/runEntrypoints.ts @@ -119,7 +119,7 @@ function nodeOf( }; if (node.evidence?.startLine !== undefined) out.line = node.evidence.startLine; - const signature = signatureOf(graph.project, node); + const signature = signatureOf(graph, node); if (signature !== undefined) out.signature = signature; const decorators = decoratorsOf(node); if (decorators !== undefined) out.decorators = decorators; diff --git a/packages/graph/src/operations/runLookup.ts b/packages/graph/src/operations/runLookup.ts index 95f6753..c379397 100644 --- a/packages/graph/src/operations/runLookup.ts +++ b/packages/graph/src/operations/runLookup.ts @@ -96,7 +96,7 @@ export function runLookup( // Every hit's id came from a node in this same graph just above. /* c8 ignore next */ if (node === undefined) continue; - const sig = signatureOf(graph.project, node); + const sig = signatureOf(graph, node); if (sig !== undefined) hit.signature = sig; } return { diff --git a/packages/graph/src/operations/runTour.ts b/packages/graph/src/operations/runTour.ts index 0d9c094..a52391d 100644 --- a/packages/graph/src/operations/runTour.ts +++ b/packages/graph/src/operations/runTour.ts @@ -403,8 +403,8 @@ function graphNodeOf( node: ISamchonGraphNode, ): ISamchonGraphTour.INode { const span = node.implementation ?? node.evidence; - const signature = signatureOf(graph.project, node); - const doc = docOf(graph.project, node); + const signature = signatureOf(graph, node); + const doc = docOf(graph, node); const decorators = decoratorsOf(node); return { id: node.id, diff --git a/packages/graph/src/operations/runTrace.ts b/packages/graph/src/operations/runTrace.ts index 85eed69..1604235 100644 --- a/packages/graph/src/operations/runTrace.ts +++ b/packages/graph/src/operations/runTrace.ts @@ -153,12 +153,12 @@ export function runTrace( focus, includeExternal, ); + const hasPath = found !== null; const path = found?.path ?? []; const hops = found?.hops ?? []; - const junctions = - hops.length > 0 - ? [] - : junctionsBetween(graph, start.node.id, target.node.id, focus); + const junctions = hasPath + ? [] + : junctionsBetween(graph, start.node.id, target.node.id, focus); return { result: { ...base, @@ -178,10 +178,9 @@ export function runTrace( // callers of the target are the way across, and the graph has them, so // say which call to make instead of handing back an empty result dressed // as the answer. Excalidraw's tour spent eleven calls finding this out. - next: - hops.length > 0 - ? pathNext - : junctions.length > 0 + next: hasPath + ? pathNext + : junctions.length > 0 ? resultNext( "inspect", "No call path runs between the two ends — a callback stands between them (an event emitter, a subscription, a lifecycle hook), and no call edge crosses one. `junctions` names the symbols both ends touch, which is the seam: trace the junction to see who registers on it and who fires it.", @@ -205,24 +204,35 @@ export function runTrace( while (queue.length > 0) { const next: Array<{ id: string; depth: number }> = []; for (const { id, depth } of queue) { + const candidates = traceEdges(graph, id, reverse, focus); if (depth >= maxDepth) { - truncated = true; + if ( + candidates.some( + (edge) => + eligibleTraceEndpoint( + graph, + edge, + reverse, + focus, + includeExternal, + ) !== undefined, + ) + ) { + truncated = true; + } continue; } - const edges = orderedEdges( - graph, - reverse - ? graph.incoming(id) - : [...graph.outgoing(id), ...dispatchEdges(graph, id, focus)], - direction, - reverse, - ); + const edges = orderedEdges(graph, candidates, direction, reverse); for (const edge of edges) { - if (!traversable(edge.kind, focus)) continue; - const otherId = reverse ? edge.from : edge.to; - const other = graph.node(otherId); - if (other === undefined || other.kind === "file") continue; - if (!includeExternal && isExternalNode(other)) continue; + const endpoint = eligibleTraceEndpoint( + graph, + edge, + reverse, + focus, + includeExternal, + ); + if (endpoint === undefined) continue; + const { otherId, other } = endpoint; const hop: ISamchonGraphTrace.IHop = { from: edge.from, to: edge.to, @@ -274,6 +284,39 @@ export function runTrace( }; } +interface ITraceEndpoint { + otherId: string; + other: ISamchonGraphNode; +} + +/** Candidate edges in the selected direction before focus and node policy. */ +function traceEdges( + graph: SamchonGraphMemory, + id: string, + reverse: boolean, + focus: ISamchonGraphTrace.IRequest["focus"], +): readonly ISamchonGraphEdge[] { + return reverse + ? graph.incoming(id) + : [...graph.outgoing(id), ...dispatchEdges(graph, id, focus)]; +} + +/** The endpoint an unbounded trace would represent, if any. */ +function eligibleTraceEndpoint( + graph: SamchonGraphMemory, + edge: ISamchonGraphEdge, + reverse: boolean, + focus: ISamchonGraphTrace.IRequest["focus"], + includeExternal: boolean, +): ITraceEndpoint | undefined { + if (!traversable(edge.kind, focus)) return undefined; + const otherId = reverse ? edge.from : edge.to; + const other = graph.node(otherId); + if (other === undefined || other.kind === "file") return undefined; + if (!includeExternal && isExternalNode(other)) return undefined; + return { otherId, other }; +} + function traceSteps( graph: SamchonGraphMemory, hops: ISamchonGraphTrace.IHop[], @@ -578,7 +621,7 @@ function summary( } if (depth !== undefined) out.depth = depth; if (withSignature) { - const sig = signatureOf(graph.project, node); + const sig = signatureOf(graph, node); if (sig !== undefined) out.signature = sig; } if (withRoles) { diff --git a/packages/graph/src/operations/signatureOf.ts b/packages/graph/src/operations/signatureOf.ts index cac4909..d6f5d21 100644 --- a/packages/graph/src/operations/signatureOf.ts +++ b/packages/graph/src/operations/signatureOf.ts @@ -1,5 +1,5 @@ +import { SamchonGraphMemory } from "../SamchonGraphMemory"; import { ISamchonGraphNode } from "../structures"; -import { fileLines } from "./fileLines"; // A signature is the declaration head up to the body brace: a handful of lines. const MAX_SIGNATURE_LINES = 4; @@ -10,12 +10,12 @@ const MAX_SIGNATURE_LINES = 4; * is no brace, capped so a wrapped signature cannot run away. */ export function signatureOf( - project: string, + graph: SamchonGraphMemory, node: ISamchonGraphNode, ): string | undefined { const evidence = node.evidence; const lines = - evidence === undefined ? undefined : fileLines(project, evidence.file); + evidence === undefined ? undefined : graph.source.lines(evidence.file); if (lines === undefined || evidence === undefined) return undefined; const start = Math.max(0, evidence.startLine - 1); const out: string[] = []; diff --git a/packages/graph/src/provider/ttscgraph/ITtscGraphSnapshot.ts b/packages/graph/src/provider/ttscgraph/ITtscGraphSnapshot.ts index 9725138..1459c6c 100644 --- a/packages/graph/src/provider/ttscgraph/ITtscGraphSnapshot.ts +++ b/packages/graph/src/provider/ttscgraph/ITtscGraphSnapshot.ts @@ -104,7 +104,7 @@ export namespace ITtscGraphSnapshot { * `DumpSchemaVersion` in ttsc's `internal/graph/provenance.go` for the pinned * producer release. */ - export const DUMP_SCHEMA_VERSION = 1; + export const DUMP_SCHEMA_VERSION = 5; /** * What the compiler did, as opposed to what the transport did. diff --git a/packages/graph/src/provider/ttscgraph/TtscGraphClient.ts b/packages/graph/src/provider/ttscgraph/TtscGraphClient.ts index bb2956c..a217dd9 100644 --- a/packages/graph/src/provider/ttscgraph/TtscGraphClient.ts +++ b/packages/graph/src/provider/ttscgraph/TtscGraphClient.ts @@ -1,46 +1,41 @@ import { ChildProcessWithoutNullStreams, spawn } from "node:child_process"; + import { IBulkGraphSession } from "../IBulkGraphSession"; import { adaptTtscGraphDump } from "./adaptTtscGraphDump"; import { ITtscGraphSnapshot } from "./ITtscGraphSnapshot"; import { parseTtscGraphSnapshot } from "./parseTtscGraphSnapshot"; -/** - * Resident NDJSON client for `ttscgraph serve`. - * - * One request per refresh, and no disk read. Both of those used to be false and - * were the same defect: this client adapted the dump, then `readText`-ed every - * file the dump named — off the disk, outside the compiler's program, at a - * later instant — and then issued a *second* `{id}` round-trip purely to ask - * whether anything had moved in between, looping if it had. - * - * That protocol narrowed the race without closing it, and could not close it. A - * write that lands and reverts between the dump and the confirmation is - * invisible to both. And a clean confirmation only ever proved that the *server* - * saw no change — never that the bytes this process read are the bytes the - * checker resolved against, which is the only thing the sources were wanted for. - * Under a source-preamble plugin the two are not even supposed to match. - * - * The manifest ends the question by answering it at the source: the producer - * publishes the digest of the text its checker read, in the same envelope as - * the facts. There is nothing left to confirm, so there is no second request, - * and nothing left to read, so there is no disk access. - */ +const DEFAULT_REQUEST_TIMEOUT_MS = 300_000; +const MAX_TIMER_MS = 2_147_483_647; +const TERMINATION_GRACE_MS = 1_000; + +interface NativeChild { + process: ChildProcessWithoutNullStreams; + stdoutChunks: string[]; + stderr: string; +} + +interface Pending { + child: NativeChild; + resolve: (value: ITtscGraphSnapshot) => void; + reject: (error: Error) => void; + timer: NodeJS.Timeout; + signal?: AbortSignal; + abort?: () => void; +} + +/** Resident, restartable NDJSON client for `ttscgraph serve`. */ export class TtscGraphClient implements IBulkGraphSession { public readonly kind = "bulk" as const; public readonly language = "typescript" as const; public readonly root: string; - private readonly child: ChildProcessWithoutNullStreams; - private readonly pending = new Map< - number, - { - resolve: (value: ITtscGraphSnapshot) => void; - reject: (error: Error) => void; - } - >(); + private readonly command: string; + private readonly args: readonly string[]; + private readonly requestTimeoutMs: number; + private child: NativeChild | undefined; + private readonly pending = new Map(); private nextId = 1; - private readonly stdoutChunks: string[] = []; - private stderr = ""; private queue: Promise = Promise.resolve(); private closed = false; private closing: Promise | undefined; @@ -48,50 +43,21 @@ export class TtscGraphClient implements IBulkGraphSession { private version = 0; public constructor(options: TtscGraphClient.IOptions) { - this.root = options.root; - this.child = spawn( - options.command, - [...(options.args ?? []), "serve", "--cwd", options.root], - { - cwd: options.root, - env: process.env, - shell: false, - stdio: ["pipe", "pipe", "pipe"], - windowsHide: true, - }, - ); - this.child.stdout.setEncoding("utf8"); - this.child.stderr.setEncoding("utf8"); - this.child.stdout.on("data", (chunk: string) => { - this.consume(chunk); - }); - this.child.stderr.on("data", (chunk: string) => { - this.stderr = (this.stderr + chunk).slice(-64 * 1024); - }); - this.child.on("error", (error) => { - this.rejectPending(error); - }); - /* c8 ignore start */ - this.child.stdin.on("error", (error) => { - // Even a child that explicitly destroys its stdin can leave the parent's - // pipe writable on Windows. Delivery of this Writable error is a libuv - // boundary, not a state a hermetic fake child can induce portably. - this.rejectPending(error); - }); - /* c8 ignore stop */ - this.child.on("exit", (code, signal) => { - // Node supplies a signal for termination or a code for normal exit. The - // callback types are nullable because they distinguish those two cases, - // not because an exit can have neither. - const status = signal ?? code; - this.rejectPending( - new Error( - `ttscgraph: process exited (${status})${ - this.stderr.trim() === "" ? "" : `: ${this.stderr.trim()}` - }`, - ), + const requestTimeoutMs = + options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + if ( + !Number.isSafeInteger(requestTimeoutMs) || + requestTimeoutMs <= 0 || + requestTimeoutMs > MAX_TIMER_MS + ) { + throw new TypeError( + `ttscgraph: requestTimeoutMs must be an integer between 1 and ${String(MAX_TIMER_MS)}`, ); - }); + } + this.root = options.root; + this.command = options.command; + this.args = options.args ?? []; + this.requestTimeoutMs = requestTimeoutMs; } public get generation(): number { @@ -102,198 +68,320 @@ export class TtscGraphClient implements IBulkGraphSession { return this.snapshot; } - public refresh(): Promise { + public refresh( + options: TtscGraphClient.IRefreshOptions = {}, + ): Promise { + if (this.closed) { + return Promise.reject(new Error("ttscgraph: session is closed")); + } return this.enqueue(async () => { this.assertOpen(); - const response = await this.request(); - // Narrowing, not a lookup: `mode` is typed `"error"` exactly on the frames - // that carry one, so ruling failure out here is what makes the rest of - // this method unable to ask for a snapshot that was never produced. - if (response.mode === "error") { - throw new Error(`ttscgraph: ${response.error}`); - } - const mode: IBulkGraphSession.Mode = response.mode; - if (!response.changed) { - if (this.snapshot === undefined) { + try { + const response = await this.request(options.signal); + if (response.mode === "error") { + throw new Error(`ttscgraph: ${response.error}`); + } + const mode: IBulkGraphSession.Mode = response.mode; + if (!response.changed) { + if (this.snapshot === undefined) { + throw new Error( + "ttscgraph: first response was unchanged without a snapshot", + ); + } + assertCapabilitiesMatch( + response.capabilities, + this.snapshot.provenance.capabilities, + ); + return { + changed: false, + generation: this.version, + mode, + snapshot: this.snapshot, + }; + } + + const adapted = adaptTtscGraphDump(response.dump, this.root); + const provenance: IBulkGraphSession.IProvenance = { + ...adapted.provenance, + protocolVersion: response.protocolVersion, + }; + assertCapabilitiesMatch(response.capabilities, provenance.capabilities); + if ( + mode === "incremental" && + this.snapshot !== undefined && + this.snapshot.provenance.universe !== provenance.universe + ) { throw new Error( - "ttscgraph: first response was unchanged without a snapshot", + "ttscgraph: incremental snapshot reports a build universe that moved since the last generation, so its program cannot have been reused", ); } - assertCapabilitiesMatch( - response.capabilities, - this.snapshot.provenance.capabilities, - ); + const next: IBulkGraphSession.ISnapshot = { + language: "typescript", + nodes: adapted.nodes, + edges: adapted.edges, + diagnostics: adapted.diagnostics, + sources: adapted.sources, + provenance, + warnings: adapted.warnings, + }; + this.snapshot = next; + this.version += 1; return { - changed: false, + changed: true, generation: this.version, mode, - snapshot: this.snapshot, + snapshot: next, }; + } catch (error) { + const child = this.child; + if (child !== undefined) this.failChild(child, asError(error)); + throw error; } - - // Parse and validate the complete response before publishing any part of - // it. A malformed full dump leaves both the previous snapshot and its - // generation untouched. - const adapted = adaptTtscGraphDump(response.dump, this.root); - const provenance: IBulkGraphSession.IProvenance = { - ...adapted.provenance, - protocolVersion: response.protocolVersion, - }; - assertCapabilitiesMatch(response.capabilities, provenance.capabilities); - // `incremental` means the resident program was reused, and a program can - // only be reused while the inputs that decide its file set hold still — - // that is what separates it from `reload` upstream. So the claim has - // independent evidence riding beside it, and checking costs one string - // compare. A producer whose universe moved under an `incremental` label - // is not a producer whose mode is cosmetically wrong: it is one that - // reused a program it should have reloaded, and every fact in the - // snapshot is suspect. Report `mode` honestly means refusing to report a - // `mode` the snapshot itself contradicts. - if ( - mode === "incremental" && - this.snapshot !== undefined && - this.snapshot.provenance.universe !== provenance.universe - ) { - throw new Error( - "ttscgraph: incremental snapshot reports a build universe that moved since the last generation, so its program cannot have been reused", - ); - } - const next: IBulkGraphSession.ISnapshot = { - language: "typescript", - nodes: adapted.nodes, - edges: adapted.edges, - diagnostics: adapted.diagnostics, - sources: adapted.sources, - provenance, - warnings: adapted.warnings, - }; - this.snapshot = next; - this.version += 1; - return { - changed: true, - generation: this.version, - mode, - snapshot: next, - }; - }); + }, options.signal); } + /** Close immediately even when a serialized refresh is stalled. */ public close(): Promise { if (this.closing !== undefined) return this.closing; this.closed = true; - this.closing = this.enqueue(async () => { - // `waitForExit` owns both sides of the process-status race, including a - // child that exited before close began. Ending an already-closed writable - // is harmless and keeps that state transition in one place. - this.child.stdin.end(); - if (await waitForExit(this.child, 2_000)) return; - // This exact ChildProcess is owned by this client. No PID lookup or - // process-tree operation is used, so an unrelated process cannot be hit. - this.child.kill(); + const error = new Error("ttscgraph: session is closed"); + const child = this.child; + if (child === undefined) { + this.failPending(error); + this.closing = Promise.resolve(); + return this.closing; + } + this.failChild(child, error); + this.closing = (async () => { + if (await waitForExit(child.process, 2_000)) return; + terminateChild(child.process, true); /* c8 ignore start */ - // A child can ignore SIGTERM on POSIX, while Windows terminates the same - // owned process unconditionally. No hermetic cross-platform fixture can - // reach this defensive failure without leaking that deliberately defiant - // process after the test. - if (!(await waitForExit(this.child, 2_000))) { + if (!(await waitForExit(child.process, 2_000))) { throw new Error("ttscgraph: owned process did not exit after close"); } /* c8 ignore stop */ - }); + })(); return this.closing; } - private request(): Promise { + private request(signal?: AbortSignal): Promise { + if (signal?.aborted) throw cancelledError(signal); + const child = this.ensureChild(); const id = this.nextId++; - const response = new Promise((resolve, reject) => { - this.pending.set(id, { resolve, reject }); + return new Promise((resolve, reject) => { + const pending: Pending = { + child, + resolve, + reject, + timer: setTimeout(() => { + this.failChild( + child, + new Error( + `ttscgraph: snapshot request timed out after ${String(this.requestTimeoutMs)} ms${stderrSuffix(child)}`, + ), + ); + }, this.requestTimeoutMs), + signal, + }; + pending.timer.unref(); + if (signal !== undefined) { + pending.abort = () => + this.failChild(child, cancelledError(signal, child)); + signal.addEventListener("abort", pending.abort, { once: true }); + } + this.pending.set(id, pending); + if (signal?.aborted) { + pending.abort!(); + return; + } + child.process.stdin.write(`${JSON.stringify({ id })}\n`, (error) => { + if (error === null || error === undefined) return; + if (this.pending.get(id) !== pending) return; + this.failChild( + child, + new Error(`ttscgraph: could not request snapshot: ${error.message}`), + ); + }); }); - this.child.stdin.write(`${JSON.stringify({ id })}\n`, (error) => { - if (error === null || error === undefined) return; - const pending = this.pending.get(id); - this.pending.delete(id); - // The stdin error event may clear the same request before this write - // callback observes the pipe failure; their ordering belongs to libuv. - /* c8 ignore next */ - pending?.reject(error); + } + + private ensureChild(): NativeChild { + this.assertOpen(); + if ( + this.child !== undefined && + this.child.process.exitCode === null && + this.child.process.signalCode === null + ) { + return this.child; + } + const spawned = spawn( + this.command, + [...this.args, "serve", "--cwd", this.root], + { + cwd: this.root, + env: process.env, + shell: false, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }, + ); + const child: NativeChild = { + process: spawned, + stdoutChunks: [], + stderr: "", + }; + this.child = 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); }); - return response; + spawned.on("error", (error) => + this.failChild( + child, + new Error(`ttscgraph: process failed: ${error.message}`), + ), + ); + /* c8 ignore start */ + spawned.stdin.on("error", (error) => + this.failChild( + child, + new Error(`ttscgraph: stdin failed: ${error.message}`), + ), + ); + /* c8 ignore stop */ + spawned.on("exit", (code, signal) => { + const status = signal ?? code; + this.failChild( + child, + new Error( + `ttscgraph: process exited (${status})${stderrSuffix(child)}`, + ), + false, + ); + }); + return child; } - private consume(chunk: string): void { + private consume(child: NativeChild, chunk: string): void { + if (this.child !== child) return; let start = 0; for (;;) { const newline = chunk.indexOf("\n", start); if (newline === -1) { - if (start < chunk.length) this.stdoutChunks.push(chunk.slice(start)); + if (start < chunk.length) child.stdoutChunks.push(chunk.slice(start)); return; } - this.stdoutChunks.push(chunk.slice(start, newline)); - const line = this.stdoutChunks.join("").trim(); - this.stdoutChunks.length = 0; + child.stdoutChunks.push(chunk.slice(start, newline)); + const line = child.stdoutChunks.join("").trim(); + child.stdoutChunks.length = 0; start = newline + 1; if (line === "") continue; let value: unknown; try { value = JSON.parse(line); } catch (error) { - this.rejectPending( + this.failChild( + child, new Error( `ttscgraph: invalid NDJSON response: ${asError(error).message}`, ), ); - continue; + return; } let response: ITtscGraphSnapshot; try { - // The whole frame is validated here, before it is routed. A protocol - // mismatch or a malformed envelope is never one caller's bad luck — it - // is the wrong binary, or a producer this client cannot read at all, so - // every request outstanding against it is equally doomed and fails with - // that same reason rather than hanging until the process exits. response = parseTtscGraphSnapshot(value); } catch (error) { - this.rejectPending(asError(error)); - continue; + this.failChild(child, asError(error)); + return; } const pending = this.pending.get(response.id); - if (pending === undefined) { - this.rejectPending( + if (pending === undefined || pending.child !== child) { + this.failChild( + child, new Error(`ttscgraph: unexpected response id ${String(response.id)}`), ); - continue; + return; + } + this.settlePending(response.id, pending, response); + } + } + + private failChild( + child: NativeChild, + error: Error, + terminate = true, + ): void { + if (this.child !== child) return; + this.child = undefined; + this.snapshot = undefined; + this.failPending(error, child); + if (terminate) terminateChild(child.process); + } + + private failPending(error: Error, child?: NativeChild): void { + for (const [id, pending] of this.pending) { + if (child === undefined || pending.child === child) { + this.settlePending(id, pending, error); } - this.pending.delete(response.id); - pending.resolve(response); } } - private rejectPending(error: Error): void { - for (const pending of this.pending.values()) pending.reject(error); - this.pending.clear(); + private settlePending( + id: number, + pending: Pending, + result: ITtscGraphSnapshot | Error, + ): void { + if (this.pending.get(id) !== pending) return; + this.pending.delete(id); + clearTimeout(pending.timer); + if (pending.signal !== undefined && pending.abort !== undefined) { + pending.signal.removeEventListener("abort", pending.abort); + } + if (result instanceof Error) pending.reject(result); + else pending.resolve(result); } private assertOpen(): void { if (this.closed) throw new Error("ttscgraph: session is closed"); - if (this.child.exitCode !== null || this.child.signalCode !== null) { - throw new Error( - `ttscgraph: process is not running${ - this.stderr.trim() === "" ? "" : `: ${this.stderr.trim()}` - }`, - ); - } } - private enqueue(task: () => Promise): Promise { + 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 = resolve; - rejectResult = reject; + resolveResult = (value) => { + if (settled) return; + settled = true; + resolve(value); + }; + rejectResult = (error) => { + if (settled) return; + 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) { @@ -309,9 +397,33 @@ export namespace TtscGraphClient { root: string; command: string; args?: readonly string[]; + /** Maximum time for one native snapshot response. */ + requestTimeoutMs?: number; + } + + export interface IRefreshOptions { + /** Cancel this refresh and retire the native child generation it owns. */ + signal?: AbortSignal; } } +function terminateChild( + child: ChildProcessWithoutNullStreams, + forceNow = false, +): void { + if (!child.stdin.destroyed) child.stdin.destroy(); + if (child.exitCode !== null || child.signalCode !== null) return; + try { + child.kill(forceNow ? "SIGKILL" : undefined); + } catch { + return; + } + if (forceNow) return; + const force = setTimeout(() => terminateChild(child, true), TERMINATION_GRACE_MS); + force.unref(); + child.once("exit", () => clearTimeout(force)); +} + function waitForExit( child: ChildProcessWithoutNullStreams, timeoutMs: number, @@ -322,11 +434,8 @@ function waitForExit( return new Promise((resolve) => { let settled = false; const finish = (value: boolean): void => { - /* c8 ignore start */ - // The first callback removes the exit listener and clears the timer. This - // guard only protects callbacks that libuv had already queued together. + /* c8 ignore next */ if (settled) return; - /* c8 ignore stop */ settled = true; clearTimeout(timer); child.off("exit", exited); @@ -336,16 +445,36 @@ function waitForExit( const exited = (): void => finish(true); const timer = setTimeout(() => finish(false), timeoutMs); child.once("exit", exited); - // A command that cannot be spawned emits `error` and then `close`, but no - // `exit`. Waiting for both process terminal events lets close() retire that - // failed handle immediately instead of idling through its graceful timeout. child.once("close", exited); }); } +function cancelledError(signal?: AbortSignal, child?: NativeChild): Error { + const error = new Error( + `ttscgraph: snapshot request cancelled${abortDetail(signal)}${ + child === undefined ? "" : stderrSuffix(child) + }`, + ); + error.name = "AbortError"; + return error; +} + +function abortDetail(signal?: AbortSignal): string { + const reason = signal?.reason; + if (reason === undefined) return ""; + try { + return `: ${reason instanceof Error ? reason.message : String(reason)}`; + } catch { + return ""; + } +} + +function stderrSuffix(child: NativeChild): string { + const stderr = child.stderr.trim(); + return stderr === "" ? "" : `: ${stderr}`; +} + function asError(error: unknown): Error { - // All current throw/rejection sites produce Error objects. The conversion is - // retained because JavaScript permits future callees to throw any value. /* c8 ignore next */ return error instanceof Error ? error : new Error(String(error)); } @@ -355,7 +484,7 @@ function assertCapabilitiesMatch( dump: readonly string[], ): void { const compare = (left: string, right: string): number => - left.localeCompare(right); + left < right ? -1 : left > right ? 1 : 0; const left = JSON.stringify([...envelope].sort(compare)); const right = JSON.stringify([...dump].sort(compare)); if (left !== right) { diff --git a/packages/graph/src/provider/ttscgraph/adaptTtscGraphDump.ts b/packages/graph/src/provider/ttscgraph/adaptTtscGraphDump.ts index b20d116..f34199f 100644 --- a/packages/graph/src/provider/ttscgraph/adaptTtscGraphDump.ts +++ b/packages/graph/src/provider/ttscgraph/adaptTtscGraphDump.ts @@ -122,6 +122,15 @@ export function adaptTtscGraphDump( modifierOf(value, `${id}.modifiers[${modifierIndex}]`), ); } + if (raw.literals !== undefined) { + node.literals = stringArrayOf(raw.literals, `${id}.literals`); + } + if (raw.enumMembers !== undefined) { + node.enumMembers = enumMembersOf(raw.enumMembers, id); + } + if (raw.objectMembers !== undefined) { + node.objectMembers = objectMembersOf(raw.objectMembers, id); + } if (raw.decorators !== undefined) { node.decorators = decoratorsOf(raw.decorators, id); } @@ -474,6 +483,7 @@ const EDGE_KINDS = new Set([ "type_ref", "extends", "implements", + "overrides", "renders", ]); const MODIFIERS = new Set[number]>([ @@ -638,6 +648,48 @@ function decoratorsOf(value: unknown, id: string): ISamchonGraphDecorator[] { }); } +function enumMembersOf( + value: unknown, + id: string, +): ISamchonGraphNode.IEnumMember[] { + return arrayOf(value, `${id}.enumMembers`).map((item, index) => { + const label = `${id}.enumMembers[${index}]`; + const raw = objectOf(item, label); + const member: ISamchonGraphNode.IEnumMember = { + name: stringOf(raw.name, `${label}.name`), + }; + optionalString(raw.value, `${label}.value`, (entry) => { + member.value = entry; + }); + return member; + }); +} + +function objectMembersOf( + value: unknown, + id: string, +): ISamchonGraphNode.IObjectMember[] { + return arrayOf(value, `${id}.objectMembers`).map((item, index) => { + const label = `${id}.objectMembers[${index}]`; + const raw = objectOf(item, label); + const kind = stringOf(raw.kind, `${label}.kind`); + if (kind !== "property" && kind !== "method") { + throw new Error(`ttscgraph: unsupported ${label}.kind: ${kind}`); + } + const member: ISamchonGraphNode.IObjectMember = { + name: stringOf(raw.name, `${label}.name`), + kind, + }; + if (raw.line !== undefined) { + member.line = integerOf(raw.line, `${label}.line`); + } + optionalString(raw.signature, `${label}.signature`, (entry) => { + member.signature = entry; + }); + return member; + }); +} + function optionalString( value: unknown, label: string, diff --git a/packages/graph/src/reduce.ts b/packages/graph/src/reduce.ts index 0e8dfdc..5047db9 100644 --- a/packages/graph/src/reduce.ts +++ b/packages/graph/src/reduce.ts @@ -55,33 +55,60 @@ function posix(p: string): string { return p.replace(/\\/g, "/"); } -/** An absolute path (POSIX or Windows drive); relative dumps skip rerooting. */ +/** Absolute POSIX, Windows drive, or UNC path; relative dumps skip rerooting. */ function isAbsolute(p: string): boolean { return /^(?:[A-Za-z]:)?\//.test(posix(p)); } -function commonRoot(files: string[]): string { - // `reduce()` guards this helper with `projectFiles.length > 0`. - /* c8 ignore next */ - if (files.length === 0) return ""; - let parts = posix(files[0]!).split("/"); - for (const file of files.slice(1)) { - const other = posix(file).split("/"); +function isWindowsPath(p: string): boolean { + const normalized = posix(p); + return /^[A-Za-z]:(?:\/|$)/.test(normalized) || normalized.startsWith("//"); +} + +function directoryOf(file: string): string { + const normalized = posix(file).replace(/\/+$/, ""); + const slash = normalized.lastIndexOf("/"); + if (slash < 0) return ""; + return slash === 0 ? "/" : normalized.slice(0, slash); +} + +function commonRoot(directories: string[]): string { + if (directories.length === 0) return ""; + let parts = posix(directories[0]!).split("/"); + const caseInsensitive = directories.every(isWindowsPath); + for (const directory of directories.slice(1)) { + const other = posix(directory).split("/"); let i = 0; - while (i < parts.length && i < other.length && parts[i] === other[i]) i++; + while ( + i < parts.length && + i < other.length && + (caseInsensitive + ? parts[i]!.toLowerCase() === other[i]!.toLowerCase() + : parts[i] === other[i]) + ) + i++; parts = parts.slice(0, i); if (parts.length === 0) break; } return parts.join("/"); } -// An empty root means the dump's paths are already project-relative (the -// current `samchon-graph dump` contract); they pass through structure-intact. -function relativize(abs: string, root: string): string { +// A null root means the dump's paths are already project-relative (the current +// `samchon-graph dump` contract); they pass through structure-intact. +function relativize(abs: string, root: string | null): string { const a = posix(abs); - const r = posix(root).replace(/\/+$/, ""); - if (!r) return a; - if (a === r || a.startsWith(r + "/")) + if (root === null) return a; + const normalizedRoot = posix(root); + const r = normalizedRoot === "/" ? "/" : normalizedRoot.replace(/\/+$/, ""); + const caseInsensitive = isWindowsPath(a) && isWindowsPath(r); + const comparedPath = caseInsensitive ? a.toLowerCase() : a; + const comparedRoot = caseInsensitive ? r.toLowerCase() : r; + if ( + comparedRoot && + (comparedRoot === "/" || + comparedPath === comparedRoot || + comparedPath.startsWith(comparedRoot + "/")) + ) return a.slice(r.length).replace(/^\/+/, ""); const nm = a.lastIndexOf("node_modules/"); if (nm >= 0) return a.slice(nm); @@ -89,7 +116,7 @@ function relativize(abs: string, root: string): string { return slash >= 0 ? a.slice(slash + 1) : a; } -function rewriteId(id: string, root: string): string { +function rewriteId(id: string, root: string | null): string { const hash = id.indexOf("#"); if (hash < 0) return id; return relativize(id.slice(0, hash), root) + id.slice(hash); @@ -121,6 +148,7 @@ const DISPLAY_KIND: Record = { type_ref: "type-ref", extends: "heritage", implements: "heritage", + overrides: "heritage", }; function displayKind(kind: string): string { @@ -140,8 +168,8 @@ export function reduce( const projectFiles = raw.nodes.filter((n) => !n.external).map((n) => n.file); const root = projectFiles.length > 0 && isAbsolute(projectFiles[0]!) - ? commonRoot(projectFiles) - : ""; + ? commonRoot(projectFiles.map(directoryOf)) + : null; const liveIds = new Set(keptByExternal.map((n) => n.id)); const liveEdges = raw.edges.filter( diff --git a/packages/graph/src/structures/ISamchonGraphNode.ts b/packages/graph/src/structures/ISamchonGraphNode.ts index 79a7d99..bf5645e 100644 --- a/packages/graph/src/structures/ISamchonGraphNode.ts +++ b/packages/graph/src/structures/ISamchonGraphNode.ts @@ -93,6 +93,17 @@ export interface ISamchonGraphNode { */ enumMembers?: ISamchonGraphNode.IEnumMember[]; + /** + * Direct, statically named members when this variable is initialized with an + * object literal, in declaration order. + * + * The native builder takes identity from the compiler AST and renders the + * compact signature from the same Program-owned source snapshot. A spread or + * dynamic computed name has no declaration name it can report soundly, so it + * contributes no fabricated member. + */ + objectMembers?: ISamchonGraphNode.IObjectMember[]; + /** * Decorators written on this declaration, in source order: raw facts * (`@Controller`, `@Get`) a consumer interprets without re-parsing source. @@ -122,4 +133,19 @@ export namespace ISamchonGraphNode { */ value?: string; } + + /** One direct, statically named member of an object-literal variable. */ + export interface IObjectMember { + /** The source-visible static property name. */ + name: string; + + /** Whether the declaration is a data property or callable/accessor member. */ + kind: "property" | "method"; + + /** 1-based declaration line in the node's file, when source was available. */ + line?: number; + + /** Compact declaration outline rendered from the compiler source snapshot. */ + signature?: string; + } } diff --git a/packages/graph/src/structures/ISamchonGraphTrace.ts b/packages/graph/src/structures/ISamchonGraphTrace.ts index bd2b346..92c541c 100644 --- a/packages/graph/src/structures/ISamchonGraphTrace.ts +++ b/packages/graph/src/structures/ISamchonGraphTrace.ts @@ -17,7 +17,7 @@ export interface ISamchonGraphTrace { /** Unique nodes reached (excluding the start), each with its depth and roles. */ reached: ISamchonGraphTrace.INode[]; - /** True when the trace hit its node or depth cap; the returned flow stands. */ + /** In an open trace, true when a bound omitted an eligible node or hop. */ truncated: boolean; /** The resolved `to` target, when a path was requested. */ diff --git a/tests/benchmark/graph/viewer.mjs b/tests/benchmark/graph/viewer.mjs index 130504b..bdf0f7f 100644 --- a/tests/benchmark/graph/viewer.mjs +++ b/tests/benchmark/graph/viewer.mjs @@ -28,14 +28,22 @@ const PUBLIC_GRAPH_DIR = path.join(REPO_ROOT, "tests", "benchmark", "results", " // Pure transform // --------------------------------------------------------------------------- -/** Longest shared directory prefix of POSIX-normalized paths. */ -function commonRoot(files) { - if (files.length === 0) return ""; - let parts = posix(files[0]).split("/"); - for (const file of files.slice(1)) { - const other = posix(file).split("/"); +/** Longest shared prefix of POSIX-normalized directories. */ +function commonRoot(directories) { + if (directories.length === 0) return ""; + let parts = posix(directories[0]).split("/"); + const caseInsensitive = directories.every(isWindowsPath); + for (const directory of directories.slice(1)) { + const other = posix(directory).split("/"); let i = 0; - while (i < parts.length && i < other.length && parts[i] === other[i]) i++; + while ( + i < parts.length && + i < other.length && + (caseInsensitive + ? parts[i].toLowerCase() === other[i].toLowerCase() + : parts[i] === other[i]) + ) + i++; parts = parts.slice(0, i); if (parts.length === 0) break; } @@ -46,23 +54,44 @@ function posix(p) { return p.replace(/\\/g, "/"); } -/** An absolute path (POSIX or Windows drive); relative dumps skip rerooting. */ +/** Absolute POSIX, Windows drive, or UNC path; relative dumps skip rerooting. */ function isAbsolute(p) { return /^(?:[A-Za-z]:)?\//.test(posix(p)); } +function isWindowsPath(p) { + const normalized = posix(p); + return /^[A-Za-z]:(?:\/|$)/.test(normalized) || normalized.startsWith("//"); +} + +function directoryOf(file) { + const normalized = posix(file).replace(/\/+$/, ""); + const slash = normalized.lastIndexOf("/"); + if (slash < 0) return ""; + return slash === 0 ? "/" : normalized.slice(0, slash); +} + /** * Make an absolute path project-relative; a path outside the project keeps the * portion from its last node_modules/ segment, or its base name, so nothing - * leaks an absolute machine path. An empty root means the dump's paths are + * leaks an absolute machine path. A null root means the dump's paths are * already project-relative (the current `samchon-graph dump` contract), so they * pass through with their directory structure intact. */ function relativize(abs, root) { const a = posix(abs); - const r = posix(root).replace(/\/+$/, ""); - if (!r) return a; - if (a === r || a.startsWith(r + "/")) + if (root === null) return a; + const normalizedRoot = posix(root); + const r = normalizedRoot === "/" ? "/" : normalizedRoot.replace(/\/+$/, ""); + const caseInsensitive = isWindowsPath(a) && isWindowsPath(r); + const comparedPath = caseInsensitive ? a.toLowerCase() : a; + const comparedRoot = caseInsensitive ? r.toLowerCase() : r; + if ( + comparedRoot && + (comparedRoot === "/" || + comparedPath === comparedRoot || + comparedPath.startsWith(comparedRoot + "/")) + ) return a.slice(r.length).replace(/^\/+/, ""); const nm = a.lastIndexOf("node_modules/"); if (nm >= 0) return a.slice(nm); @@ -94,6 +123,7 @@ const DISPLAY_KIND = { type_ref: "type-ref", extends: "heritage", implements: "heritage", + overrides: "heritage", }; function displayKind(kind) { @@ -122,8 +152,8 @@ export function reduce( .map((n) => n.file); const root = projectFiles.length > 0 && isAbsolute(projectFiles[0]) - ? commonRoot(projectFiles) - : ""; + ? commonRoot(projectFiles.map(directoryOf)) + : null; const liveIds = new Set(keptBoundary.map((n) => n.id)); const liveEdges = raw.edges.filter( diff --git a/tests/test-graph/src/features/test_benchmark_viewer_reduce_matches_package_reducer.ts b/tests/test-graph/src/features/test_benchmark_viewer_reduce_matches_package_reducer.ts new file mode 100644 index 0000000..83a2ef7 --- /dev/null +++ b/tests/test-graph/src/features/test_benchmark_viewer_reduce_matches_package_reducer.ts @@ -0,0 +1,61 @@ +import { TestValidator } from "@nestia/e2e"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +import { GraphPaths } from "../internal/GraphPaths"; + +/** The benchmark copy must preserve the package reducer's path semantics. */ +export const test_benchmark_viewer_reduce_matches_package_reducer = async () => { + const viewer = (await import( + pathToFileURL( + path.join( + GraphPaths.repositoryRoot, + "tests", + "benchmark", + "graph", + "viewer.mjs", + ), + ).href + )) as { + reduce: ( + dump: { nodes: unknown[]; edges: unknown[] }, + options?: { keepExternal?: boolean }, + ) => { nodes: Array<{ id: string; file: string }>; links: Array<{ kind: string }> }; + }; + const sameFile = viewer.reduce({ + nodes: [ + node("C:/Only/File.ts", "A"), + node("c:/only/File.ts", "B"), + ], + edges: [edge("C:/Only/File.ts", "A", "c:/only/File.ts", "B", "overrides")], + }); + TestValidator.equals( + "the benchmark viewer retains a single absolute filename", + sameFile.nodes.map((entry) => entry.file), + ["File.ts", "File.ts"], + ); + TestValidator.equals( + "the benchmark viewer retains filename-based identity", + sameFile.nodes.map((entry) => entry.id.slice(0, entry.id.indexOf("#"))), + ["File.ts", "File.ts"], + ); + TestValidator.equals( + "override edges use the heritage display family", + sameFile.links[0]?.kind, + "heritage", + ); +}; + +const node = (file: string, name: string) => ({ + id: `${file}#${name}:method`, + name, + kind: "method", + file, + external: false, +}); + +const edge = (fromFile: string, from: string, toFile: string, to: string, kind: string) => ({ + from: `${fromFile}#${from}:method`, + to: `${toFile}#${to}:method`, + kind, +}); diff --git a/tests/test-graph/src/features/test_details_consume_snapshot_identity_facts.ts b/tests/test-graph/src/features/test_details_consume_snapshot_identity_facts.ts new file mode 100644 index 0000000..5a34bb8 --- /dev/null +++ b/tests/test-graph/src/features/test_details_consume_snapshot_identity_facts.ts @@ -0,0 +1,85 @@ +import { TestValidator } from "@nestia/e2e"; +import { + SamchonGraphApplication, + SamchonGraphMemory, + type ISamchonGraphDetails, + type ISamchonGraphDump, +} from "@samchon/graph"; + +/** + * Details must consume identity facts already owned by the graph snapshot. It + * must not discard them and try to reconstruct a weaker answer from live + * source text. + */ +export const test_details_consume_snapshot_identity_facts = async () => { + const dump: ISamchonGraphDump = { + project: "/snapshot-facts", + languages: ["typescript"], + indexer: "lsp", + nodes: [ + { + id: "src/a.ts#State:enum", + kind: "enum", + language: "typescript", + name: "State", + file: "src/a.ts", + external: false, + literals: ['"ready"', '"done"'], + enumMembers: [ + { name: "Ready", value: '"ready"' }, + { name: "Computed" }, + ], + }, + { + id: "src/a.ts#service:variable", + kind: "variable", + language: "typescript", + name: "service", + file: "src/a.ts", + external: false, + objectMembers: [ + { + name: "execute", + kind: "method", + line: 8, + signature: "execute(input: Input): Output", + }, + { name: "label", kind: "property", line: 12 }, + ], + }, + ], + edges: [], + }; + const app = new SamchonGraphApplication(SamchonGraphMemory.from(dump)); + const output = await app.inspect_code_graph({ + question: "What are State and service?", + draft: { reason: "Inspect both identity-bearing nodes.", type: "details" }, + review: "Use their exact handles.", + request: { type: "details", handles: ["State", "service"] }, + }); + const result = output.result as ISamchonGraphDetails; + + TestValidator.equals("enum literals come from the snapshot", result.nodes[0]?.literals, [ + '"ready"', + '"done"', + ]); + TestValidator.equals("enum members keep names and values", result.nodes[0]?.members, [ + { name: "State.Ready", kind: "property", signature: 'Ready = "ready"' }, + { name: "State.Computed", kind: "property" }, + ]); + TestValidator.equals("object members keep compiler-owned outlines", result.nodes[1]?.members, [ + { + name: "execute", + kind: "method", + line: 8, + signature: "execute(input: Input): Output", + }, + { name: "label", kind: "property", line: 12 }, + ]); + TestValidator.predicate( + "details audit describes complete identity and sliced fan-out separately", + output.audit.includes("What a symbol is") && + output.audit.includes("short orientation slice") && + !output.audit.includes("`truncated` marks it"), + ); +}; diff --git a/tests/test-graph/src/features/test_graph_handle_limits_follow_complete_reference_ranking.ts b/tests/test-graph/src/features/test_graph_handle_limits_follow_complete_reference_ranking.ts new file mode 100644 index 0000000..f124786 --- /dev/null +++ b/tests/test-graph/src/features/test_graph_handle_limits_follow_complete_reference_ranking.ts @@ -0,0 +1,163 @@ +import { TestValidator } from "@nestia/e2e"; +import { + ISamchonGraphNode, + SamchonGraphMemory, +} from "@samchon/graph"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +import { GraphPaths } from "../internal/GraphPaths"; + +export const test_graph_handle_limits_follow_complete_reference_ranking = + async () => { + const { resolveGraphHandle } = await importLib<{ + resolveGraphHandle: ( + graph: SamchonGraphMemory, + handle: string, + candidateLimit?: number, + ) => { candidates?: ISamchonGraphNode[] }; + }>("operations/resolveGraphHandle.js"); + + const exact = candidates("Shared", "class"); + assertWinner( + resolveGraphHandle(memory(exact), "Shared"), + exact[12]!.id, + 12, + "exact names rank their complete set before limiting", + ); + + const fileQualified = candidates("Thing", "class", { + file: () => "shared.ts", + }); + assertWinner( + resolveGraphHandle(memory(fileQualified), "shared.Thing"), + fileQualified[12]!.id, + 12, + "file-qualified names rank their complete set before limiting", + ); + + const memberFallback = candidates("run", "method", { + qualifiedName: (index) => `Service${String(index)}.run`, + }); + assertWinner( + resolveGraphHandle(memory(memberFallback), "client.run"), + memberFallback[12]!.id, + 12, + "value-member fallback ranks its complete set before limiting", + ); + + const stale = candidates("Moved", "class"); + assertWinner( + resolveGraphHandle(memory(stale), "src/old.ts#Moved:class"), + stale[12]!.id, + 12, + "stale-id fallback ranks its complete set before limiting", + ); + + const suffix = candidates("run", "method", { + qualifiedName: (index) => `Outer${String(index)}.Inner.run`, + }); + assertWinner( + resolveGraphHandle(memory(suffix), "Inner.run"), + suffix[12]!.id, + 12, + "qualified suffixes rank their complete set before limiting", + ); + + const bounded = candidates("Bounded", "class", { count: 5 }); + for (const [limit, length] of [ + [0, 0], + [1, 1], + [3, 3], + [5, 5], + [8, 5], + ] as const) { + const resolved = resolveGraphHandle(memory(bounded), "Bounded", limit); + TestValidator.equals( + `candidate limit ${String(limit)} caps only the ranked response`, + resolved.candidates?.length, + length, + ); + if (length > 0) { + TestValidator.equals( + `candidate limit ${String(limit)} preserves the strongest match`, + resolved.candidates?.[0]?.id, + bounded[4]!.id, + ); + } + } + + const tied = ["c", "a", "b"].map((letter) => ({ + id: `src/${letter}.ts#Tied:class`, + kind: "class" as const, + language: "typescript" as const, + name: "Tied", + file: `src/${letter}.ts`, + external: false, + })); + const expected = [ + "src/a.ts#Tied:class", + "src/b.ts#Tied:class", + "src/c.ts#Tied:class", + ]; + for (const order of [tied, [...tied].reverse()]) { + TestValidator.equals( + "equal relevance uses stable graph identity rather than visit order", + resolveGraphHandle(memory(order), "Tied", 3).candidates?.map( + (node) => node.id, + ), + expected, + ); + } + }; + +function candidates( + name: string, + kind: "class" | "method", + options: { + count?: number; + file?: (index: number) => string; + qualifiedName?: (index: number) => string; + } = {}, +): ISamchonGraphNode[] { + return Array.from({ length: options.count ?? 13 }, (_, index) => { + const file = options.file?.(index) ?? `src/item-${String(index)}.ts`; + return { + id: `${file}#${options.qualifiedName?.(index) ?? name}:${kind}`, + kind, + language: "typescript", + name, + ...(options.qualifiedName === undefined + ? {} + : { qualifiedName: options.qualifiedName(index) }), + file, + external: false, + ...(index === (options.count ?? 13) - 1 ? { exported: true } : {}), + }; + }); +} + +function memory(nodes: ISamchonGraphNode[]): SamchonGraphMemory { + return SamchonGraphMemory.from({ + project: "C:/synthetic-graph", + languages: ["typescript"], + indexer: "lsp", + nodes, + edges: [], + }); +} + +function assertWinner( + resolved: { candidates?: ISamchonGraphNode[] }, + winner: string, + length: number, + label: string, +): void { + TestValidator.equals(`${label}: bounded length`, resolved.candidates?.length, length); + TestValidator.equals(`${label}: strongest first`, resolved.candidates?.[0]?.id, winner); +} + +const importLib = (relative: string): Promise => + import( + pathToFileURL(path.join(GraphPaths.graphPackageRoot, "lib", relative)).href + ) as Promise; diff --git a/tests/test-graph/src/features/test_ported_operation_engines_cover_scoring_branches.ts b/tests/test-graph/src/features/test_ported_operation_engines_cover_scoring_branches.ts index 5297f45..9c4c3b0 100644 --- a/tests/test-graph/src/features/test_ported_operation_engines_cover_scoring_branches.ts +++ b/tests/test-graph/src/features/test_ported_operation_engines_cover_scoring_branches.ts @@ -317,23 +317,34 @@ const scenario_details_edges = async () => { `export const labels = ["dup", "dup", "${"x".repeat(41)}", "()", ${fillers}];\n`, ); const nodes = [ - node("src/obj.ts#opts:variable", "variable", "opts", "src/obj.ts", 1, 4), - node("src/literals.ts#labels:variable", "variable", "labels", "src/literals.ts", 1, 1), - // object-literal variable whose file does not exist → fileLines catch + { + ...node("src/obj.ts#opts:variable", "variable", "opts", "src/obj.ts", 1, 4), + objectMembers: [ + { name: "host", kind: "property", line: 2, signature: 'host: "h"' }, + { name: "connect", kind: "method", line: 3, signature: "connect()" }, + ], + }, + { + ...node("src/literals.ts#labels:variable", "variable", "labels", "src/literals.ts", 1, 1), + literals: Array.from({ length: 20 }, (_, i) => + i === 0 ? '"dup"' : `"f${i - 1}"`, + ), + }, + // Variables without provider-owned member facts remain selectable. node("src/gone.ts#ghost:variable", "variable", "ghost", "src/gone.ts", 1, 3), - // variable whose evidence file is empty → fileLines file==="" guard + // Empty evidence paths remain valid for detail selection. (() => { const n = node("noFile#blank:variable", "variable", "blank", "", 1, 3); n.evidence = { file: "", startLine: 1, startCol: 1, endLine: 3, endCol: 1 }; return n; })(), - // variable with no endLine → objectLiteralMembers early return + // Missing end coordinates do not affect provider-owned details. (() => { const n = node("src/n.ts#noEnd:variable", "variable", "noEnd", "src/n.ts", 1, 1); n.evidence = { file: "src/n.ts", startLine: 1, startCol: 1, endLine: undefined as unknown as number, endCol: 1 }; return n; })(), - // oversized span → objectLiteralMembers size guard + // Large source spans do not trigger live-source parsing. node("src/big.ts#huge:variable", "variable", "huge", "src/big.ts", 1, 5000), // container with a dangling contained member node("src/c.ts#Box:class", "class", "Box", "src/c.ts", 1, 10), @@ -344,14 +355,12 @@ const scenario_details_edges = async () => { TestValidator.predicate("details resolves the selected nodes", details.nodes.length >= 1); const literalsDetails = (await call(app, { type: "details", handles: ["labels"] })).result; const literals = literalsDetails.nodes.find((n) => n.name === "labels")?.literals ?? []; - // #742 makes a union or enum's value set identity — returned whole, not - // sampled — so detail.literals is no longer sliced to 6. It now exposes the - // whole extracted set, bounded only by literalSummaries' internal 20-item - // safety cap (graph scrapes the signature text rather than reading a - // checker-resolved list, so the parse stays bounded). - TestValidator.equals("literals return whole, bounded by the extraction cap", literals.length, 20); - TestValidator.predicate("literal summaries dedupe repeats", literals.filter((l) => l === "dup").length === 1); - TestValidator.predicate("literal summaries drop overlong and punctuation-only tokens", !literals.includes("()") && !literals.some((l) => l.length > 40)); + // The snapshot already carries the provider-resolved literal identity set. + TestValidator.equals("provider-owned literals return whole", literals.length, 20); + TestValidator.predicate( + "provider-owned literals preserve source spelling", + literals[0] === '"dup"' && !literals.includes("()"), + ); // memberLimit=1 breaks after the first object-literal member. const capped = (await call(app, { type: "details", handles: ["opts"], memberLimit: 1 })).result; TestValidator.equals("object-literal members respect the limit", capped.nodes.find((n) => n.name === "opts")?.members?.length ?? 0, 1); diff --git a/tests/test-graph/src/features/test_resident_bulk_provider_polls_generations_without_reprocessing_strict_facts.ts b/tests/test-graph/src/features/test_resident_bulk_provider_polls_generations_without_reprocessing_strict_facts.ts index 3a32bb8..92da607 100644 --- a/tests/test-graph/src/features/test_resident_bulk_provider_polls_generations_without_reprocessing_strict_facts.ts +++ b/tests/test-graph/src/features/test_resident_bulk_provider_polls_generations_without_reprocessing_strict_facts.ts @@ -148,7 +148,7 @@ function snapshot( // carries their text, so a resident refresh has nothing to re-read either. sources: new Map([[file, { checkerDigest: digest, diskDigest: digest }]]), provenance: { - schemaVersion: 1, + schemaVersion: 5, tool: "ttscgraph", toolVersion: "0.19.2", compilerVersion: "5.9.0", diff --git a/tests/test-graph/src/features/test_source_reader_enforces_snapshot_identity_once.ts b/tests/test-graph/src/features/test_source_reader_enforces_snapshot_identity_once.ts new file mode 100644 index 0000000..16c0cfc --- /dev/null +++ b/tests/test-graph/src/features/test_source_reader_enforces_snapshot_identity_once.ts @@ -0,0 +1,103 @@ +import { TestValidator } from "@nestia/e2e"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +import { SamchonGraphSourceReader } from "@samchon/graph"; + +import { GraphPaths } from "../internal/GraphPaths"; + +/** Source display bytes are confined, snapshot-proved, and adjudicated once. */ +export const test_source_reader_enforces_snapshot_identity_once = () => { + const root = GraphPaths.createTempDirectory("samchon-graph-source-reader-"); + const outsideRoot = GraphPaths.createTempDirectory( + "samchon-graph-source-reader-outside-", + ); + const file = path.join(root, "src", "a.ts"); + const outside = path.join(outsideRoot, "secret.ts"); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, "export const snapshot = 1;\n"); + fs.writeFileSync(outside, "export const secret = 2;\n"); + + let reads = 0; + const reader = new SamchonGraphSourceReader(root, { + digests: new Map([ + [ + file, + { + checkerDigest: sha("export const snapshot = 1;\n"), + diskDigest: sha("export const snapshot = 1;\n"), + }, + ], + ]), + read: (target) => { + reads += 1; + return fs.readFileSync(target); + }, + }); + TestValidator.equals("matching checker bytes are readable", reader.lines("src/a.ts"), [ + "export const snapshot = 1;", + "", + ]); + fs.writeFileSync(file, "export const changed = 2;\n"); + TestValidator.equals("a successful adjudication is immutable", reader.lines("src/a.ts"), [ + "export const snapshot = 1;", + "", + ]); + TestValidator.equals("a successful file is read once", reads, 1); + + const mismatch = new SamchonGraphSourceReader(root, { + digests: new Map([ + [ + file, + { + checkerDigest: sha("export const snapshot = 1;\n"), + diskDigest: sha("export const changed = 2;\n"), + }, + ], + ]), + }); + TestValidator.equals("changed disk bytes fail closed", mismatch.lines("src/a.ts"), undefined); + fs.writeFileSync(file, "export const snapshot = 1;\n"); + TestValidator.equals("a failed adjudication is cached", mismatch.lines("src/a.ts"), undefined); + + const exact = new SamchonGraphSourceReader(root, { + texts: new Map([ + [path.join(root, "src", "consumed.ts"), "export const consumed = true;\n"], + ]), + read: () => { + throw new Error("exact snapshot text must not touch disk"); + }, + }); + TestValidator.equals( + "exact consumed text survives without a live file", + exact.lines("src/consumed.ts"), + ["export const consumed = true;", ""], + ); + + const none = SamchonGraphSourceReader.none(root); + TestValidator.equals("a provenance-free reader omits live source", none.lines("src/a.ts"), undefined); + TestValidator.equals( + "a parent traversal cannot read outside the project", + SamchonGraphSourceReader.live(root).lines( + path.relative(root, outside).replace(/\\/g, "/"), + ), + undefined, + ); + + const link = path.join(root, "src", "linked.ts"); + try { + fs.symlinkSync(outside, link, "file"); + TestValidator.equals( + "a symlink escape cannot read outside the project", + SamchonGraphSourceReader.live(root).lines("src/linked.ts"), + undefined, + ); + } catch { + // Windows installations without symlink permission cannot exercise this + // OS seam; lexical confinement and every digest path remain covered. + } +}; + +const sha = (text: string): string => + createHash("sha256").update(text, "utf8").digest("hex"); diff --git a/tests/test-graph/src/features/test_trace_reports_only_actual_omissions.ts b/tests/test-graph/src/features/test_trace_reports_only_actual_omissions.ts new file mode 100644 index 0000000..74e6b82 --- /dev/null +++ b/tests/test-graph/src/features/test_trace_reports_only_actual_omissions.ts @@ -0,0 +1,73 @@ +import { TestValidator } from "@nestia/e2e"; +import { + SamchonGraphApplication, + SamchonGraphMemory, + type ISamchonGraphDump, + type ISamchonGraphTrace, +} from "@samchon/graph"; + +/** Trace existence and completeness are separate facts. */ +export const test_trace_reports_only_actual_omissions = async () => { + const graph = SamchonGraphMemory.from(dump()); + const app = new SamchonGraphApplication(graph); + const inspect = async ( + request: ISamchonGraphTrace.IRequest, + ): Promise>> => + app.inspect_code_graph({ + question: "Trace the selected path.", + draft: { reason: "Follow the exact handles.", type: "trace" }, + review: "Inspect the bounded result.", + request, + }); + + const self = await inspect({ type: "trace", from: A, to: A }); + const selfResult = self.result as ISamchonGraphTrace; + TestValidator.equals("a node has a zero-hop path to itself", selfResult.path, [ + { id: A, name: "a", kind: "function", file: "src/a.ts", depth: 0 }, + ]); + TestValidator.equals("a self path has no hops", selfResult.hops, []); + TestValidator.equals("a self path has no fake junctions", selfResult.junctions, undefined); + TestValidator.equals("a self path is an answer", self.next.action, "answer"); + + const complete = await inspect({ type: "trace", from: A, maxDepth: 1 }); + TestValidator.equals( + "an exact-depth leaf is complete", + (complete.result as ISamchonGraphTrace).truncated, + false, + ); + + const omitted = await inspect({ type: "trace", from: ROOT, maxDepth: 1 }); + TestValidator.equals( + "a depth boundary with an eligible continuation is truncated", + (omitted.result as ISamchonGraphTrace).truncated, + true, + ); +}; + +const ROOT = "src/a.ts#root:function"; +const A = "src/a.ts#a:function"; +const LEAF = "src/a.ts#leaf:function"; + +const dump = (): ISamchonGraphDump => ({ + project: "/trace-truth", + languages: ["typescript"], + indexer: "lsp", + nodes: [ + node(ROOT, "root"), + node(A, "a"), + node(LEAF, "leaf"), + ], + edges: [ + { from: ROOT, to: A, kind: "calls" }, + { from: A, to: LEAF, kind: "calls" }, + ], +}); + +const node = (id: string, name: string): ISamchonGraphDump.INode => ({ + id, + kind: "function", + language: "typescript", + name, + file: "src/a.ts", + external: false, +}); diff --git a/tests/test-graph/src/features/test_ttsc_memory_synthesizes_properties_and_member_relations.ts b/tests/test-graph/src/features/test_ttsc_memory_synthesizes_properties_and_member_relations.ts index 9b9c0bf..0114883 100644 --- a/tests/test-graph/src/features/test_ttsc_memory_synthesizes_properties_and_member_relations.ts +++ b/tests/test-graph/src/features/test_ttsc_memory_synthesizes_properties_and_member_relations.ts @@ -10,10 +10,10 @@ import { } from "@samchon/graph"; /** - * A strict ttsc dump deliberately leaves two resident facts to its canonical - * memory layer: class-owned variables are properties, and a type heritage edge - * implies the matching member relation. The generalized memory must restore - * those facts without mutating or duplicating the compiler dump. + * A strict ttsc dump leaves property refinement to its canonical memory layer, + * but member implementation relations are checker-owned dump facts. The + * generalized memory must refine properties without inventing a same-named + * member edge the checker rejected. */ export const test_ttsc_memory_synthesizes_properties_and_member_relations = async () => { @@ -50,7 +50,7 @@ export const test_ttsc_memory_synthesizes_properties_and_member_relations = edge.kind === "implements" || edge.kind === "overrides", ); TestValidator.predicate( - "type implementation derives the matching method relation", + "a checker-supplied member implementation is preserved", relations.some( (edge) => edge.kind === "implements" && @@ -61,7 +61,7 @@ export const test_ttsc_memory_synthesizes_properties_and_member_relations = ), ); TestValidator.predicate( - "refined properties participate in member implementation", + "a checker-supplied refined property implementation is preserved", relations.some( (edge) => edge.kind === "implements" && @@ -70,7 +70,7 @@ export const test_ttsc_memory_synthesizes_properties_and_member_relations = ), ); TestValidator.predicate( - "a language-server field participates in member overriding", + "a checker-supplied field override is preserved", relations.some( (edge) => edge.kind === "overrides" && @@ -85,6 +85,13 @@ export const test_ttsc_memory_synthesizes_properties_and_member_relations = ).length, 1, ); + TestValidator.predicate( + "a checker-rejected same-name member relation is not synthesized", + relations.every( + (edge) => + edge.from !== BAD_HANDLE || edge.to !== HANDLER_HANDLE, + ), + ); TestValidator.predicate( "constructors are not inherited member implementations", relations.every( @@ -163,6 +170,8 @@ const WORKER_LABEL = "src/worker.ts#Worker.label:variable"; const WORKER_RUN = "src/worker.ts#Worker.run:method"; const WORKER_SLOT = "src/worker.ts#Worker.slot:field"; const WORKER_CONSTRUCTOR = "src/worker.ts#Worker.constructor:constructor"; +const BAD = "src/bad.ts#Bad:class"; +const BAD_HANDLE = "src/bad.ts#Bad.handle:method"; const WORK = "src/work.ts#work:function"; const START = "src/start.ts#start:function"; const VERSION = "src/version.ts#version:variable"; @@ -206,6 +215,8 @@ const ttscStyleDump = (): ISamchonGraphDump => ({ "Worker.constructor", 18, ), + node(BAD, "class", "Bad", undefined, 1, true), + node(BAD_HANDLE, "method", "handle", "Bad.handle", 2), node(WORK, "function", "work", undefined, 1), node(START, "function", "start", undefined, 1, true), node(VERSION, "variable", "version", undefined, 1), @@ -214,7 +225,16 @@ const ttscStyleDump = (): ISamchonGraphDump => ({ { from: "src/index.ts", to: START, kind: "exports" }, { from: "src/index.ts", to: WORKER, kind: "exports" }, { from: WORKER, to: HANDLER, kind: "implements" }, + { from: BAD, to: HANDLER, kind: "implements" }, { from: WORKER, to: BASE, kind: "extends" }, + { + from: WORKER_HANDLE, + to: HANDLER_HANDLE, + kind: "implements", + evidence: { startLine: 12 }, + }, + { from: WORKER_LABEL, to: HANDLER_LABEL, kind: "implements" }, + { from: WORKER_SLOT, to: BASE_SLOT, kind: "overrides" }, { from: WORKER_RUN, to: BASE_RUN, 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 cddea60..74796db 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 @@ -105,7 +105,7 @@ export const test_ttscgraph_bulk_provider_reuses_and_atomically_replaces_snapsho TestValidator.equals( "the snapshot reports the dump schema its facts obey", initial.snapshot.provenance.schemaVersion, - 1, + 5, ); TestValidator.equals( "the first snapshot reports the compiler's own mode, not an inferred one", @@ -155,14 +155,14 @@ export const test_ttscgraph_bulk_provider_reuses_and_atomically_replaces_snapsho ); await rejects(client.refresh(), "serve errors are surfaced"); TestValidator.predicate( - "a failed response cannot replace the last valid snapshot", - client.current === changed.snapshot && client.generation === 2, + "an untrusted child generation clears resident snapshot state", + client.current === undefined && client.generation === 2, ); await client.close(); TestValidator.equals( - "close reaches only the provider process owned by the client", - fs.readFileSync(marker, "utf8"), - "closed\n", + "an invalid producer is terminated instead of trusted to close cooperatively", + fs.existsSync(marker), + false, ); // Four refreshes, four requests. The client used to spend a second // round-trip per *changed* snapshot asking the server whether the dump it diff --git a/tests/test-graph/src/features/test_ttscgraph_bulk_session_stays_alive_when_kept.ts b/tests/test-graph/src/features/test_ttscgraph_bulk_session_stays_alive_when_kept.ts index 05508b1..f3f96a3 100644 --- a/tests/test-graph/src/features/test_ttscgraph_bulk_session_stays_alive_when_kept.ts +++ b/tests/test-graph/src/features/test_ttscgraph_bulk_session_stays_alive_when_kept.ts @@ -62,8 +62,8 @@ export const test_ttscgraph_bulk_session_stays_alive_when_kept = async () => { // bulk provider, which the assertion above already proved this is). if (session !== undefined && "close" in session) await session.close(); TestValidator.equals( - "closing the retained session shuts down the process it owns", - fs.readFileSync(marker, "utf8"), - "closed\n", + "closing does not trust the owned process to acknowledge termination", + fs.existsSync(marker), + false, ); }; 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 85f4f6e..c442273 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 @@ -18,7 +18,7 @@ const sha = (text: string): string => // the proof boundary satisfied so each malformed case can reach and fail at the // node, edge, or span it targets. const provenance = () => ({ - schemaVersion: 1, + schemaVersion: 5, capabilities: ["universe", "sourceDigests", "diskDigests", "diagnostics"], producer: { tool: "ttscgraph", version: "0.19.2", typescript: "5.9.0" }, universe: { @@ -79,7 +79,7 @@ export const test_ttscgraph_dump_adapter_rejects_malformed_facts = async () => { rejects( () => adaptTtscGraphDump( - mutate((d) => (d.provenance.schemaVersion = 2)), + mutate((d) => (d.provenance.schemaVersion = 6)), project, ), "a dump above the pinned schema", @@ -175,6 +175,19 @@ export const test_ttscgraph_dump_adapter_rejects_malformed_facts = async () => { external: false, qualifiedName: "pkg.foo", modifiers: ["export", "async"], + literals: ['"ready"', '"done"'], + enumMembers: [ + { name: "Ready", value: '"ready"' }, + { name: "Computed" }, + ], + objectMembers: [ + { + name: "execute", + kind: "method", + line: 3, + signature: "execute(): void", + }, + ], decorators: [{ name: "Route", arguments: [{ literal: "path" }, {}] }], evidence: { startLine: 1, startCol: 1, endLine: 1, endCol: 5 }, implementation: { startLine: 2 }, @@ -192,6 +205,31 @@ export const test_ttscgraph_dump_adapter_rejects_malformed_facts = async () => { const foo = rich.nodes.find((node) => node.id === "src/a.ts#foo:function"); TestValidator.equals("a qualified name is preserved", foo?.qualifiedName, "pkg.foo"); TestValidator.equals("an implementation span is preserved", foo?.implementation?.startLine, 2); + TestValidator.equals( + "compiler-resolved literal values are preserved", + foo?.literals, + ['"ready"', '"done"'], + ); + TestValidator.equals( + "compiler-owned enum members are preserved", + foo?.enumMembers, + [ + { name: "Ready", value: '"ready"' }, + { name: "Computed" }, + ], + ); + TestValidator.equals( + "compiler-owned object members are preserved", + foo?.objectMembers, + [ + { + name: "execute", + kind: "method", + line: 3, + signature: "execute(): void", + }, + ], + ); TestValidator.equals( "a decorator keeps its scalar and its empty argument", foo?.decorators?.[0]?.arguments, @@ -206,6 +244,44 @@ export const test_ttscgraph_dump_adapter_rejects_malformed_facts = async () => { "an external non-bundled dependency remains an external fact", rich.nodes.some((node) => node.id === "vendor/dep.ts#Dep:interface" && node.external), ); + + rejects( + () => + adaptTtscGraphDump( + mutate( + (d) => + ((d.nodes[1] as { literals?: unknown }).literals = ["ok", 1]), + ), + project, + ), + "a non-string literal value", + ); + rejects( + () => + adaptTtscGraphDump( + mutate( + (d) => + ((d.nodes[1] as { enumMembers?: unknown }).enumMembers = [ + { name: "Ready", value: false }, + ]), + ), + project, + ), + "a non-string enum member value", + ); + rejects( + () => + adaptTtscGraphDump( + mutate( + (d) => + ((d.nodes[1] as { objectMembers?: unknown }).objectMembers = [ + { name: "dynamic", kind: "computed" }, + ]), + ), + project, + ), + "an unsupported object member kind", + ); }; function rejects(task: () => unknown, label: string): void { 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 new file mode 100644 index 0000000..4a9f18f --- /dev/null +++ b/tests/test-graph/src/features/test_ttscgraph_native_requests_recover_from_stalls.ts @@ -0,0 +1,133 @@ +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"; + +/** A stalled request owns one child generation, never the resident queue. */ +export const test_ttscgraph_native_requests_recover_from_stalls = async () => { + const root = fixture(); + const marker = path.join(root, "first-child.txt"); + const client = create(root, marker, 300); + try { + const first = client.refresh(); + const queued = client.refresh(); + const error = await rejectionOf(first); + TestValidator.predicate( + "a silent native request times out precisely", + error.message.includes("timed out after 300 ms"), + ); + const recovered = await queued; + TestValidator.equals( + "a queued refresh restarts on a fresh child", + recovered.generation, + 1, + ); + TestValidator.predicate( + "the timed-out snapshot is not retained", + recovered.snapshot.nodes.some((node) => node.name === "first"), + ); + } finally { + await client.close(); + } + + const closeRoot = fixture(); + const closeClient = create( + closeRoot, + path.join(closeRoot, "first-child.txt"), + 5_000, + ); + const stalled = closeClient.refresh(); + await delay(100); + const closed = await Promise.race([ + Promise.allSettled([stalled, closeClient.close()]), + delay(1_000).then(() => "timeout" as const), + ]); + TestValidator.predicate( + "close settles without queueing behind a stalled refresh", + closed !== "timeout" && closed[0]?.status === "rejected", + ); + + const abortRoot = fixture(); + const abortClient = create( + abortRoot, + path.join(abortRoot, "first-child.txt"), + 5_000, + ); + try { + const controller = new AbortController(); + const aborted = abortClient.refresh({ signal: controller.signal }); + await delay(100); + controller.abort("test cancellation"); + const error = await rejectionOf(aborted); + TestValidator.predicate( + "abort retires its exact child generation", + error.name === "AbortError" && error.message.includes("test cancellation"), + ); + TestValidator.equals( + "the next refresh recovers after abort", + (await abortClient.refresh()).generation, + 1, + ); + } finally { + await abortClient.close(); + } + + for (const value of [0, -1, 1.5, Number.NaN, 2_147_483_648]) { + let error: unknown; + try { + new TtscGraphClient({ + root, + command: process.execPath, + args: [GraphPaths.fakeTtscGraphServer], + requestTimeoutMs: value, + }); + } catch (caught) { + error = caught; + } + TestValidator.predicate( + `unsafe timeout ${String(value)} is rejected before spawn`, + error instanceof TypeError, + ); + } +}; + +const create = ( + root: string, + marker: string, + requestTimeoutMs: number, +): TtscGraphClient => + new TtscGraphClient({ + root, + command: process.execPath, + args: [ + GraphPaths.fakeTtscGraphServer, + `--ignore-first-process=${marker}`, + ], + requestTimeoutMs, + }); + +const fixture = (): string => { + const root = GraphPaths.createTempDirectory( + "samchon-graph-ttscgraph-timeout-", + ); + 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 * from './core/order';\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; +}; + +const rejectionOf = async (promise: Promise): Promise => { + try { + await promise; + } catch (error) { + if (error instanceof Error) return error; + } + throw new Error("expected promise to reject"); +}; + +const delay = (milliseconds: number): Promise => + new Promise((resolve) => setTimeout(resolve, milliseconds)); diff --git a/tests/test-graph/src/features/test_ttscgraph_provider_proves_its_program_without_reading_disk.ts b/tests/test-graph/src/features/test_ttscgraph_provider_proves_its_program_without_reading_disk.ts index fd73c95..9a63028 100644 --- a/tests/test-graph/src/features/test_ttscgraph_provider_proves_its_program_without_reading_disk.ts +++ b/tests/test-graph/src/features/test_ttscgraph_provider_proves_its_program_without_reading_disk.ts @@ -157,8 +157,8 @@ async function assertUniverseDriftRefused(root: string): Promise { error instanceof Error, ); TestValidator.predicate( - "the last provable generation survives a contradicted mode", - client.current === initial.snapshot && client.generation === 1, + "a contradicted mode clears the untrusted child generation", + client.current === undefined && client.generation === 1, ); } finally { await client.close(); diff --git a/tests/test-graph/src/features/test_ttscgraph_provider_surfaces_process_failures.ts b/tests/test-graph/src/features/test_ttscgraph_provider_surfaces_process_failures.ts index e42d3a8..2bff200 100644 --- a/tests/test-graph/src/features/test_ttscgraph_provider_surfaces_process_failures.ts +++ b/tests/test-graph/src/features/test_ttscgraph_provider_surfaces_process_failures.ts @@ -94,8 +94,7 @@ export const test_ttscgraph_provider_surfaces_process_failures = async () => { "a refresh after close reports the session is closed", ); - // A process that ignores its stdin closing is force-killed after the graceful - // window, and only that exact owned child is ended. + // Closing before the first request must not spawn a process merely to stop it. const marker = path.join(root, "stubborn-closed.txt"); const stubborn = new TtscGraphClient({ root, @@ -108,9 +107,9 @@ export const test_ttscgraph_provider_surfaces_process_failures = async () => { }); await stubborn.close(); TestValidator.equals( - "closing a stubborn child still ends the exact owned process", - fs.readFileSync(marker, "utf8"), - "closed\n", + "pre-start close creates no owned process", + fs.existsSync(marker), + false, ); }; diff --git a/tests/test-graph/src/features/test_viewer_reduce_preserves_the_reference_contract.ts b/tests/test-graph/src/features/test_viewer_reduce_preserves_the_reference_contract.ts index 89d78dc..b768811 100644 --- a/tests/test-graph/src/features/test_viewer_reduce_preserves_the_reference_contract.ts +++ b/tests/test-graph/src/features/test_viewer_reduce_preserves_the_reference_contract.ts @@ -75,13 +75,13 @@ export const test_viewer_reduce_preserves_the_reference_contract = () => { ], }, { keepExternal: true }); TestValidator.equals( - "a disjoint absolute set keeps safe drive-qualified paths", + "a disjoint absolute set falls back to portable basenames", splitRoots.nodes.map((entry) => entry.file), [ - "C:/one/a.ts", - "D:/two/b.ts", - "C:/one/node_modules/pkg/x.d.ts", - "E:/outside/y.ts", + "a.ts", + "b.ts", + "node_modules/pkg/x.d.ts", + "y.ts", ], ); TestValidator.predicate( @@ -134,9 +134,15 @@ export const test_viewer_reduce_preserves_the_reference_contract = () => { ], edges: [edge("C:/only/file.ts", "A", "class", "C:/only/file.ts", "B", "class", "calls")], }); - TestValidator.predicate( - "a one-file absolute root strips the whole file prefix", - sameFile.nodes.every((entry) => entry.file === ""), + TestValidator.equals( + "a one-file absolute root retains the source filename", + sameFile.nodes.map((entry) => entry.file), + ["file.ts", "file.ts"], + ); + TestValidator.equals( + "a one-file absolute root retains filename-based node identity", + sameFile.nodes.map((entry) => entry.id.slice(0, entry.id.indexOf("#"))), + ["file.ts", "file.ts"], ); const hashless = reduce({ diff --git a/tests/test-graph/src/internal/fake-ttscgraph-server.cjs b/tests/test-graph/src/internal/fake-ttscgraph-server.cjs index 492209f..b9ccef8 100644 --- a/tests/test-graph/src/internal/fake-ttscgraph-server.cjs +++ b/tests/test-graph/src/internal/fake-ttscgraph-server.cjs @@ -29,7 +29,20 @@ const universeDrift = args.includes("--universe-drift"); // snapshot still holds when there is none yet to reuse. const stderrExit = args.includes("--stderr-exit"); const exitSilently = args.includes("--exit-silently"); -const ignoreStdin = args.includes("--ignore-stdin"); +const ignoreFirstArg = args.find((arg) => + arg.startsWith("--ignore-first-process="), +); +const ignoreFirstMarker = ignoreFirstArg?.slice( + "--ignore-first-process=".length, +); +const ignoreThisProcess = + ignoreFirstMarker !== undefined && !fs.existsSync(ignoreFirstMarker); +if (ignoreThisProcess) { + fs.mkdirSync(path.dirname(ignoreFirstMarker), { recursive: true }); + fs.writeFileSync(ignoreFirstMarker, String(process.pid)); +} +const ignoreStdin = args.includes("--ignore-stdin") || ignoreThisProcess; +const hangRequests = args.includes("--hang-requests") || ignoreThisProcess; const blankLine = args.includes("--blank-line"); const splitFrame = args.includes("--split-frame"); const nonJson = args.includes("--nonjson"); @@ -93,7 +106,7 @@ const universe = (drift) => ({ }); const provenance = (drift) => ({ - schemaVersion: 1, + schemaVersion: 5, capabilities: CAPABILITIES, producer: { tool: "ttscgraph", @@ -230,6 +243,8 @@ if (ignoreStdin) { input.on("line", (line) => { const request = JSON.parse(line); requests += 1; + if (requestLog !== undefined) fs.writeFileSync(requestLog, `${requests}\n`); + if (hangRequests) return; let response; if (firstUnchanged) { // A first answer that reuses a snapshot that does not exist yet. diff --git a/tests/test-graph/src/internal/ttsc-canonical-contract.json b/tests/test-graph/src/internal/ttsc-canonical-contract.json index 8296e0a..b398c07 100644 --- a/tests/test-graph/src/internal/ttsc-canonical-contract.json +++ b/tests/test-graph/src/internal/ttsc-canonical-contract.json @@ -1,7 +1,7 @@ { "reference": { "repository": "samchon/ttsc", - "commit": "b47d315a16e12b0339c1fa8c8ffc896c301a562d", + "commit": "2b724664eee71c57cd688c55122cac137774bf62", "directory": "packages/graph/src" }, "contracts": { @@ -46,8 +46,8 @@ "prose": "/** What to do with a compiler-derived graph result. */\nexport interface ITtscGraphNext {\n/** What to do with this result: - `answer`: the result carries the evidence; stop and answer, do not call graph again or read files to re-check it - `inspect`: the result is genuinely partial; make exactly the one `request` named, once - `outside`: the answer is outside the graph; escape and read source - `clarify`: the request was malformed or ambiguous; restate it */\naction: \"answer\" | \"inspect\" | \"outside\" | \"clarify\";\n/** The single graph request type to use when `action` is `inspect`. */\nrequest?:\n| \"entrypoints\"\n| \"lookup\"\n| \"trace\"\n| \"details\"\n| \"overview\"\n| \"tour\";\n/** Why the returned evidence supports that action. */\nreason: string;\n}" }, "Node": { - "structure": "import { ITtscGraphDecorator } from \"./ITtscGraphDecorator\";\nimport { ITtscGraphEvidence } from \"./ITtscGraphEvidence\";\nimport { TtscGraphNodeKind } from \"./TtscGraphNodeKind\";\nimport { TtscGraphNodeModifier } from \"./TtscGraphNodeModifier\";\nexport interface ITtscGraphNode {\nid: string;\nkind: TtscGraphNodeKind;\nname: string;\nqualifiedName?: string;\nfile: string;\nexternal: boolean;\nignored?: boolean;\nexported?: boolean;\nclosure?: boolean;\nmodifiers?: TtscGraphNodeModifier[];\nliterals?: string[];\nenumMembers?: ITtscGraphNode.IEnumMember[];\ndecorators?: ITtscGraphDecorator[];\nevidence?: ITtscGraphEvidence;\nimplementation?: ITtscGraphEvidence;\n}\nexport namespace ITtscGraphNode {\nexport interface IEnumMember {\nname: string;\nvalue?: string;\n}\n}", - "prose": "import { ITtscGraphDecorator } from \"./ITtscGraphDecorator\";\nimport { ITtscGraphEvidence } from \"./ITtscGraphEvidence\";\nimport { TtscGraphNodeKind } from \"./TtscGraphNodeKind\";\nimport { TtscGraphNodeModifier } from \"./TtscGraphNodeModifier\";\n/** One node in the graph: a declared symbol or a structural container (file, package). The `id` is position-invariant: `path#qualifiedName:kind` (e.g. `src/order.ts#OrderService.create:method`), so inserting a line above a declaration does not re-key it. Line and span live in `evidence` and are never part of identity. */\nexport interface ITtscGraphNode {\n/** Position-invariant identity (see the interface doc for the id grammar). */\nid: string;\n/** What this node represents. */\nkind: TtscGraphNodeKind;\n/** The simple, unqualified declared name (`create`, `OrderService`, `App`). */\nname: string;\n/** The owner-qualified name, when the node lives inside another declaration: `OrderService.create`, `Shopping.ISale`. Absent for a top-level declaration. */\nqualifiedName?: string;\n/** Project-relative path of the file that declares this node. */\nfile: string;\n/** True when the declaration is outside the workspace (a dependency): kept as a named endpoint, not walked into. */\nexternal: boolean;\n/** True when `file` is git-ignored generated code (Prisma client, codegen output); projections desurface these so generated nodes do not bury the authored graph. */\nignored?: boolean;\n/** True when the symbol is part of its module's export surface. */\nexported?: boolean;\n/** True for a declaration made inside another declaration's body: Vue's `baseCreateRenderer.patch`, a callback bound to a const inside a method. It is a name the runtime calls, so a trace, a lookup, or a details request answers with it. An orientation tour does not rank or walk it: a tour is asked what the project's surface is and how it runs, and a body's inner functions are neither — letting them into the seed ranking reshuffled which flows a tour told, and the model went back to the files. */\nclosure?: boolean;\n/** Declaration modifiers, when the declaration pass recorded any. */\nmodifiers?: TtscGraphNodeModifier[];\n/** The complete value set of a type alias or enum whose declared type the checker resolved to literals, each in TypeScript source form (`\"a\"`, `1`, `true`, `null`). Present only when every constituent is enumerable, so the list is the whole type and never a sample of it: `type T = Kind | string` admits values no list can name and carries none. It is resolved from the type, not read off the declaration, so indirection (`type I = Kind | 'f'`) is followed and the answer does not depend on how the declaration is wrapped. */\nliterals?: string[];\n/** What an enum declares, in checker order: the name a caller writes and the value it carries. Absent on every other kind. `literals` says which values the enum admits, which is what a serializer asks. The code says `Colors.Red`, so the names are the other half, and without them a caller that had already named the enum still had to open the file to learn what to type. The members are not nodes — `Colors.Red` is a string a grep finds exactly — so this fills in the node the graph already holds instead of minting one per member. */\nenumMembers?: ITtscGraphNode.IEnumMember[];\n/** Decorators written on this declaration, in source order: raw facts (`@Controller`, `@Get`) a consumer interprets without re-parsing source. */\ndecorators?: ITtscGraphDecorator[];\n/** The declaration span, for display and signatures. */\nevidence?: ITtscGraphEvidence;\n/** The implementation span when a callable/property member is implemented by a function assignment separate from its declaration. */\nimplementation?: ITtscGraphEvidence;\n}\nexport namespace ITtscGraphNode {\n/** One member of an enum: the name a caller writes and the value it carries. */\nexport interface IEnumMember {\n/** The member's own name, unqualified (`Red` on `Colors.Red`). */\nname: string;\n/** The value it carries, in TypeScript source form (`\"red\"`, `1`). Absent for a computed member the checker could not fold to a constant; the name still stands. */\nvalue?: string;\n}\n}" + "structure": "import { ITtscGraphDecorator } from \"./ITtscGraphDecorator\";\nimport { ITtscGraphEvidence } from \"./ITtscGraphEvidence\";\nimport { TtscGraphNodeKind } from \"./TtscGraphNodeKind\";\nimport { TtscGraphNodeModifier } from \"./TtscGraphNodeModifier\";\nexport interface ITtscGraphNode {\nid: string;\nkind: TtscGraphNodeKind;\nname: string;\nqualifiedName?: string;\nfile: string;\nexternal: boolean;\nignored?: boolean;\nexported?: boolean;\nclosure?: boolean;\nmodifiers?: TtscGraphNodeModifier[];\nliterals?: string[];\nenumMembers?: ITtscGraphNode.IEnumMember[];\nobjectMembers?: ITtscGraphNode.IObjectMember[];\ndecorators?: ITtscGraphDecorator[];\nevidence?: ITtscGraphEvidence;\nimplementation?: ITtscGraphEvidence;\n}\nexport namespace ITtscGraphNode {\nexport interface IEnumMember {\nname: string;\nvalue?: string;\n}\nexport interface IObjectMember {\nname: string;\nkind: \"property\" | \"method\";\nline?: number;\nsignature?: string;\n}\n}", + "prose": "import { ITtscGraphDecorator } from \"./ITtscGraphDecorator\";\nimport { ITtscGraphEvidence } from \"./ITtscGraphEvidence\";\nimport { TtscGraphNodeKind } from \"./TtscGraphNodeKind\";\nimport { TtscGraphNodeModifier } from \"./TtscGraphNodeModifier\";\n/** One node in the graph: a declared symbol or a structural container (file, package). The `id` is position-invariant: `path#qualifiedName:kind` (e.g. `src/order.ts#OrderService.create:method`), so inserting a line above a declaration does not re-key it. Line and span live in `evidence` and are never part of identity. */\nexport interface ITtscGraphNode {\n/** Position-invariant identity (see the interface doc for the id grammar). */\nid: string;\n/** What this node represents. */\nkind: TtscGraphNodeKind;\n/** The simple, unqualified declared name (`create`, `OrderService`, `App`). */\nname: string;\n/** The owner-qualified name, when the node lives inside another declaration: `OrderService.create`, `Shopping.ISale`. Absent for a top-level declaration. */\nqualifiedName?: string;\n/** Project-relative path of the file that declares this node. */\nfile: string;\n/** True when the declaration is outside the workspace (a dependency): kept as a named endpoint, not walked into. */\nexternal: boolean;\n/** True when `file` is git-ignored generated code (Prisma client, codegen output); projections desurface these so generated nodes do not bury the authored graph. */\nignored?: boolean;\n/** True when the symbol is part of its module's export surface. */\nexported?: boolean;\n/** True for a declaration made inside another declaration's body: Vue's `baseCreateRenderer.patch`, a callback bound to a const inside a method. It is a name the runtime calls, so a trace, a lookup, or a details request answers with it. An orientation tour does not rank or walk it: a tour is asked what the project's surface is and how it runs, and a body's inner functions are neither — letting them into the seed ranking reshuffled which flows a tour told, and the model went back to the files. */\nclosure?: boolean;\n/** Declaration modifiers, when the declaration pass recorded any. */\nmodifiers?: TtscGraphNodeModifier[];\n/** The complete value set of a type alias or enum whose declared type the checker resolved to literals, each in TypeScript source form (`\"a\"`, `1`, `true`, `null`). Present only when every constituent is enumerable, so the list is the whole type and never a sample of it: `type T = Kind | string` admits values no list can name and carries none. It is resolved from the type, not read off the declaration, so indirection (`type I = Kind | 'f'`) is followed and the answer does not depend on how the declaration is wrapped. */\nliterals?: string[];\n/** What an enum declares, in checker order: the name a caller writes and the value it carries. Absent on every other kind. `literals` says which values the enum admits, which is what a serializer asks. The code says `Colors.Red`, so the names are the other half, and without them a caller that had already named the enum still had to open the file to learn what to type. The members are not nodes — `Colors.Red` is a string a grep finds exactly — so this fills in the node the graph already holds instead of minting one per member. */\nenumMembers?: ITtscGraphNode.IEnumMember[];\n/** Direct, statically named members when this variable is initialized with an object literal, in declaration order. The native builder takes identity from the compiler AST and renders the compact signature from the same Program-owned source snapshot. A spread or dynamic computed name has no declaration name it can report soundly, so it contributes no fabricated member. */\nobjectMembers?: ITtscGraphNode.IObjectMember[];\n/** Decorators written on this declaration, in source order: raw facts (`@Controller`, `@Get`) a consumer interprets without re-parsing source. */\ndecorators?: ITtscGraphDecorator[];\n/** The declaration span, for display and signatures. */\nevidence?: ITtscGraphEvidence;\n/** The implementation span when a callable/property member is implemented by a function assignment separate from its declaration. */\nimplementation?: ITtscGraphEvidence;\n}\nexport namespace ITtscGraphNode {\n/** One member of an enum: the name a caller writes and the value it carries. */\nexport interface IEnumMember {\n/** The member's own name, unqualified (`Red` on `Colors.Red`). */\nname: string;\n/** The value it carries, in TypeScript source form (`\"red\"`, `1`). Absent for a computed member the checker could not fold to a constant; the name still stands. */\nvalue?: string;\n}\n/** One direct, statically named member of an object-literal variable. */\nexport interface IObjectMember {\n/** The source-visible static property name. */\nname: string;\n/** Whether the declaration is a data property or callable/accessor member. */\nkind: \"property\" | \"method\";\n/** 1-based declaration line in the node's file, when source was available. */\nline?: number;\n/** Compact declaration outline rendered from the compiler source snapshot. */\nsignature?: string;\n}\n}" }, "Overview": { "structure": "export interface ITtscGraphOverview {\ntype: \"overview\";\nproject: string;\ncounts: ITtscGraphOverview.ICounts;\nlayers?: ITtscGraphOverview.ILayer[];\nhotspots?: ITtscGraphOverview.IHotspot[];\npublicApi?: ITtscGraphOverview.IPublicApi[];\n}\nexport namespace ITtscGraphOverview {\nexport interface IRequest {\ntype: \"overview\";\naspect?: \"all\" | \"layers\" | \"hotspots\" | \"publicApi\";\n}\nexport interface ICounts {\nfiles: number;\nnodes: number;\nedges: number;\nbyKind: Record;\n}\nexport interface ILayer {\ndir: string;\nfiles: number;\nexported: number;\n}\nexport interface INode {\nid: string;\nname: string;\nkind: string;\nfile: string;\nline?: number;\n}\nexport interface IHotspot extends INode {\nfanIn: number;\nfanOut: number;\n}\nexport type IPublicApi = INode;\n}", @@ -63,7 +63,7 @@ }, "Trace": { "structure": "import { ITtscGraphEvidence } from \"./ITtscGraphEvidence\";\nexport interface ITtscGraphTrace {\ntype: \"trace\";\nstart?: ITtscGraphTrace.INode;\ndirection: string;\nhops: ITtscGraphTrace.IHop[];\nreached: ITtscGraphTrace.INode[];\ntruncated: boolean;\ntarget?: ITtscGraphTrace.INode;\npath?: ITtscGraphTrace.INode[];\nsteps?: string[];\njunctions?: ITtscGraphTrace.IJunction[];\ncandidates?: ITtscGraphTrace.INode[];\n}\nexport namespace ITtscGraphTrace {\nexport interface IJunction {\nid: string;\nname: string;\nkind: string;\nfile: string;\nline?: number;\nfromStart: IJunctionEdge;\nfromTarget: IJunctionEdge;\n}\nexport interface IJunctionEdge {\nkind: string;\noutgoing: boolean;\nevidence?: ITtscGraphEvidence;\n}\nexport interface IRequest {\ntype: \"trace\";\nfrom: string;\nto?: string;\ndirection?: \"forward\" | \"reverse\" | \"impact\";\nfocus?: \"all\" | \"execution\" | \"types\";\nmaxDepth?: number;\nmaxNodes?: number;\nincludeExternal?: boolean;\n}\nexport interface IHop {\nfrom: string;\nto: string;\nkind: string;\ndepth: number;\nevidence?: ITtscGraphEvidence;\n}\nexport interface INode {\nid: string;\nname: string;\nkind: string;\nfile: string;\nline?: number;\nsourceSpan?: Pick;\ndepth?: number;\nsignature?: string;\nroles?: string[];\n}\n}", - "prose": "import { ITtscGraphEvidence } from \"./ITtscGraphEvidence\";\n/** The compact dependency or caller flow returned from a selected start symbol. */\nexport interface ITtscGraphTrace {\n/** Discriminator for dependency tracing. */\ntype: \"trace\";\n/** The resolved start node, or undefined when `from` matched nothing. */\nstart?: ITtscGraphTrace.INode;\n/** Trace direction actually used by this result. */\ndirection: string;\n/** Edges traversed, in breadth-first order. */\nhops: ITtscGraphTrace.IHop[];\n/** Unique nodes reached (excluding the start), each with its depth and roles. */\nreached: ITtscGraphTrace.INode[];\n/** True when the trace hit its node or depth cap; the returned flow stands. */\ntruncated: boolean;\n/** The resolved `to` target, when a path was requested. */\ntarget?: ITtscGraphTrace.INode;\n/** Ordered dependency path from `from` to `to` when `to` was given (`from` first, `to` last), empty when `to` is unreachable. */\npath?: ITtscGraphTrace.INode[];\n/** Compact hop summaries preserving node names and edge evidence, capped. */\nsteps?: string[];\n/** Symbols both ends touch, when no call path runs between them. Nothing calls across the gap because in an event-driven codebase nothing does: a handler registers a listener on an emitter, the emitter's `emit()` runs whatever a registration put in an array, and no call edge crosses that array. But both ends touch the emitter, and that is an edge, not a guess — Excalidraw's pointer handler and its store's emit both reference `Store.onDurableIncrementEmitter`, which is exactly the seam the call graph cannot walk. A junction is not a path. It is the symbol to look at next, and the edges that say why. */\njunctions?: ITtscGraphTrace.IJunction[];\n/** When `from` was an ambiguous name, the matches to disambiguate with. */\ncandidates?: ITtscGraphTrace.INode[];\n}\nexport namespace ITtscGraphTrace {\n/** A symbol both ends of an unreachable path touch, and how each touches it. */\nexport interface IJunction {\n/** Stable node id: trace or inspect this symbol to cross the seam. */\nid: string;\n/** Qualified symbol name when available, otherwise the simple name. */\nname: string;\n/** Declaration kind (`variable`, `method`, `class`, ...). */\nkind: string;\n/** Project-relative path of the file that declares it. */\nfile: string;\n/** 1-based declaration line, when known. */\nline?: number;\n/** How the start reaches it: the edge kind, and where that edge sits. */\nfromStart: IJunctionEdge;\n/** How the target reaches it, or is reached from it. */\nfromTarget: IJunctionEdge;\n}\n/** One edge between an end of the requested path and the junction. */\nexport interface IJunctionEdge {\n/** `calls`, `accesses`, `instantiates`, `type_ref`, ... */\nkind: string;\n/** True when the end is the edge's source, false when it is the target. */\noutgoing: boolean;\n/** Where the reference sits in source. */\nevidence?: ITtscGraphEvidence;\n}\n/** Where and how far to trace dependency flow. */\nexport interface IRequest {\n/** Discriminator for dependency tracing. */\ntype: \"trace\";\n/** Where to start: a node id, a simple symbol name, or a dotted member (`OrderService.create`). An ambiguous name returns its candidates instead of a trace. */\nfrom: string;\n/** Target symbol (node id, simple name, or dotted member). When given, the tool returns the dependency path from `from` to it, the one-call answer for \"how does A reach B\". Prefer this path mode whenever both ends are known. */\nto?: string;\n/** Trace direction: - `forward`: what the start uses (callees, instantiations, renders) - `reverse`: what uses the start (callers); the usual fit for caller questions - `impact`: reverse trace prioritizing public API and test nodes a change reaches; its test nodes are semantic usage edges, not a text search @default \"forward\" */\ndirection?: \"forward\" | \"reverse\" | \"impact\";\n/** Non-structural edge family to follow: - `execution`: runtime calls, instantiations, property access, JSX renders - `types`: type references and inheritance - `all`: the full graph Flow questions usually want `execution`, not `all`. @default \"all\" */\nfocus?: \"all\" | \"execution\" | \"types\";\n/** Hops deep to follow (open forward/reverse cap at 8, impact at 4, path mode at 12). Raise it to follow a runtime chain to its end in one call. @default 3 */\nmaxDepth?: number;\n/** Cap on reached nodes (open forward/reverse cap at 32, impact at 16). @default 12 */\nmaxNodes?: number;\n/** Include dependency-boundary nodes from node_modules or bundled `.d.ts` libraries. Enable only for questions about external type/API boundaries. @default false */\nincludeExternal?: boolean;\n}\n/** One traversed edge, with its depth from the start. */\nexport interface IHop {\n/** Source node id for this traversed edge. */\nfrom: string;\n/** Target node id for this traversed edge. */\nto: string;\n/** Edge kind (`calls`, `type_ref`, `accesses`, ...). */\nkind: string;\n/** Hops from the start (1 = direct). */\ndepth: number;\n/** Source span that produced the hop: citable without opening the file. */\nevidence?: ITtscGraphEvidence;\n}\n/** A node on the trace: the start, a reached node, or a candidate. */\nexport interface INode {\n/** Stable node id for subsequent graph calls. */\nid: string;\n/** Qualified symbol name when available, otherwise the simple name. */\nname: string;\n/** Declaration kind (`class`, `method`, `function`, ...). */\nkind: string;\n/** Project-relative path of the declaration file. */\nfile: string;\n/** 1-based declaration line, when known. */\nline?: number;\n/** Declaration or implementation citation range, when known. */\nsourceSpan?: Pick;\n/** Hops from the start, on a reached node. */\ndepth?: number;\n/** The node's signature, carried on path nodes so the path explains itself. */\nsignature?: string;\n/** Why this node matters to an impact trace: `exported`, `test`. */\nroles?: string[];\n}\n}" + "prose": "import { ITtscGraphEvidence } from \"./ITtscGraphEvidence\";\n/** The compact dependency or caller flow returned from a selected start symbol. */\nexport interface ITtscGraphTrace {\n/** Discriminator for dependency tracing. */\ntype: \"trace\";\n/** The resolved start node, or undefined when `from` matched nothing. */\nstart?: ITtscGraphTrace.INode;\n/** Trace direction actually used by this result. */\ndirection: string;\n/** Edges traversed, in breadth-first order. */\nhops: ITtscGraphTrace.IHop[];\n/** Unique nodes reached (excluding the start), each with its depth and roles. */\nreached: ITtscGraphTrace.INode[];\n/** In an open trace, true when a bound omitted an eligible node or hop. */\ntruncated: boolean;\n/** The resolved `to` target, when a path was requested. */\ntarget?: ITtscGraphTrace.INode;\n/** Ordered dependency path from `from` to `to` when `to` was given (`from` first, `to` last), empty when `to` is unreachable. */\npath?: ITtscGraphTrace.INode[];\n/** Compact hop summaries preserving node names and edge evidence, capped. */\nsteps?: string[];\n/** Symbols both ends touch, when no call path runs between them. Nothing calls across the gap because in an event-driven codebase nothing does: a handler registers a listener on an emitter, the emitter's `emit()` runs whatever a registration put in an array, and no call edge crosses that array. But both ends touch the emitter, and that is an edge, not a guess — Excalidraw's pointer handler and its store's emit both reference `Store.onDurableIncrementEmitter`, which is exactly the seam the call graph cannot walk. A junction is not a path. It is the symbol to look at next, and the edges that say why. */\njunctions?: ITtscGraphTrace.IJunction[];\n/** When `from` was an ambiguous name, the matches to disambiguate with. */\ncandidates?: ITtscGraphTrace.INode[];\n}\nexport namespace ITtscGraphTrace {\n/** A symbol both ends of an unreachable path touch, and how each touches it. */\nexport interface IJunction {\n/** Stable node id: trace or inspect this symbol to cross the seam. */\nid: string;\n/** Qualified symbol name when available, otherwise the simple name. */\nname: string;\n/** Declaration kind (`variable`, `method`, `class`, ...). */\nkind: string;\n/** Project-relative path of the file that declares it. */\nfile: string;\n/** 1-based declaration line, when known. */\nline?: number;\n/** How the start reaches it: the edge kind, and where that edge sits. */\nfromStart: IJunctionEdge;\n/** How the target reaches it, or is reached from it. */\nfromTarget: IJunctionEdge;\n}\n/** One edge between an end of the requested path and the junction. */\nexport interface IJunctionEdge {\n/** `calls`, `accesses`, `instantiates`, `type_ref`, ... */\nkind: string;\n/** True when the end is the edge's source, false when it is the target. */\noutgoing: boolean;\n/** Where the reference sits in source. */\nevidence?: ITtscGraphEvidence;\n}\n/** Where and how far to trace dependency flow. */\nexport interface IRequest {\n/** Discriminator for dependency tracing. */\ntype: \"trace\";\n/** Where to start: a node id, a simple symbol name, or a dotted member (`OrderService.create`). An ambiguous name returns its candidates instead of a trace. */\nfrom: string;\n/** Target symbol (node id, simple name, or dotted member). When given, the tool returns the dependency path from `from` to it, the one-call answer for \"how does A reach B\". Prefer this path mode whenever both ends are known. */\nto?: string;\n/** Trace direction: - `forward`: what the start uses (callees, instantiations, renders) - `reverse`: what uses the start (callers); the usual fit for caller questions - `impact`: reverse trace prioritizing public API and test nodes a change reaches; its test nodes are semantic usage edges, not a text search @default \"forward\" */\ndirection?: \"forward\" | \"reverse\" | \"impact\";\n/** Non-structural edge family to follow: - `execution`: runtime calls, instantiations, property access, JSX renders - `types`: type references and inheritance - `all`: the full graph Flow questions usually want `execution`, not `all`. @default \"all\" */\nfocus?: \"all\" | \"execution\" | \"types\";\n/** Hops deep to follow (open forward/reverse cap at 8, impact at 4, path mode at 12). Raise it to follow a runtime chain to its end in one call. @default 3 */\nmaxDepth?: number;\n/** Cap on reached nodes (open forward/reverse cap at 32, impact at 16). @default 12 */\nmaxNodes?: number;\n/** Include dependency-boundary nodes from node_modules or bundled `.d.ts` libraries. Enable only for questions about external type/API boundaries. @default false */\nincludeExternal?: boolean;\n}\n/** One traversed edge, with its depth from the start. */\nexport interface IHop {\n/** Source node id for this traversed edge. */\nfrom: string;\n/** Target node id for this traversed edge. */\nto: string;\n/** Edge kind (`calls`, `type_ref`, `accesses`, ...). */\nkind: string;\n/** Hops from the start (1 = direct). */\ndepth: number;\n/** Source span that produced the hop: citable without opening the file. */\nevidence?: ITtscGraphEvidence;\n}\n/** A node on the trace: the start, a reached node, or a candidate. */\nexport interface INode {\n/** Stable node id for subsequent graph calls. */\nid: string;\n/** Qualified symbol name when available, otherwise the simple name. */\nname: string;\n/** Declaration kind (`class`, `method`, `function`, ...). */\nkind: string;\n/** Project-relative path of the declaration file. */\nfile: string;\n/** 1-based declaration line, when known. */\nline?: number;\n/** Declaration or implementation citation range, when known. */\nsourceSpan?: Pick;\n/** Hops from the start, on a reached node. */\ndepth?: number;\n/** The node's signature, carried on path nodes so the path explains itself. */\nsignature?: string;\n/** Why this node matters to an impact trace: `exported`, `test`. */\nroles?: string[];\n}\n}" }, "NodeModifier": { "structure": "export type TtscGraphNodeModifier =\n| \"export\"\n| \"default\"\n| \"declare\"\n| \"abstract\"\n| \"static\"\n| \"readonly\"\n| \"async\"\n| \"const\"\n| \"public\"\n| \"private\"\n| \"protected\"\n| \"optional\";", From 145f76a339a4d804124d685f2241ec736bdc1c34 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Sun, 19 Jul 2026 16:41:14 +0900 Subject: [PATCH 03/16] Keep graph facts bound to one trustworthy snapshot Close the review gaps where live disk reads, synthesized generic relationships, incomplete native shutdown, and bounded traces could make a result claim more than its producing snapshot proved. Preserve explicit compatibility with the latest published schema while keeping schema 5 as the complete compiler-owned contract. Constraint: Published ttsc 0.19.3 still emits schema 3; complete schema 5 manifests require samchon/ttsc#781 until the next release. Rejected: Infer member relationships from generic source text | name equality cannot prove checker dispatch. Rejected: Re-read live files for missing snapshot text | it mixes different program generations. Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep schema 3 as an explicit incomplete compatibility lane and require producer-owned facts for schema 5 details. Tested: @samchon/graph build; @samchon/graph-test build; 16 focused feature tests including coverage edges; 18-contract parity preview; schema 5 self-index of graph and graph-sitter with zero warnings. Not-tested: Full local coverage and all-platform suite; GitHub Actions remains authoritative for those surfaces. --- packages/graph/src/SamchonGraphMemory.ts | 11 +- .../graph/src/indexer/IBuildGraphOptions.ts | 9 + packages/graph/src/indexer/IIndexerResult.ts | 4 + .../graph/src/indexer/IResidentGraphSource.ts | 2 +- packages/graph/src/indexer/buildGraph.ts | 10 +- packages/graph/src/indexer/buildGraphDump.ts | 22 +- packages/graph/src/indexer/buildLspGraph.ts | 21 +- .../graph/src/indexer/buildStaticGraph.ts | 19 +- .../src/indexer/createResidentGraphSource.ts | 164 ++++++++++----- packages/graph/src/indexer/scanSession.ts | 3 - .../graph/src/indexer/staticGraphParts.ts | 2 - .../mcp/createResidentGraphMemorySource.ts | 3 +- .../src/operations/resolveGraphHandle.ts | 12 +- packages/graph/src/operations/runTrace.ts | 108 ++++++++-- packages/graph/src/operations/signatureOf.ts | 10 +- .../graph/src/provider/IBulkGraphSession.ts | 4 +- .../provider/ttscgraph/ITtscGraphSnapshot.ts | 17 +- .../provider/ttscgraph/adaptTtscGraphDump.ts | 94 +++++++-- .../src/structures/ISamchonGraphDetails.ts | 7 +- .../src/structures/ISamchonGraphTrace.ts | 2 +- pnpm-lock.yaml | 100 ++++----- pnpm-workspace.yaml | 2 +- ..._preserves_its_consumed_source_snapshot.ts | 105 +++++++++ .../src/features/test_coverage_edge_cases.ts | 37 +++- ...s_resolve_signature_decorated_callables.ts | 2 +- ...d_operation_engines_cover_rich_branches.ts | 14 +- ...tions_without_reprocessing_strict_facts.ts | 2 +- ...nt_close_reaches_a_stalled_bulk_refresh.ts | 99 +++++++++ ...t_static_mode_never_starts_an_lsp_build.ts | 30 +++ .../test_result_audits_before_the_facts.ts | 8 +- ...tic_indexes_inheritance_and_containment.ts | 9 +- ...est_trace_reports_only_actual_omissions.ts | 108 +++++++++- ...ph_dump_adapter_rejects_malformed_facts.ts | 199 +++++++++++++++--- ...3_is_an_explicit_compatibility_snapshot.ts | 65 ++++++ ...reduce_preserves_the_reference_contract.ts | 28 +++ .../test-graph/src/internal/ContractParity.ts | 13 +- 36 files changed, 1109 insertions(+), 236 deletions(-) create mode 100644 tests/test-graph/src/features/test_build_graph_preserves_its_consumed_source_snapshot.ts create mode 100644 tests/test-graph/src/features/test_resident_close_reaches_a_stalled_bulk_refresh.ts create mode 100644 tests/test-graph/src/features/test_resident_static_mode_never_starts_an_lsp_build.ts create mode 100644 tests/test-graph/src/features/test_ttscgraph_published_schema3_is_an_explicit_compatibility_snapshot.ts diff --git a/packages/graph/src/SamchonGraphMemory.ts b/packages/graph/src/SamchonGraphMemory.ts index d53dc40..e8adfc3 100644 --- a/packages/graph/src/SamchonGraphMemory.ts +++ b/packages/graph/src/SamchonGraphMemory.ts @@ -77,12 +77,15 @@ export class SamchonGraphMemory { } } - /** Build a model from a parsed dump, synthesizing structural relationships. */ + /** + * Build a model from a parsed dump, synthesizing structural relationships. + * A caller that has only a dump has no proof for current disk bytes, so + * source-derived display facts fail closed unless an explicit reader is + * supplied. + */ public static from( dump: ISamchonGraphDump, - source: SamchonGraphSourceReader = SamchonGraphSourceReader.live( - dump.project, - ), + source: SamchonGraphSourceReader = SamchonGraphSourceReader.none(dump.project), ): SamchonGraphMemory { const { nodes, edges } = synthesize(dump); return new SamchonGraphMemory(dump, nodes, edges, source); diff --git a/packages/graph/src/indexer/IBuildGraphOptions.ts b/packages/graph/src/indexer/IBuildGraphOptions.ts index d23f7a2..68d474f 100644 --- a/packages/graph/src/indexer/IBuildGraphOptions.ts +++ b/packages/graph/src/indexer/IBuildGraphOptions.ts @@ -54,6 +54,15 @@ export interface IBuildGraphOptions { * `dump` CLI command never does. */ keepAlive?: boolean; + /** + * Abort an in-flight compiler-owned bulk snapshot request. + * + * Resident graph sources use this to make shutdown reach an owned native + * provider immediately instead of waiting behind its serialized refresh. + * Generic LSP settlement is tracked separately and does not yet consume this + * signal. + */ + signal?: AbortSignal; /** * Command (and any leading arguments) used to bootstrap a missing * `compile_commands.json` for cpp/c projects that configure CMake. diff --git a/packages/graph/src/indexer/IIndexerResult.ts b/packages/graph/src/indexer/IIndexerResult.ts index a54200e..c93f14f 100644 --- a/packages/graph/src/indexer/IIndexerResult.ts +++ b/packages/graph/src/indexer/IIndexerResult.ts @@ -1,5 +1,6 @@ import { ISamchonGraphDump } from "../structures"; import { GraphLanguage } from "../typings"; +import { SamchonGraphSourceReader } from "../SamchonGraphSourceReader"; import { IBulkGraphSession } from "../provider/IBulkGraphSession"; import { ILspSession } from "./ILspSession"; @@ -23,4 +24,7 @@ export interface IIndexerResult { * set the graph is finalized against comes from the session's manifest. */ sources?: Map; + + /** Immutable source-display evidence captured with this exact dump. */ + source?: SamchonGraphSourceReader; } diff --git a/packages/graph/src/indexer/IResidentGraphSource.ts b/packages/graph/src/indexer/IResidentGraphSource.ts index 77fa158..4dfcb52 100644 --- a/packages/graph/src/indexer/IResidentGraphSource.ts +++ b/packages/graph/src/indexer/IResidentGraphSource.ts @@ -27,7 +27,7 @@ export interface IResidentGraphSource { load(): Promise; /** Source display reader belonging to the dump returned by the last load. */ - source(): SamchonGraphSourceReader | undefined; + source?(): SamchonGraphSourceReader | undefined; /** * End every language-server connection this source opened. Safe to call on a diff --git a/packages/graph/src/indexer/buildGraph.ts b/packages/graph/src/indexer/buildGraph.ts index 5821d1a..7932382 100644 --- a/packages/graph/src/indexer/buildGraph.ts +++ b/packages/graph/src/indexer/buildGraph.ts @@ -1,10 +1,14 @@ import { SamchonGraphMemory } from "../SamchonGraphMemory"; -import { buildGraphDump } from "./buildGraphDump"; +import { SamchonGraphSourceReader } from "../SamchonGraphSourceReader"; +import { buildGraphResult } from "./buildGraphDump"; import { IBuildGraphOptions } from "./IBuildGraphOptions"; export async function buildGraph( options: IBuildGraphOptions = {}, ): Promise { - const dump = await buildGraphDump(options); - return SamchonGraphMemory.from(dump); + const result = await buildGraphResult(options); + return SamchonGraphMemory.from( + result.dump, + result.source ?? SamchonGraphSourceReader.none(result.dump.project), + ); } diff --git a/packages/graph/src/indexer/buildGraphDump.ts b/packages/graph/src/indexer/buildGraphDump.ts index fe6c811..b480eb1 100644 --- a/packages/graph/src/indexer/buildGraphDump.ts +++ b/packages/graph/src/indexer/buildGraphDump.ts @@ -3,23 +3,29 @@ import typia from "typia"; import { ISamchonGraphDump } from "../structures"; import { IBuildGraphOptions } from "./IBuildGraphOptions"; import { buildLspGraph } from "./buildLspGraph"; -import { buildStaticGraph } from "./buildStaticGraph"; +import { buildStaticGraphResult } from "./buildStaticGraph"; +import { IIndexerResult } from "./IIndexerResult"; export async function buildGraphDump( options: IBuildGraphOptions = {}, ): Promise { + return (await buildGraphResult(options)).dump; +} + +/** Internal one-shot result that keeps source evidence beside its dump. */ +export async function buildGraphResult( + options: IBuildGraphOptions = {}, +): Promise { const normalized: IBuildGraphOptions = { ...options, cwd: path.resolve(options.cwd ?? process.cwd()), mode: options.mode ?? "auto", }; - if (normalized.mode === "static") { - return validateDump(buildStaticGraph(normalized)); - } - if (normalized.mode === "lsp") { - return validateDump((await buildLspGraph(normalized)).dump); - } - return validateDump((await buildLspGraph(normalized)).dump); + const result = + normalized.mode === "static" + ? buildStaticGraphResult(normalized) + : await buildLspGraph(normalized); + return { ...result, dump: validateDump(result.dump) }; } function validateDump(dump: ISamchonGraphDump): ISamchonGraphDump { diff --git a/packages/graph/src/indexer/buildLspGraph.ts b/packages/graph/src/indexer/buildLspGraph.ts index 982ae02..3aa1544 100644 --- a/packages/graph/src/indexer/buildLspGraph.ts +++ b/packages/graph/src/indexer/buildLspGraph.ts @@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { IDiagnostic, LspClient } from "../lsp"; +import { SamchonGraphSourceReader } from "../SamchonGraphSourceReader"; import { ISamchonGraphDiagnostic, ISamchonGraphDump, @@ -59,6 +60,15 @@ export async function buildLspGraph( // did this text say", and the freshness hashes that ask that question already // skip every bulk language. const strictFiles = new Set(); + const strictDigests = new Map< + string, + IBulkGraphSession.ISourceDigest + >(); + const snapshotSource = (): SamchonGraphSourceReader => + new SamchonGraphSourceReader(root, { + texts: sources, + digests: strictDigests, + }); let lspNodeCount = 0; // Computed once (not per-language) since cpp and c share the same clangd // compilation database and root. @@ -101,11 +111,15 @@ export async function buildLspGraph( // 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. - for (const file of result.sources.keys()) strictFiles.add(file); + for (const [file, digest] of result.sources) { + strictFiles.add(file); + strictDigests.set(file, digest); + } lspNodeCount += result.nodes.length; if (options.keepAlive) sessions.set(language, session); continue; } catch (error) { + if (options.signal?.aborted) throw error; warnings.push( `typescript: ttscgraph bulk indexing failed; using ttscserver LSP: ${(error as Error).message}`, ); @@ -198,6 +212,7 @@ export async function buildLspGraph( return { dump: staticDump(fallback, warnings), warnings, + source: snapshotSource(), ...(options.keepAlive ? { sessions, sources } : {}), }; } @@ -212,6 +227,7 @@ export async function buildLspGraph( return { dump: staticDump(fallback, warnings), warnings, + source: snapshotSource(), ...(options.keepAlive ? { sessions, sources } : {}), }; } @@ -241,6 +257,7 @@ export async function buildLspGraph( warnings, }, warnings, + source: snapshotSource(), ...(options.keepAlive ? { sessions, sources } : {}), }; } @@ -256,7 +273,7 @@ async function collectTtscGraph( }> { const session = new TtscGraphClient({ root, command, args }); try { - const result = (await session.refresh()).snapshot; + const result = (await session.refresh({ signal: options.signal })).snapshot; if (!options.keepAlive) await session.close(); return { result, session }; } catch (error) { diff --git a/packages/graph/src/indexer/buildStaticGraph.ts b/packages/graph/src/indexer/buildStaticGraph.ts index d91edc7..23ec432 100644 --- a/packages/graph/src/indexer/buildStaticGraph.ts +++ b/packages/graph/src/indexer/buildStaticGraph.ts @@ -1,8 +1,10 @@ import { ISamchonGraphDump } from "../structures"; +import { SamchonGraphSourceReader } from "../SamchonGraphSourceReader"; import { dedupeEdges } from "./dedupeEdges"; import { dedupeNodes } from "./dedupeNodes"; import { finalizeGraph } from "./finalizeGraph"; import { IBuildGraphOptions } from "./IBuildGraphOptions"; +import { IIndexerResult } from "./IIndexerResult"; import { staticGraphParts } from "./staticGraphParts"; import { wireEdges } from "./wireEdges"; import { wireNodes } from "./wireNodes"; @@ -16,6 +18,13 @@ import { wireNodes } from "./wireNodes"; export function buildStaticGraph( options: IBuildGraphOptions = {}, ): ISamchonGraphDump { + return buildStaticGraphResult(options).dump; +} + +/** Build one static dump together with the exact source bytes it consumed. */ +export function buildStaticGraphResult( + options: IBuildGraphOptions = {}, +): IIndexerResult { const parts = staticGraphParts(options); const finalized = finalizeGraph( parts.root, @@ -23,7 +32,7 @@ export function buildStaticGraph( parts.nodes, parts.edges, ); - return { + const dump: ISamchonGraphDump = { project: parts.root, languages: parts.languages, indexer: "static", @@ -31,4 +40,12 @@ export function buildStaticGraph( edges: wireEdges(dedupeEdges(finalized.edges)), warnings: parts.warnings, }; + return { + dump, + warnings: parts.warnings, + sources: new Map(parts.sources), + source: new SamchonGraphSourceReader(parts.root, { + texts: parts.sources, + }), + }; } diff --git a/packages/graph/src/indexer/createResidentGraphSource.ts b/packages/graph/src/indexer/createResidentGraphSource.ts index e68a30c..bd16f80 100644 --- a/packages/graph/src/indexer/createResidentGraphSource.ts +++ b/packages/graph/src/indexer/createResidentGraphSource.ts @@ -14,6 +14,7 @@ import { mergeGraphSlices } from "../provider/mergeGraphSlices"; import { readText, walkSourceFiles } from "../utils/fs"; import { allExtensions } from "./allExtensions"; import { buildLspGraph } from "./buildLspGraph"; +import { buildStaticGraphResult } from "./buildStaticGraph"; import { staticGraphParts } from "./staticGraphParts"; import { discoverLanguages } from "./discoverLanguages"; import { IBuildGraphOptions } from "./IBuildGraphOptions"; @@ -34,8 +35,14 @@ interface IResidentState { source: SamchonGraphSourceReader; } -const DEFAULT_DEPENDENCIES = { +interface IResidentDependencies { + buildLspGraph: typeof buildLspGraph; + buildStaticGraphResult?: typeof buildStaticGraphResult; +} + +const DEFAULT_DEPENDENCIES: IResidentDependencies = { buildLspGraph, + buildStaticGraphResult, }; // Languages that fell back to static parsing (no LSP session to hold) are @@ -43,20 +50,32 @@ const DEFAULT_DEPENDENCIES = { // warm state to lose, so there is nothing to reuse there. export function createResidentGraphSource( options: IBuildGraphOptions = {}, - dependencies: typeof DEFAULT_DEPENDENCIES = DEFAULT_DEPENDENCIES, + dependencies: IResidentDependencies = DEFAULT_DEPENDENCIES, ): IResidentGraphSource { const root = path.resolve(options.cwd ?? process.cwd()); let state: IResidentState | undefined; let queue: Promise = Promise.resolve(); let closed = false; + let activeAbort: AbortController | undefined; + let closing: Promise | undefined; + const sessionClosures = new WeakMap< + ILspSession | IBulkGraphSession, + Promise + >(); - async function buildFresh(): Promise { + async function buildFresh(signal: AbortSignal): Promise { // `keepAlive: true` above guarantees `buildLspGraph` always returns a // sessions map, even an empty one. - const result = await dependencies.buildLspGraph({ - ...options, - keepAlive: true, - }); + const result = + options.mode === "static" + ? (dependencies.buildStaticGraphResult ?? buildStaticGraphResult)( + options, + ) + : await dependencies.buildLspGraph({ + ...options, + keepAlive: true, + signal, + }); const sessions = result.sessions ?? new Map(); try { const texts = result.sources ?? new Map(); @@ -70,12 +89,12 @@ export function createResidentGraphSource( result.sources === undefined ? snapshotSources(root, options, bulkLanguagesOf(sessions)) : hashSources(result.sources), - source: sourceReaderOf(root, texts, sessions), + source: result.source ?? sourceReaderOf(root, texts, sessions), }; } catch (error) { // Once the build hands its sessions to this source, every later failure // on the path to publishing the state belongs to us to clean up. - await closeSessions(sessions); + await closeSessions(sessions, sessionClosures); throw error; } } @@ -174,70 +193,105 @@ export function createResidentGraphSource( current.source = sourceReaderOf(root, sources, current.sessions); } - async function replaceLanguages(current: IResidentState): Promise { - const fresh = await buildFresh(); + async function replaceLanguages( + current: IResidentState, + signal: AbortSignal, + ): Promise { + const fresh = await buildFresh(signal); if (closed) { - await closeSessions(fresh.sessions); + await closeSessions(fresh.sessions, sessionClosures); throw closedError(); } // Publish the complete replacement atomically before retiring the old // sessions. A failed fresh build leaves `current` untouched; a failed old // shutdown leaves the newly built state usable on the next call. state = fresh; - await closeSessions(current.sessions); + await closeSessions(current.sessions, sessionClosures); } return { load(): Promise { return enqueue(async () => { assertOpen(); - if (state === undefined) { - const fresh = await buildFresh(); - if (closed) { - await closeSessions(fresh.sessions); - throw closedError(); - } - state = fresh; - } else { - const prefetched = await refreshBulkSessions(state.sessions); - const bulkChanged = [...prefetched].some( - ([language, refresh]) => - state!.generations.get(language) !== refresh.generation, - ); - if ( - bulkChanged || - isStale( - state.hashes, - root, - options, - bulkLanguagesOf(state.sessions), - ) - ) { - const discovered = discoverLanguages(root, options); - if (sameLanguages(state.languages, discovered)) { - await refreshStale(state, prefetched); - } else { - await replaceLanguages(state); + const controller = new AbortController(); + activeAbort = controller; + try { + if (state === undefined) { + const fresh = await buildFresh(controller.signal); + if (closed) { + await closeSessions(fresh.sessions, sessionClosures); + throw closedError(); + } + state = fresh; + } else { + const prefetched = await refreshBulkSessions( + state.sessions, + controller.signal, + ); + const bulkChanged = [...prefetched].some( + ([language, refresh]) => + state!.generations.get(language) !== refresh.generation, + ); + if ( + bulkChanged || + isStale( + state.hashes, + root, + options, + bulkLanguagesOf(state.sessions), + ) + ) { + const discovered = discoverLanguages(root, options); + if (sameLanguages(state.languages, discovered)) { + await refreshStale(state, prefetched); + } else { + await replaceLanguages(state, controller.signal); + } } } + assertOpen(); + return state.dump; + } finally { + if (activeAbort === controller) activeAbort = undefined; } - assertOpen(); - return state.dump; }); }, source(): SamchonGraphSourceReader | undefined { return state?.source; }, close(): Promise { + if (closing !== undefined) return closing; // Flip the bit synchronously. A load already inside an await observes it // before publishing a newly-built/refreshed graph, while calls queued // behind this point never start another language server. closed = true; - return enqueue(async () => { - const current = state; - state = undefined; - if (current !== undefined) await closeSessions(current.sessions); - }); + activeAbort?.abort(); + const current = state; + const immediateBulkClose = + current === undefined + ? Promise.resolve() + : closeSessions(current.sessions, sessionClosures, true); + closing = (async () => { + let failure: Error | undefined; + try { + await immediateBulkClose; + } catch (error) { + failure = asError(error); + } + try { + await enqueue(async () => { + const final = state; + state = undefined; + if (final !== undefined) { + await closeSessions(final.sessions, sessionClosures); + } + }); + } catch (error) { + failure ??= asError(error); + } + if (failure !== undefined) throw failure; + })(); + return closing; }, }; @@ -314,12 +368,21 @@ function closedError(): Error { async function closeSessions( sessions: Map, + closures: WeakMap>, + bulkOnly = false, ): Promise { let failure: Error | undefined; for (const session of sessions.values()) { + if (bulkOnly && !isBulkGraphSession(session)) continue; try { - if (isBulkGraphSession(session)) await session.close(); - else await session.client.close(); + let closure = closures.get(session); + if (closure === undefined) { + closure = isBulkGraphSession(session) + ? session.close() + : session.client.close(); + closures.set(session, closure); + } + await closure; } catch (error) { // Close every resident process even when one shutdown handshake fails. failure ??= asError(error); @@ -358,11 +421,12 @@ function bulkGenerationsOf( async function refreshBulkSessions( sessions: ReadonlyMap, + signal?: AbortSignal, ): Promise> { const refreshed = new Map(); for (const [language, session] of sessions) { if (isBulkGraphSession(session)) { - refreshed.set(language, await session.refresh()); + refreshed.set(language, await session.refresh({ signal })); } } return refreshed; diff --git a/packages/graph/src/indexer/scanSession.ts b/packages/graph/src/indexer/scanSession.ts index 9c57e0a..ab65b02 100644 --- a/packages/graph/src/indexer/scanSession.ts +++ b/packages/graph/src/indexer/scanSession.ts @@ -27,7 +27,6 @@ import { appendAll } from "./appendAll"; import { decoratorsAbove } from "./decoratorsAbove"; import { IBuildGraphOptions } from "./IBuildGraphOptions"; import { ILspSession } from "./ILspSession"; -import { overrideEdges } from "./overrideEdges"; import { resolveType } from "./resolveType"; import { supertypesOf } from "./supertypesOf"; @@ -363,8 +362,6 @@ export async function scanSession( }); } } - appendAll(edges, overrideEdges(nodes, edges)); - return { nodes, edges, diff --git a/packages/graph/src/indexer/staticGraphParts.ts b/packages/graph/src/indexer/staticGraphParts.ts index 99a8613..6ec86c9 100644 --- a/packages/graph/src/indexer/staticGraphParts.ts +++ b/packages/graph/src/indexer/staticGraphParts.ts @@ -13,7 +13,6 @@ import { projectRelative, readText, walkSourceFiles } from "../utils/fs"; import { IBuildGraphOptions } from "./IBuildGraphOptions"; import { IStaticGraphParts } from "./IStaticGraphParts"; import { allExtensions, languageOf } from "./languages"; -import { overrideEdges } from "./overrideEdges"; /** * Discover a project snapshot and delegate its best-effort syntax extraction to @@ -50,7 +49,6 @@ export function staticGraphParts( }); } const parts = graphSitterParts({ root, files }); - parts.edges.push(...overrideEdges(parts.nodes, parts.edges)); return parts; } diff --git a/packages/graph/src/mcp/createResidentGraphMemorySource.ts b/packages/graph/src/mcp/createResidentGraphMemorySource.ts index 12f278a..3624b13 100644 --- a/packages/graph/src/mcp/createResidentGraphMemorySource.ts +++ b/packages/graph/src/mcp/createResidentGraphMemorySource.ts @@ -1,5 +1,6 @@ import { IResidentGraphSource } from "../indexer/IResidentGraphSource"; import { SamchonGraphMemory } from "../SamchonGraphMemory"; +import { SamchonGraphSourceReader } from "../SamchonGraphSourceReader"; import { ISamchonGraphDump } from "../structures"; /** @@ -24,7 +25,7 @@ export function createResidentGraphMemorySource( if (currentMemory === undefined || dump !== currentDump) { const replacement = SamchonGraphMemory.from( dump, - resident.source() ?? undefined, + resident.source?.() ?? SamchonGraphSourceReader.none(dump.project), ); currentDump = dump; currentMemory = replacement; diff --git a/packages/graph/src/operations/resolveGraphHandle.ts b/packages/graph/src/operations/resolveGraphHandle.ts index e64d58c..d3e8819 100644 --- a/packages/graph/src/operations/resolveGraphHandle.ts +++ b/packages/graph/src/operations/resolveGraphHandle.ts @@ -235,12 +235,16 @@ function rank( candidateLimit: number, ): IResolvedGraphHandle { if (resolved.candidates === undefined) return resolved; - const ranked = [...resolved.candidates] + const ranked = resolved.candidates + .map((node) => ({ node, score: candidateScore(graph, node) })) .sort((a, b) => { - const score = candidateScore(graph, b) - candidateScore(graph, a); - return score === 0 ? compareIdentity(a.id, b.id) : score; + const score = b.score - a.score; + return score === 0 + ? compareIdentity(a.node.id, b.node.id) + : score; }) - .slice(0, candidateLimit); + .slice(0, candidateLimit) + .map(({ node }) => node); return { candidates: ranked }; } diff --git a/packages/graph/src/operations/runTrace.ts b/packages/graph/src/operations/runTrace.ts index 1604235..3dbff8e 100644 --- a/packages/graph/src/operations/runTrace.ts +++ b/packages/graph/src/operations/runTrace.ts @@ -153,15 +153,16 @@ export function runTrace( focus, includeExternal, ); - const hasPath = found !== null; - const path = found?.path ?? []; - const hops = found?.hops ?? []; - const junctions = hasPath + const hasPath = found.path !== null; + const path = found.path ?? []; + const hops = found.hops; + const junctions = hasPath || found.truncated ? [] : junctionsBetween(graph, start.node.id, target.node.id, focus); return { result: { ...base, + truncated: found.truncated, start: summary(graph, start.node), target: summary(graph, target.node), hops, @@ -180,6 +181,12 @@ export function runTrace( // as the answer. Excalidraw's tour spent eleven calls finding this out. next: hasPath ? pathNext + : found.truncated + ? resultNext( + "inspect", + "The bounded path search omitted eligible continuations before it could prove reachability. Increase maxDepth when possible, or trace an intermediate handle instead of treating this as no connection.", + "trace", + ) : junctions.length > 0 ? resultNext( "inspect", @@ -444,12 +451,17 @@ function findPath( maxDepth: number, focus: ISamchonGraphTrace.IRequest["focus"], includeExternal: boolean, -): { path: ISamchonGraphNode[]; hops: ISamchonGraphTrace.IHop[] } | null { +): { + path: ISamchonGraphNode[] | null; + hops: ISamchonGraphTrace.IHop[]; + truncated: boolean; +} { const startNode = graph.node(startId); // The caller already resolved startId to a real node in this same graph. /* c8 ignore next */ - if (startNode === undefined) return null; - if (startId === targetId) return { path: [startNode], hops: [] }; + if (startNode === undefined) return { path: null, hops: [], truncated: false }; + if (startId === targetId) + return { path: [startNode], hops: [], truncated: false }; const parent = new Map< string, { @@ -460,20 +472,30 @@ function findPath( >(); const visited = new Set([startId]); let queue: Array<{ id: string; depth: number }> = [{ id: startId, depth: 0 }]; + const omitted = new Set(); while (queue.length > 0) { const next: Array<{ id: string; depth: number }> = []; for (const { id, depth } of queue) { - if (depth >= maxDepth) continue; - for (const edge of [ - ...graph.outgoing(id), - ...dispatchEdges(graph, id, focus), - ]) { - if (!traversable(edge.kind, focus)) continue; - const otherId = edge.to; - if (visited.has(otherId)) continue; - const other = graph.node(otherId); - if (other === undefined || other.kind === "file") continue; - if (!includeExternal && isExternalNode(other)) continue; + const candidates = pathEdges(graph, id, targetId, focus); + for (const edge of candidates.omitted) { + const endpoint = pathEndpoint(graph, edge, focus, includeExternal); + if (endpoint !== undefined && !visited.has(endpoint.otherId)) { + omitted.add(endpoint.otherId); + } + } + if (depth >= maxDepth) { + for (const edge of candidates.edges) { + const endpoint = pathEndpoint(graph, edge, focus, includeExternal); + if (endpoint !== undefined && !visited.has(endpoint.otherId)) { + omitted.add(endpoint.otherId); + } + } + continue; + } + for (const edge of candidates.edges) { + const endpoint = pathEndpoint(graph, edge, focus, includeExternal); + if (endpoint === undefined || visited.has(endpoint.otherId)) continue; + const { otherId, other } = endpoint; visited.add(otherId); const evidence = edgeEvidenceOf(edge); parent.set(otherId, { @@ -511,14 +533,49 @@ function findPath( hop.evidence = parentEdge.evidence; hops.push(hop); } - return { path, hops }; + return { path, hops, truncated: false }; } next.push({ id: otherId, depth: depth + 1 }); } } queue = next; } - return null; + return { + path: null, + hops: [], + truncated: [...omitted].some((id) => !visited.has(id)), + }; +} + +function pathEdges( + graph: SamchonGraphMemory, + id: string, + targetId: string, + focus: ISamchonGraphTrace.IRequest["focus"], +): { edges: ISamchonGraphEdge[]; omitted: ISamchonGraphEdge[] } { + const ordinary = [...graph.outgoing(id)]; + const dispatch = dispatchCandidates(graph, id, focus); + if (dispatch.length < DISPATCH_HUB) { + return { edges: [...ordinary, ...dispatch], omitted: [] }; + } + const target = dispatch.filter((edge) => edge.to === targetId); + return { + edges: [...ordinary, ...target], + omitted: dispatch.filter((edge) => edge.to !== targetId), + }; +} + +function pathEndpoint( + graph: SamchonGraphMemory, + edge: ISamchonGraphEdge, + focus: ISamchonGraphTrace.IRequest["focus"], + includeExternal: boolean, +): ITraceEndpoint | undefined { + if (!traversable(edge.kind, focus)) return undefined; + const other = graph.node(edge.to); + if (other === undefined || other.kind === "file") return undefined; + if (!includeExternal && isExternalNode(other)) return undefined; + return { otherId: edge.to, other }; } function orderedEdges( @@ -657,6 +714,15 @@ function dispatchEdges( graph: SamchonGraphMemory, id: string, focus: ISamchonGraphTrace.IRequest["focus"], +): ISamchonGraphEdge[] { + const out = dispatchCandidates(graph, id, focus); + return out.length >= DISPATCH_HUB ? [] : out; +} + +function dispatchCandidates( + graph: SamchonGraphMemory, + id: string, + focus: ISamchonGraphTrace.IRequest["focus"], ): ISamchonGraphEdge[] { if (focus === "types" || hasExecutionBody(graph, id)) return []; const out: ISamchonGraphEdge[] = []; @@ -672,7 +738,7 @@ function dispatchEdges( ...(edge.evidence !== undefined ? { evidence: edge.evidence } : {}), }); } - return out.length >= DISPATCH_HUB ? [] : out; + return out; } /** Whether the declaration has a body: something it calls, reads, or renders. */ diff --git a/packages/graph/src/operations/signatureOf.ts b/packages/graph/src/operations/signatureOf.ts index d6f5d21..f46927d 100644 --- a/packages/graph/src/operations/signatureOf.ts +++ b/packages/graph/src/operations/signatureOf.ts @@ -18,12 +18,12 @@ export function signatureOf( evidence === undefined ? undefined : graph.source.lines(evidence.file); if (lines === undefined || evidence === undefined) return undefined; const start = Math.max(0, evidence.startLine - 1); + const last = + evidence.endLine === undefined + ? lines.length - 1 + : Math.min(lines.length - 1, evidence.endLine - 1); const out: string[] = []; - for ( - let i = start; - i < lines.length && out.length < MAX_SIGNATURE_LINES; - i++ - ) { + for (let i = start; i <= last && out.length < MAX_SIGNATURE_LINES; i++) { const line = lines[i]; /* c8 ignore next */ if (line === undefined) break; diff --git a/packages/graph/src/provider/IBulkGraphSession.ts b/packages/graph/src/provider/IBulkGraphSession.ts index 2b82164..f7ae8a0 100644 --- a/packages/graph/src/provider/IBulkGraphSession.ts +++ b/packages/graph/src/provider/IBulkGraphSession.ts @@ -20,7 +20,9 @@ export interface IBulkGraphSession { readonly generation: number; readonly current: IBulkGraphSession.ISnapshot | undefined; - refresh(): Promise; + refresh(options?: { + signal?: AbortSignal; + }): Promise; close(): Promise; } diff --git a/packages/graph/src/provider/ttscgraph/ITtscGraphSnapshot.ts b/packages/graph/src/provider/ttscgraph/ITtscGraphSnapshot.ts index 1459c6c..e4ddf34 100644 --- a/packages/graph/src/provider/ttscgraph/ITtscGraphSnapshot.ts +++ b/packages/graph/src/provider/ttscgraph/ITtscGraphSnapshot.ts @@ -101,11 +101,24 @@ export namespace ITtscGraphSnapshot { * * Independent of {@link PROTOCOL_VERSION}: one versions the NDJSON envelope, * the other the graph document inside a changed frame. Keep this equal to - * `DumpSchemaVersion` in ttsc's `internal/graph/provenance.go` for the pinned - * producer release. + * `DumpSchemaVersion` in ttsc's `internal/graph/provenance.go` at canonical + * commit `2b724664e`. That schema is newer than the latest published ttsc at + * the time of this pin, so normal resolution fails closed and callers may + * provide that exact binary through `TTSC_GRAPH_BINARY` until it is released. */ export const DUMP_SCHEMA_VERSION = 5; + /** + * Body schemas this adapter can read without inventing missing facts. + * + * Schema 3 is the latest published producer's contract. It already carries + * the one-Program manifest, diagnostics, literals, and enum members, but + * predates checker-owned member relations and object-literal member facts. + * The adapter accepts it as an explicitly warned compatibility snapshot; + * schema 5 remains the complete canonical contract. + */ + export const SUPPORTED_DUMP_SCHEMA_VERSIONS: readonly number[] = [3, 5]; + /** * What the compiler did, as opposed to what the transport did. * diff --git a/packages/graph/src/provider/ttscgraph/adaptTtscGraphDump.ts b/packages/graph/src/provider/ttscgraph/adaptTtscGraphDump.ts index f34199f..34298b7 100644 --- a/packages/graph/src/provider/ttscgraph/adaptTtscGraphDump.ts +++ b/packages/graph/src/provider/ttscgraph/adaptTtscGraphDump.ts @@ -53,15 +53,26 @@ export function adaptTtscGraphDump( const dump = objectOf(input, "dump"); const rawProvenance = objectOf(dump.provenance, "dump.provenance"); const schemaVersion = rawProvenance.schemaVersion; - if (schemaVersion !== ITtscGraphSnapshot.DUMP_SCHEMA_VERSION) { + 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 v${String( - ITtscGraphSnapshot.DUMP_SCHEMA_VERSION, - )}. Install a matching ttsc (the binary resolves from the target project, or from TTSC_GRAPH_BINARY).`, + }, 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[] = []; + if (schemaVersion !== ITtscGraphSnapshot.DUMP_SCHEMA_VERSION) { + warnings.push( + `typescript: ttscgraph schema v${String(schemaVersion)} compatibility snapshot predates checker-owned member implements/overrides and object-literal member facts; those facts are absent. Use a schema v${String(ITtscGraphSnapshot.DUMP_SCHEMA_VERSION)} producer through TTSC_GRAPH_BINARY for the complete canonical graph.`, ); } const project = stringOf(dump.project, "dump.project"); @@ -77,6 +88,7 @@ export function adaptTtscGraphDump( const sourceFileById = new Map(); const nodes: ISamchonGraphNode[] = []; const files = new Set(); + const factFiles = new Set(); for (let index = 0; index < rawNodes.length; index++) { const raw = objectOf(rawNodes[index], `dump.nodes[${index}]`); @@ -89,6 +101,7 @@ export function adaptTtscGraphDump( validateGraphFile(file, `dump.nodes[${index}].file`, external); validateNodeId(id, file, kind); sourceFileById.set(id, file); + factFiles.add(file); if (!external && file !== "") files.add(file); if (kind === "module") { // `validateGraphFile` above already rejects an empty file for every node, @@ -123,12 +136,32 @@ export function adaptTtscGraphDump( ); } if (raw.literals !== undefined) { + if (kind !== "type" && kind !== "enum") { + throw new Error( + `ttscgraph: ${id}.literals is only valid on type or enum nodes`, + ); + } node.literals = stringArrayOf(raw.literals, `${id}.literals`); } if (raw.enumMembers !== undefined) { + if (kind !== "enum") { + throw new Error( + `ttscgraph: ${id}.enumMembers is only valid on enum nodes`, + ); + } node.enumMembers = enumMembersOf(raw.enumMembers, id); } if (raw.objectMembers !== undefined) { + if (schemaVersion !== ITtscGraphSnapshot.DUMP_SCHEMA_VERSION) { + throw new Error( + `ttscgraph: ${id}.objectMembers is not part of schema v${String(schemaVersion)}`, + ); + } + if (kind !== "variable") { + throw new Error( + `ttscgraph: ${id}.objectMembers is only valid on variable nodes`, + ); + } node.objectMembers = objectMembersOf(raw.objectMembers, id); } if (raw.decorators !== undefined) { @@ -136,6 +169,7 @@ export function adaptTtscGraphDump( } if (raw.evidence !== undefined) { node.evidence = evidenceOf(raw.evidence, file, `${id}.evidence`, true); + factFiles.add(node.evidence.file); } if (raw.implementation !== undefined) { node.implementation = evidenceOf( @@ -143,6 +177,7 @@ export function adaptTtscGraphDump( file, `${id}.implementation`, ); + factFiles.add(node.implementation.file); } nodes.push(node); } @@ -199,12 +234,19 @@ export function adaptTtscGraphDump( `dump.edges[${index}].evidence`, true, ); + factFiles.add(edge.evidence.file); } edges.push(edge); } - const warnings: string[] = []; const manifest = manifestOf(dump.provenance); + for (const file of [...factFiles].sort(compareText)) { + if (!manifest.has(file)) { + throw new Error( + `ttscgraph: dump declares facts for ${file}, which its own source manifest never loaded`, + ); + } + } const sources = new Map(); // The workspace file set still comes from the nodes, exactly as before: the // manifest is every file the *program* loaded, which includes the bundled @@ -212,15 +254,8 @@ export function adaptTtscGraphDump( // are not this project's sources. What the manifest changes is that the set // is now provable — each of these files must be one the same program loaded, // and it arrives with the digest of the text the checker actually read. - for (const file of [...files].sort((left, right) => - left.localeCompare(right), - )) { - const digest = manifest.get(file); - if (digest === undefined) { - throw new Error( - `ttscgraph: dump declares facts for ${file}, which its own source manifest never loaded`, - ); - } + for (const file of [...files].sort(compareText)) { + const digest = manifest.get(file)!; sources.set(path.resolve(expectedRoot, file), digest); } @@ -714,11 +749,10 @@ function validateGraphFile( allowBundled = false, ): void { if (allowBundled && isNormalizedBundledFile(file)) return; + if (isNormalizedAbsoluteFile(file)) return; if ( file === "" || file.includes("\\") || - path.posix.isAbsolute(file) || - path.win32.isAbsolute(file) || path.posix.normalize(file) !== file || file.split("/").some((segment) => segment === "" || segment === "." || segment === ".." @@ -730,6 +764,30 @@ function validateGraphFile( } } +/** + * Canonical ttsc keeps the producer's normalized absolute identity for a file + * loaded outside the selected project root (for example a sibling workspace + * package declaration). It is a valid fact identity but not a display-source + * capability: {@link SamchonGraphSourceReader} separately confines reads to + * the project root. + */ +function isNormalizedAbsoluteFile(file: string): boolean { + if (file.includes("\\")) return false; + if (/^[A-Za-z]:\//.test(file)) { + return path.posix.normalize(file) === file; + } + if (file.startsWith("//")) { + const segments = file.slice(2).split("/"); + return ( + segments.length >= 3 && + segments.every( + (segment) => segment !== "" && segment !== "." && segment !== "..", + ) + ); + } + return path.posix.isAbsolute(file) && path.posix.normalize(file) === file; +} + function isNormalizedBundledFile(file: string): boolean { if (!file.startsWith(BUNDLED_FILE_PREFIX)) return false; const relative = file.slice(BUNDLED_FILE_PREFIX.length); @@ -766,3 +824,7 @@ function samePath(left: string, right: string): boolean { ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase() : normalizedLeft === normalizedRight; } + +function compareText(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} diff --git a/packages/graph/src/structures/ISamchonGraphDetails.ts b/packages/graph/src/structures/ISamchonGraphDetails.ts index d0c6d69..1563f57 100644 --- a/packages/graph/src/structures/ISamchonGraphDetails.ts +++ b/packages/graph/src/structures/ISamchonGraphDetails.ts @@ -148,9 +148,10 @@ export namespace ISamchonGraphDetails { implementedBy?: IReference[]; /** - * String-literal values found in the declaration signature, such as a union - * or enum's value set. Returned whole rather than sampled: a symbol's value - * set is part of its identity, not a slice of its fan-out. + * The complete value set a type alias or enum admits, in __LANG__ source + * form (`"a"`, `1`, `true`, `null`) — the provider-resolved union members, + * not quoted tokens scraped from `signature`. Absent when the active index + * cannot prove an enumerable value set. */ literals?: string[]; diff --git a/packages/graph/src/structures/ISamchonGraphTrace.ts b/packages/graph/src/structures/ISamchonGraphTrace.ts index 92c541c..b7b6aa0 100644 --- a/packages/graph/src/structures/ISamchonGraphTrace.ts +++ b/packages/graph/src/structures/ISamchonGraphTrace.ts @@ -17,7 +17,7 @@ export interface ISamchonGraphTrace { /** Unique nodes reached (excluding the start), each with its depth and roles. */ reached: ISamchonGraphTrace.INode[]; - /** In an open trace, true when a bound omitted an eligible node or hop. */ + /** True when a walk or path-search bound omitted an eligible node or hop. */ truncated: boolean; /** The resolved `to` target, when a path was requested. */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 440f439..3d1c799 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,11 +14,11 @@ catalogs: specifier: ^12.0.0 version: 12.0.0 '@ttsc/lint': - specifier: ^0.19.2 - version: 0.19.2 + specifier: ^0.19.3 + version: 0.19.3 '@ttsc/unplugin': - specifier: ^0.19.2 - version: 0.19.2 + specifier: ^0.19.3 + version: 0.19.3 '@typia/interface': specifier: ^13.1.1 version: 13.1.1 @@ -26,8 +26,8 @@ catalogs: specifier: ^13.1.1 version: 13.1.1 ttsc: - specifier: ^0.19.2 - version: 0.19.2 + specifier: ^0.19.3 + version: 0.19.3 typia: specifier: ^13.1.1 version: 13.1.1 @@ -64,7 +64,7 @@ importers: devDependencies: '@ttsc/lint': specifier: catalog:samchon - version: 0.19.2 + version: 0.19.3 packages/graph: dependencies: @@ -104,7 +104,7 @@ importers: version: 1.43.4(three@0.184.0) ttsc: specifier: catalog:samchon - version: 0.19.2 + version: 0.19.3 typescript: specifier: catalog:typescript version: 7.0.2 @@ -119,7 +119,7 @@ importers: version: 6.1.3 ttsc: specifier: catalog:samchon - version: 0.19.2 + version: 0.19.3 typescript: specifier: catalog:typescript version: 7.0.2 @@ -142,7 +142,7 @@ importers: devDependencies: ttsc: specifier: catalog:samchon - version: 0.19.2 + version: 0.19.3 typescript: specifier: catalog:typescript version: 7.0.2 @@ -163,7 +163,7 @@ importers: version: link:../../packages/graph-sitter '@ttsc/unplugin': specifier: catalog:samchon - version: 0.19.2(ttsc@0.19.2) + version: 0.19.3(ttsc@0.19.3) '@types/node': specifier: catalog:utils version: 22.20.0 @@ -480,46 +480,46 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@ttsc/darwin-arm64@0.19.2': - resolution: {integrity: sha512-DYCm+wXpLtZHzlJmTFY+NsVNiqw3irmD4Cx1kyH34xgfudHsPn7ItWPlYoZTWWANH1xPRoH05CP+akDEsNX80Q==} + '@ttsc/darwin-arm64@0.19.3': + resolution: {integrity: sha512-eI+ssgCFHJc4fMS/hAqlqsjTI0L4ckFj3Y0mLEKYm/HJc7Vs4jh9MmMkulQoPUm2Mm0FobcwFsPY1YhC5W+aLA==} cpu: [arm64] os: [darwin] - '@ttsc/darwin-x64@0.19.2': - resolution: {integrity: sha512-0FoaF4YVhItnRWXf4oMJEwe1XQvOmaGH/M0PxO2HTiVI7cMCGTy555gOZ2svYgBmliqkLBVGoez29IWVlgZF5Q==} + '@ttsc/darwin-x64@0.19.3': + resolution: {integrity: sha512-1edaM/3K/SnmWl/COq7PdTNcCnrnGZ7kvji8gtzwrLIFn/+zdh7KC1FlZWobDGAAIc3+nLrzTHJAxlmtISp74g==} cpu: [x64] os: [darwin] - '@ttsc/lint@0.19.2': - resolution: {integrity: sha512-BUyzbC0lRq/CHrgWIK71S1z6K+dKpmNetLO/a4D56LTRpjyq4XubosoG36nSgIdqe+lYLi3Gh5A/F+4jnZi4ww==} + '@ttsc/lint@0.19.3': + resolution: {integrity: sha512-DU2pLRUJBYxn+qWgF+TWWsxGZ8/8h6WVUTA6lzIHvwCYlNw9eVbV9oTnQ2bG+OzCULmdD+s2m/A2f05a3YVoXg==} - '@ttsc/linux-arm64@0.19.2': - resolution: {integrity: sha512-/m7xTO5dJHzJpv9A7m4H5PBl8txFBGEMoM7k8L8Iod5ZrnUlF3mZSb5v6lUAZfJA5SkFBa6oHJ1fgafCmBInjg==} + '@ttsc/linux-arm64@0.19.3': + resolution: {integrity: sha512-R5PqAePCAb4RUkeDb+7JWUl2zJevc5Rb2KUVUJNSluqqMTc+V1+E1tgTeeH1cuq7/AeswGuk6CtwYVU5KigRog==} cpu: [arm64] os: [linux] - '@ttsc/linux-arm@0.19.2': - resolution: {integrity: sha512-fAuf9Kk+Jask/GAmIS54tCzYBj4MQY4LAjj+RFfBh3N5zGN3hXNXFKRa1i5ZDmRKQwTy8NYYaBXJDHYMvM2+Rw==} + '@ttsc/linux-arm@0.19.3': + resolution: {integrity: sha512-nyzFbvf3cwLbxKT/M3vyC+eei/OXYLXOmn2/NJMexJBs2DgXDgYdrAmja/gYUYCQimfO3kYn+3Jg9jNXUCKFkg==} cpu: [arm] os: [linux] - '@ttsc/linux-x64@0.19.2': - resolution: {integrity: sha512-9X8a1IgxgYaRbeY4v0uQk+B7116/xsqDR8SznoyXbLrWoJqynKLfe3XZWo5kbpTaXNQN6vlXFgVRlvis9+LOIA==} + '@ttsc/linux-x64@0.19.3': + resolution: {integrity: sha512-GTeU6iplbxQteChqwWmJMGMkDwwYfH/z3G+U9Mvp4v8cqI715sUJlyLI/3891BK0++aIHUC6wIrRw4Z3ZjcKXQ==} cpu: [x64] os: [linux] - '@ttsc/unplugin@0.19.2': - resolution: {integrity: sha512-027L/hHBleyFq9OJdglGmiL2dmLeA23nIKKC6TcwC/NlMa+ZVvTccXsL8FQyESR6u9ntGxCPr1N1heeMPNRcXg==} + '@ttsc/unplugin@0.19.3': + resolution: {integrity: sha512-ysb4VYui8BMQ0dMc5lOb6FmGv3EFnBKkGCIdbfMquiOivijQa/o3w3bhh9YCI1c9dlikhWDts6h4Ux3iKtGIoA==} peerDependencies: - ttsc: 0.19.2 + ttsc: 0.19.3 - '@ttsc/win32-arm64@0.19.2': - resolution: {integrity: sha512-SEkMh77Jx4RtThidhZi46GMoFePxoUuaybTo0Es2vKDMId6ntQTXrfgx7lhxEHs5HK6Q+O1Mzmq4rdY8YJsezg==} + '@ttsc/win32-arm64@0.19.3': + resolution: {integrity: sha512-zcNVrf62YIAO+I4lLkn4KNDcRBmGiwwDF+UAunehkLRhE/yVZDqu17NX2P5GFLuHbisPGULQgndqXR1aRpqd3A==} cpu: [arm64] os: [win32] - '@ttsc/win32-x64@0.19.2': - resolution: {integrity: sha512-6aimNDbf6ASRZTEiUDwOWajLRo0t678hWW3UpiKLTmUDWa7aI3x8LXvAYlQlChJvsgNgiNaZ8cXc9p3R+EfPrQ==} + '@ttsc/win32-x64@0.19.3': + resolution: {integrity: sha512-/QwzH76movDgF9kirjTApB9mKDcHW+vzA3ylLW6EPKfZkkihAJTfhoWtutmsE9p6GnXB8dssLUxyjcRiBVOQ7A==} cpu: [x64] os: [win32] @@ -1548,8 +1548,8 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - ttsc@0.19.2: - resolution: {integrity: sha512-By0MZNSTg75rURwyDgCdFnjRsQe5eA0w9jdfgmRUsfS5z0cRxvj292eb6bAPBBnsDgIh82HN+D1c9wHIR+/T3g==} + ttsc@0.19.3: + resolution: {integrity: sha512-0/NeYtEqo6vvzZ8YdKZ5EeNXlcfjGDYA5hY3YH+RuV7YtuIvFS6NvjEQbgX2w3qCE2Z7W68AQ8pMboa325mmzA==} engines: {node: '>=22.15.0'} hasBin: true @@ -1852,32 +1852,32 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@ttsc/darwin-arm64@0.19.2': + '@ttsc/darwin-arm64@0.19.3': optional: true - '@ttsc/darwin-x64@0.19.2': + '@ttsc/darwin-x64@0.19.3': optional: true - '@ttsc/lint@0.19.2': {} + '@ttsc/lint@0.19.3': {} - '@ttsc/linux-arm64@0.19.2': + '@ttsc/linux-arm64@0.19.3': optional: true - '@ttsc/linux-arm@0.19.2': + '@ttsc/linux-arm@0.19.3': optional: true - '@ttsc/linux-x64@0.19.2': + '@ttsc/linux-x64@0.19.3': optional: true - '@ttsc/unplugin@0.19.2(ttsc@0.19.2)': + '@ttsc/unplugin@0.19.3(ttsc@0.19.3)': dependencies: - ttsc: 0.19.2 + ttsc: 0.19.3 unplugin: 2.3.11 - '@ttsc/win32-arm64@0.19.2': + '@ttsc/win32-arm64@0.19.3': optional: true - '@ttsc/win32-x64@0.19.2': + '@ttsc/win32-x64@0.19.3': optional: true '@tweenjs/tween.js@23.1.3': {} @@ -2876,15 +2876,15 @@ snapshots: tslib@2.8.1: {} - ttsc@0.19.2: + ttsc@0.19.3: optionalDependencies: - '@ttsc/darwin-arm64': 0.19.2 - '@ttsc/darwin-x64': 0.19.2 - '@ttsc/linux-arm': 0.19.2 - '@ttsc/linux-arm64': 0.19.2 - '@ttsc/linux-x64': 0.19.2 - '@ttsc/win32-arm64': 0.19.2 - '@ttsc/win32-x64': 0.19.2 + '@ttsc/darwin-arm64': 0.19.3 + '@ttsc/darwin-x64': 0.19.3 + '@ttsc/linux-arm': 0.19.3 + '@ttsc/linux-arm64': 0.19.3 + '@ttsc/linux-x64': 0.19.3 + '@ttsc/win32-arm64': 0.19.3 + '@ttsc/win32-x64': 0.19.3 type-fest@0.21.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ebfad14..dc6810f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,7 +7,7 @@ catalogs: typescript: ^7.0.2 samchon: "@nestia/e2e": ^12.0.0 - ttsc: &ttsc ^0.19.2 + ttsc: &ttsc ^0.19.3 "@ttsc/lint": *ttsc "@ttsc/unplugin": *ttsc typia: &typia ^13.1.1 diff --git a/tests/test-graph/src/features/test_build_graph_preserves_its_consumed_source_snapshot.ts b/tests/test-graph/src/features/test_build_graph_preserves_its_consumed_source_snapshot.ts new file mode 100644 index 0000000..863b8f9 --- /dev/null +++ b/tests/test-graph/src/features/test_build_graph_preserves_its_consumed_source_snapshot.ts @@ -0,0 +1,105 @@ +import { TestValidator } from "@nestia/e2e"; +import { + SamchonGraphApplication, + SamchonGraphMemory, + buildGraph, + type ISamchonGraphDetails, + type ISamchonGraphDump, +} from "@samchon/graph"; +import fs from "node:fs"; +import path from "node:path"; + +import { GraphPaths } from "../internal/GraphPaths"; + +export const test_build_graph_preserves_its_consumed_source_snapshot = + async () => { + const root = GraphPaths.createTempDirectory("samchon-graph-snapshot-source-"); + const file = path.join(root, "target.ts"); + fs.writeFileSync( + file, + [ + "/** The original snapshot documentation. */", + "export function target(value: string): string {", + " return value;", + "}", + ].join("\n"), + ); + + const graph = await buildGraph({ + cwd: root, + mode: "static", + languages: ["typescript"], + }); + const target = graph.nodes.find( + (node) => node.kind === "function" && node.name === "target", + ); + TestValidator.predicate("the consumed declaration was indexed", target !== undefined); + + fs.writeFileSync( + file, + [ + "/** Later disk documentation that is not part of the graph. */", + "export function target(value: number): number {", + " return value;", + "}", + ].join("\n"), + ); + + const details = await inspect(graph, target!.id); + const selected = details.nodes[0]; + TestValidator.predicate( + "buildGraph keeps the exact signature bytes consumed by its index pass", + selected?.signature?.includes("value: string") === true && + selected.signature.includes("value: number") === false, + ); + TestValidator.predicate( + "buildGraph keeps the exact documentation bytes consumed by its index pass", + selected?.doc?.includes("original snapshot") === true && + selected.doc.includes("Later disk") === false, + ); + + const detachedDump: ISamchonGraphDump = { + project: root, + languages: ["typescript"], + indexer: "static", + nodes: [ + { + id: "target.ts#target:function", + kind: "function", + language: "typescript", + name: "target", + file: "target.ts", + external: false, + evidence: { startLine: 2, endLine: 4 }, + }, + ], + edges: [], + }; + const detached = await inspect( + SamchonGraphMemory.from(detachedDump), + "target.ts#target:function", + ); + TestValidator.equals( + "a provenance-free dump does not read a later mutable disk signature", + detached.nodes[0]?.signature, + undefined, + ); + TestValidator.equals( + "a provenance-free dump does not read later mutable disk documentation", + detached.nodes[0]?.doc, + undefined, + ); + }; + +async function inspect( + graph: SamchonGraphMemory, + handle: string, +): Promise { + const response = await new SamchonGraphApplication(graph).inspect_code_graph({ + question: "Inspect the selected snapshot declaration.", + draft: { reason: "The exact declaration shape is required.", type: "details" }, + review: "Details is the narrowest operation.", + request: { type: "details", handles: [handle] }, + }); + return response.result as ISamchonGraphDetails; +} diff --git a/tests/test-graph/src/features/test_coverage_edge_cases.ts b/tests/test-graph/src/features/test_coverage_edge_cases.ts index e2b017f..d6bf342 100644 --- a/tests/test-graph/src/features/test_coverage_edge_cases.ts +++ b/tests/test-graph/src/features/test_coverage_edge_cases.ts @@ -1,5 +1,5 @@ import { TestValidator } from "@nestia/e2e"; -import { SamchonGraphMemory, LANGUAGE_SPECS, SamchonGraphApplication, buildGraphDump, languageOf } from "@samchon/graph"; +import { SamchonGraphMemory, SamchonGraphSourceReader, LANGUAGE_SPECS, SamchonGraphApplication, buildGraphDump, languageOf } from "@samchon/graph"; import type { ISamchonGraphDump, ISamchonGraphNode } from "@samchon/graph"; import { execFileSync, spawnSync } from "node:child_process"; import fs from "node:fs"; @@ -647,7 +647,10 @@ export const test_coverage_edge_cases = async () => { { from: plainId, to: exactId, kind: "references", evidence: branchEvidence("src/b.ts", 2) }, ], }; - const branchGraph = SamchonGraphMemory.from(branchDump); + const branchGraph = SamchonGraphMemory.from( + branchDump, + SamchonGraphSourceReader.live(branchRoot), + ); TestValidator.equals("missing incoming edges default to empty", branchGraph.incoming("absent-node"), []); const branchApp = new SamchonGraphApplication(branchGraph); @@ -890,22 +893,40 @@ export const test_coverage_edge_cases = async () => { // Windows without Developer Mode may deny symlink creation. } const { signatureOf } = await importLib<{ - signatureOf: (project: string, node: { file: string; name: string; external: boolean; evidence?: { file: string; startLine: number; endLine?: number }; signature?: string }) => string | undefined; + signatureOf: (graph: SamchonGraphMemory, node: { file: string; name: string; external: boolean; evidence?: { file: string; startLine: number; endLine?: number }; signature?: string }) => string | undefined; }>("operations/signatureOf.js"); - TestValidator.equals("blank explicit signature falls back to source span", signatureOf(path.dirname(signatureFile), { + const signatureGraph = SamchonGraphMemory.from( + { + project: path.dirname(signatureFile), + languages: ["typescript"], + indexer: "static", + nodes: [], + edges: [], + }, + SamchonGraphSourceReader.live(path.dirname(signatureFile)), + ); + TestValidator.equals("blank explicit signature falls back to source span", signatureOf(signatureGraph, { external: false, file: "sample.ts", name: "sample", signature: " ", evidence: { file: "sample.ts", startLine: 1 }, })?.includes("export function sample("), true); - TestValidator.equals("explicit signature endLine caps source span", signatureOf(path.dirname(signatureFile), { + TestValidator.equals("explicit signature endLine caps source span", signatureOf(signatureGraph, { external: false, file: "sample.ts", name: "sample", evidence: { file: "sample.ts", startLine: 1, endLine: 3 }, })?.includes(") {"), true); - TestValidator.equals("missing signature source file returns undefined", signatureOf(path.dirname(signatureFile), { + const enumFile = path.join(path.dirname(signatureFile), "member.ts"); + fs.writeFileSync(enumFile, ["VIEW,", "EDIT,", "OTHER,"].join("\n")); + TestValidator.equals("a comma-ended declaration cannot absorb neighboring members", signatureOf(signatureGraph, { + external: false, + file: "member.ts", + name: "VIEW", + evidence: { file: "member.ts", startLine: 1, endLine: 1 }, + }), "VIEW,"); + TestValidator.equals("missing signature source file returns undefined", signatureOf(signatureGraph, { external: false, file: "missing.ts", name: "missing", @@ -913,13 +934,13 @@ export const test_coverage_edge_cases = async () => { }), undefined); const emptySignatureFile = path.join(path.dirname(signatureFile), "empty.ts"); fs.writeFileSync(emptySignatureFile, ""); - TestValidator.equals("empty signature source span returns undefined", signatureOf(path.dirname(signatureFile), { + TestValidator.equals("empty signature source span returns undefined", signatureOf(signatureGraph, { external: false, file: "empty.ts", name: "empty", evidence: { file: "empty.ts", startLine: 1 }, }), undefined); - TestValidator.equals("missing signature evidence returns undefined", signatureOf(path.dirname(signatureFile), { + TestValidator.equals("missing signature evidence returns undefined", signatureOf(signatureGraph, { external: false, file: "", name: "missing", diff --git a/tests/test-graph/src/features/test_graph_handles_resolve_signature_decorated_callables.ts b/tests/test-graph/src/features/test_graph_handles_resolve_signature_decorated_callables.ts index 5a20337..8f69030 100644 --- a/tests/test-graph/src/features/test_graph_handles_resolve_signature_decorated_callables.ts +++ b/tests/test-graph/src/features/test_graph_handles_resolve_signature_decorated_callables.ts @@ -33,8 +33,8 @@ export const test_graph_handles_resolve_signature_decorated_callables = "an owner-qualified callable base returns every overload", (await trace("Gson.toJson")).candidates?.map((node) => node.id), [ - "Gson.java#Gson.toJson(Object):method", "Gson.java#Gson.toJson(JsonElement):method", + "Gson.java#Gson.toJson(Object):method", ], ); TestValidator.equals( diff --git a/tests/test-graph/src/features/test_ported_operation_engines_cover_rich_branches.ts b/tests/test-graph/src/features/test_ported_operation_engines_cover_rich_branches.ts index 791c4a9..9f38994 100644 --- a/tests/test-graph/src/features/test_ported_operation_engines_cover_rich_branches.ts +++ b/tests/test-graph/src/features/test_ported_operation_engines_cover_rich_branches.ts @@ -20,8 +20,8 @@ const call = ( request, }); -// A real source tree so runDetails.objectLiteralMembers can read the object -// literal back from disk, plus nodes named for the tour damping buckets +// A source-shaped fixture whose producer-owned object member facts match its +// snapshot, plus nodes named for the tour damping buckets // (error / config / serialization) and an internal/ path for the public-API // noise filter. const createRichFixture = () => { @@ -70,7 +70,12 @@ const createRichFixture = () => { }); const nodes = [ - node(`${richFile}#settings:variable`, "variable", "settings", richFile, 1, 7), + node(`${richFile}#settings:variable`, "variable", "settings", richFile, 1, 7, { + objectMembers: [ + { name: "host", kind: "property", line: 2, signature: 'host: "localhost"' }, + { name: "connect", kind: "method", line: 4, signature: "connect()" }, + ], + }), node(`${richFile}#ErrorReporter:class`, "class", "ErrorReporter", richFile, 9, 9), node(`${richFile}#connectionConfig:variable`, "variable", "connectionConfig", richFile, 11, 11), node(`${richFile}#serializeState:function`, "function", "serializeState", richFile, 13, 13), @@ -112,8 +117,7 @@ export const test_ported_operation_engines_cover_rich_branches = async () => { const { dump } = createRichFixture(); const app = new SamchonGraphApplication(SamchonGraphMemory.from(dump)); - // runDetails.objectLiteralMembers: the `settings` variable's object literal is - // parsed from disk into a property (host) and a method (connect). + // The `settings` variable's snapshot facts expose a property and a method. const details = (await call(app, { type: "details", handles: ["settings"] })).result; const settingsNode = details.nodes.find((node) => node.name === "settings"); TestValidator.predicate("object-literal property parsed", settingsNode?.members?.some((m) => m.name === "host" && m.kind === "property") === true); diff --git a/tests/test-graph/src/features/test_resident_bulk_provider_polls_generations_without_reprocessing_strict_facts.ts b/tests/test-graph/src/features/test_resident_bulk_provider_polls_generations_without_reprocessing_strict_facts.ts index 92da607..cce834f 100644 --- a/tests/test-graph/src/features/test_resident_bulk_provider_polls_generations_without_reprocessing_strict_facts.ts +++ b/tests/test-graph/src/features/test_resident_bulk_provider_polls_generations_without_reprocessing_strict_facts.ts @@ -150,7 +150,7 @@ function snapshot( provenance: { schemaVersion: 5, tool: "ttscgraph", - toolVersion: "0.19.2", + toolVersion: "0.19.3-21-g2b724664e", compilerVersion: "5.9.0", protocolVersion: 1, universe: createHash("sha256").update("universe").digest("hex"), diff --git a/tests/test-graph/src/features/test_resident_close_reaches_a_stalled_bulk_refresh.ts b/tests/test-graph/src/features/test_resident_close_reaches_a_stalled_bulk_refresh.ts new file mode 100644 index 0000000..2ed7a33 --- /dev/null +++ b/tests/test-graph/src/features/test_resident_close_reaches_a_stalled_bulk_refresh.ts @@ -0,0 +1,99 @@ +import { TestValidator } from "@nestia/e2e"; +import fs from "node:fs"; + +import { createResidentGraphSource } from "../../../../packages/graph/src/indexer/createResidentGraphSource"; +import type { IIndexerResult } from "../../../../packages/graph/src/indexer/IIndexerResult"; +import type { IBulkGraphSession } from "../../../../packages/graph/src/provider/IBulkGraphSession"; +import { GraphPaths } from "../internal/GraphPaths"; + +export const test_resident_close_reaches_a_stalled_bulk_refresh = async () => { + const root = GraphPaths.createTempDirectory("samchon-graph-stalled-bulk-"); + fs.writeFileSync(`${root}/a.ts`, "export const answer = 1;\n"); + + const snapshot: IBulkGraphSession.ISnapshot = { + language: "typescript", + nodes: [], + edges: [], + diagnostics: [], + sources: new Map(), + provenance: { + schemaVersion: 5, + tool: "fake-ttscgraph", + toolVersion: "test", + compilerVersion: "test", + protocolVersion: 1, + universe: "test", + capabilities: [], + }, + warnings: [], + }; + let closeCalls = 0; + let announceRefresh!: () => void; + const refreshStarted = new Promise((resolve) => { + announceRefresh = resolve; + }); + let rejectRefresh: ((error: Error) => void) | undefined; + const session: IBulkGraphSession = { + kind: "bulk", + language: "typescript", + root, + generation: 1, + current: snapshot, + refresh(options) { + announceRefresh(); + return new Promise((_, reject) => { + rejectRefresh = reject; + options?.signal?.addEventListener( + "abort", + () => reject(new Error("synthetic refresh aborted")), + { once: true }, + ); + }); + }, + async close() { + closeCalls += 1; + rejectRefresh?.(new Error("synthetic session closed")); + }, + }; + const result: IIndexerResult = { + dump: { + project: root, + languages: ["typescript"], + indexer: "lsp", + nodes: [], + edges: [], + }, + warnings: [], + sessions: new Map([["typescript", session]]), + sources: new Map(), + }; + const resident = createResidentGraphSource( + { cwd: root, languages: ["typescript"] }, + { buildLspGraph: async () => result }, + ); + + await resident.load(); + const stalled = resident.load(); + await refreshStarted; + const closing = resident.close(); + const [loadResult, closeResult] = await Promise.allSettled([ + stalled, + closing, + ]); + + TestValidator.equals( + "resident close reaches its active bulk provider exactly once", + closeCalls, + 1, + ); + TestValidator.equals( + "the stalled load rejects after shutdown", + loadResult.status, + "rejected", + ); + TestValidator.equals( + "shutdown settles after interrupting the stalled refresh", + closeResult.status, + "fulfilled", + ); +}; diff --git a/tests/test-graph/src/features/test_resident_static_mode_never_starts_an_lsp_build.ts b/tests/test-graph/src/features/test_resident_static_mode_never_starts_an_lsp_build.ts new file mode 100644 index 0000000..64f51a0 --- /dev/null +++ b/tests/test-graph/src/features/test_resident_static_mode_never_starts_an_lsp_build.ts @@ -0,0 +1,30 @@ +import { TestValidator } from "@nestia/e2e"; +import fs from "node:fs"; +import path from "node:path"; + +import { createResidentGraphSource } from "../../../../packages/graph/src/indexer/createResidentGraphSource"; +import { GraphPaths } from "../internal/GraphPaths"; + +export const test_resident_static_mode_never_starts_an_lsp_build = async () => { + const root = GraphPaths.createTempDirectory("samchon-graph-resident-static-"); + fs.writeFileSync( + path.join(root, "answer.ts"), + "export const answer = 42;\n", + ); + let lspBuilds = 0; + const resident = createResidentGraphSource( + { cwd: root, mode: "static", languages: ["typescript"] }, + { + buildLspGraph: async () => { + lspBuilds += 1; + throw new Error("static mode must not enter the LSP builder"); + }, + }, + ); + + const dump = await resident.load(); + await resident.close(); + + TestValidator.equals("resident static mode uses the static indexer", dump.indexer, "static"); + TestValidator.equals("resident static mode starts no LSP build", lspBuilds, 0); +}; 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 6e205c4..774eec0 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 @@ -1,6 +1,7 @@ import { TestValidator } from "@nestia/e2e"; import { RESULT_AUDIT, + RESULT_AUDIT_DETAILS, RESULT_AUDIT_ESCAPE, RESULT_AUDIT_SELECTION, } from "@samchon/graph"; @@ -74,13 +75,18 @@ export const test_result_audits_before_the_facts = async () => { type: "details", handles: ["Root.Service.run"], }); - for (const output of [trace, details, overview]) { + for (const output of [trace, overview]) { TestValidator.equals( `${output.result.type} reports exact graph structure for named handles`, output.audit, RESULT_AUDIT("static"), ); } + TestValidator.equals( + "details reports its identity and fan-out completeness separately", + details.audit, + RESULT_AUDIT_DETAILS("static"), + ); // The audit states its evidence before it instructs, and the instruction it // does give hands the stop rule to `next` rather than claiming it. diff --git a/tests/test-graph/src/features/test_static_indexes_inheritance_and_containment.ts b/tests/test-graph/src/features/test_static_indexes_inheritance_and_containment.ts index 2943641..2047e14 100644 --- a/tests/test-graph/src/features/test_static_indexes_inheritance_and_containment.ts +++ b/tests/test-graph/src/features/test_static_indexes_inheritance_and_containment.ts @@ -42,11 +42,12 @@ export const test_static_indexes_inheritance_and_containment = async () => { ), ); - // A subclass method with the same name as a supertype method overrides it. - TestValidator.predicate("override across files", has("overrides", "Service.run:method", "Base.run:method")); + // A syntax-only index cannot prove checker-owned member relationships from a + // shared name. Visibility, overloads, accessors, and incompatible signatures + // all admit negative twins, so only a semantic provider may emit them. TestValidator.predicate( - "non-matching subclass method does not override", - dump.edges.every((edge) => edge.kind !== "overrides" || !edge.from.includes("Service.extra")), + "static same-name members are not promoted to overrides", + dump.edges.every((edge) => edge.kind !== "overrides"), ); // A decorator directly above a declaration links to the decorator symbol. diff --git a/tests/test-graph/src/features/test_trace_reports_only_actual_omissions.ts b/tests/test-graph/src/features/test_trace_reports_only_actual_omissions.ts index 74e6b82..6887961 100644 --- a/tests/test-graph/src/features/test_trace_reports_only_actual_omissions.ts +++ b/tests/test-graph/src/features/test_trace_reports_only_actual_omissions.ts @@ -42,11 +42,81 @@ export const test_trace_reports_only_actual_omissions = async () => { (omitted.result as ISamchonGraphTrace).truncated, true, ); + + const boundedPath = await inspect({ + type: "trace", + from: CHAIN[0]!, + to: CHAIN[13]!, + maxDepth: 12, + }); + TestValidator.equals( + "a path beyond maxDepth is incomplete rather than nonexistent", + (boundedPath.result as ISamchonGraphTrace).truncated, + true, + ); + TestValidator.equals( + "an incomplete bounded path asks for another inspection", + boundedPath.next.action, + "inspect", + ); + + const directDispatch = await inspect({ + type: "trace", + from: DISPATCH_BASE, + to: DISPATCH_IMPLEMENTATIONS[12]!, + }); + TestValidator.equals( + "an explicit dispatch target remains reachable through a suppressed hub", + (directDispatch.result as ISamchonGraphTrace).path?.map((entry) => entry.id), + [DISPATCH_BASE, DISPATCH_IMPLEMENTATIONS[12]!], + ); + + const boundedDispatch = await inspect({ + type: "trace", + from: DISPATCH_BASE, + to: OUTSIDE, + }); + TestValidator.equals( + "eligible dispatch-hub omissions make an unresolved path incomplete", + (boundedDispatch.result as ISamchonGraphTrace).truncated, + true, + ); + + const externalDispatch = await inspect({ + type: "trace", + from: EXTERNAL_DISPATCH_BASE, + to: OUTSIDE, + }); + TestValidator.equals( + "excluded external dispatch endpoints do not claim truncation", + (externalDispatch.result as ISamchonGraphTrace).truncated, + false, + ); + TestValidator.equals( + "a fully searched disconnected path remains outside", + externalDispatch.next.action, + "outside", + ); }; const ROOT = "src/a.ts#root:function"; const A = "src/a.ts#a:function"; const LEAF = "src/a.ts#leaf:function"; +const CHAIN = Array.from( + { length: 14 }, + (_, index) => `src/chain.ts#step${String(index)}:function`, +); +const DISPATCH_BASE = "src/dispatch.ts#Base.run:method"; +const DISPATCH_IMPLEMENTATIONS = Array.from( + { length: 13 }, + (_, index) => `src/dispatch.ts#Impl${String(index)}.run:method`, +); +const EXTERNAL_DISPATCH_BASE = "src/dispatch.ts#ExternalBase.run:method"; +const EXTERNAL_IMPLEMENTATIONS = Array.from( + { length: 12 }, + (_, index) => `vendor/dispatch.ts#External${String(index)}.run:method`, +); +const OUTSIDE = "src/outside.ts#outside:function"; const dump = (): ISamchonGraphDump => ({ project: "/trace-truth", @@ -56,18 +126,48 @@ const dump = (): ISamchonGraphDump => ({ node(ROOT, "root"), node(A, "a"), node(LEAF, "leaf"), + ...CHAIN.map((id, index) => node(id, `step${String(index)}`)), + node(DISPATCH_BASE, "Base.run", "method"), + ...DISPATCH_IMPLEMENTATIONS.flatMap((id, index) => [ + node(id, `Impl${String(index)}.run`, "method"), + node(`${id}.body`, `body${String(index)}`), + ]), + node(EXTERNAL_DISPATCH_BASE, "ExternalBase.run", "method"), + ...EXTERNAL_IMPLEMENTATIONS.flatMap((id, index) => [ + node(id, `External${String(index)}.run`, "method", true), + node(`${id}.body`, `externalBody${String(index)}`, "function", true), + ]), + node(OUTSIDE, "outside"), ], edges: [ { from: ROOT, to: A, kind: "calls" }, { from: A, to: LEAF, kind: "calls" }, + ...CHAIN.slice(0, -1).map((id, index) => ({ + from: id, + to: CHAIN[index + 1]!, + kind: "calls" as const, + })), + ...DISPATCH_IMPLEMENTATIONS.flatMap((id) => [ + { from: id, to: DISPATCH_BASE, kind: "implements" as const }, + { from: id, to: `${id}.body`, kind: "calls" as const }, + ]), + ...EXTERNAL_IMPLEMENTATIONS.flatMap((id) => [ + { from: id, to: EXTERNAL_DISPATCH_BASE, kind: "implements" as const }, + { from: id, to: `${id}.body`, kind: "calls" as const }, + ]), ], }); -const node = (id: string, name: string): ISamchonGraphDump.INode => ({ +const node = ( + id: string, + name: string, + kind: ISamchonGraphDump.INode["kind"] = "function", + external = false, +): ISamchonGraphDump.INode => ({ id, - kind: "function", + kind, language: "typescript", name, - file: "src/a.ts", - external: false, + file: id.slice(0, id.indexOf("#")), + external, }); 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 c442273..6f70660 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 @@ -1,5 +1,6 @@ import { TestValidator } from "@nestia/e2e"; import { createHash } from "node:crypto"; +import path from "node:path"; // `adaptTtscGraphDump` is internal to the package, so it is reached by path // rather than through the public barrel. @@ -17,21 +18,23 @@ const sha = (text: string): string => // manifest names the one workspace file its nodes name (`src/a.ts`). That keeps // the proof boundary satisfied so each malformed case can reach and fail at the // node, edge, or span it targets. -const provenance = () => ({ +const provenance = (files: readonly string[] = ["src/a.ts"]) => ({ schemaVersion: 5, capabilities: ["universe", "sourceDigests", "diskDigests", "diagnostics"], - producer: { tool: "ttscgraph", version: "0.19.2", typescript: "5.9.0" }, + producer: { + tool: "ttscgraph", + version: "0.19.3-21-g2b724664e", + typescript: "5.9.0", + }, universe: { configs: [{ file: "tsconfig.json", digest: sha("tsconfig.json") }], - roots: [{ config: "tsconfig.json", file: "src/a.ts" }], + roots: files.map((file) => ({ config: "tsconfig.json", file })), }, - sources: [ - { - file: "src/a.ts", - checkerDigest: sha("src/a.ts:checker"), - diskDigest: sha("src/a.ts:disk"), - }, - ], + sources: files.map((file) => ({ + file, + checkerDigest: sha(`${file}:checker`), + diskDigest: sha(`${file}:disk`), + })), }); /** @@ -66,6 +69,44 @@ export const test_ttscgraph_dump_adapter_rejects_malformed_facts = async () => { "a well-formed dump adapts cleanly", adaptTtscGraphDump(good(), project).nodes.length === 2, ); + const compatible = adaptTtscGraphDump( + mutate((d) => (d.provenance.schemaVersion = 3)), + project, + ); + TestValidator.predicate( + "published schema 3 is accepted with its missing canonical facts stated", + compatible.warnings.some( + (warning) => + warning.includes("schema v3 compatibility snapshot") && + warning.includes("object-literal member facts"), + ), + ); + const crossRootFile = path + .resolve(project, "..", "shared", "index.d.ts") + .replace(/\\/g, "/"); + const crossRoot = good(); + crossRoot.provenance.universe.roots.push({ + config: "tsconfig.json", + file: crossRootFile, + }); + crossRoot.provenance.sources.push({ + file: crossRootFile, + checkerDigest: sha(`${crossRootFile}:checker`), + diskDigest: sha(`${crossRootFile}:disk`), + }); + crossRoot.nodes.push({ + id: `${crossRootFile}#Shared:interface`, + kind: "interface", + name: "Shared", + file: crossRootFile, + external: false, + }); + TestValidator.predicate( + "a compiler-loaded sibling workspace file keeps its canonical absolute identity", + adaptTtscGraphDump(crossRoot, project).nodes.some( + (node) => node.file === crossRootFile, + ), + ); // Identity and project scope. rejects(() => adaptTtscGraphDump("not-an-object", project), "a non-object dump"); @@ -84,6 +125,14 @@ export const test_ttscgraph_dump_adapter_rejects_malformed_facts = async () => { ), "a dump above the pinned schema", ); + rejects( + () => + adaptTtscGraphDump( + mutate((d) => (d.provenance.schemaVersion = 1)), + project, + ), + "a legacy dump below the one-Program provenance schema", + ); rejects( () => adaptTtscGraphDump( @@ -149,6 +198,36 @@ export const test_ttscgraph_dump_adapter_rejects_malformed_facts = async () => { // Evidence spans and decorator literals. rejects(() => adaptTtscGraphDump(mutate((d) => ((d.nodes[1] as { evidence: unknown }).evidence = { startLine: 0 })), project), "a non-positive evidence line"); rejects(() => adaptTtscGraphDump(mutate((d) => ((d.nodes[1] as { evidence: unknown }).evidence = { startLine: 1, endCol: 5 })), project), "an evidence endCol without an endLine"); + rejects( + () => + adaptTtscGraphDump( + mutate( + (d) => + ((d.nodes[1] as { implementation: unknown }).implementation = { + file: "src/unloaded.ts", + startLine: 1, + }), + ), + project, + ), + "an implementation span whose file is absent from the manifest", + ); + rejects( + () => + adaptTtscGraphDump( + mutate((d) => + d.nodes.push({ + id: "vendor/ghost.d.ts#Ghost:interface", + kind: "interface", + name: "Ghost", + file: "vendor/ghost.d.ts", + external: true, + }), + ), + project, + ), + "an external fact whose file is absent from the manifest", + ); rejects( () => adaptTtscGraphDump( @@ -163,7 +242,7 @@ export const test_ttscgraph_dump_adapter_rejects_malformed_facts = async () => { const rich = adaptTtscGraphDump( { project, - provenance: provenance(), + provenance: provenance(["src/a.ts", "vendor/dep.ts"]), diagnostics: [], nodes: [ { id: "src/a.ts#src/a.ts:module", kind: "module", name: "src/a.ts", file: "src/a.ts", external: false }, @@ -175,11 +254,36 @@ export const test_ttscgraph_dump_adapter_rejects_malformed_facts = async () => { external: false, qualifiedName: "pkg.foo", modifiers: ["export", "async"], + decorators: [{ name: "Route", arguments: [{ literal: "path" }, {}] }], + evidence: { startLine: 1, startCol: 1, endLine: 1, endCol: 5 }, + implementation: { startLine: 2 }, + }, + { + id: "src/a.ts#Status:type", + kind: "type", + name: "Status", + file: "src/a.ts", + external: false, literals: ['"ready"', '"done"'], + }, + { + id: "src/a.ts#Phase:enum", + kind: "enum", + name: "Phase", + file: "src/a.ts", + external: false, + literals: ['"ready"'], enumMembers: [ { name: "Ready", value: '"ready"' }, { name: "Computed" }, ], + }, + { + id: "src/a.ts#options:variable", + kind: "variable", + name: "options", + file: "src/a.ts", + external: false, objectMembers: [ { name: "execute", @@ -188,9 +292,6 @@ export const test_ttscgraph_dump_adapter_rejects_malformed_facts = async () => { signature: "execute(): void", }, ], - decorators: [{ name: "Route", arguments: [{ literal: "path" }, {}] }], - evidence: { startLine: 1, startCol: 1, endLine: 1, endCol: 5 }, - implementation: { startLine: 2 }, }, { id: "src/a.ts#bar:function", kind: "function", name: "bar", file: "src/a.ts", external: false }, { id: "vendor/dep.ts#Dep:interface", kind: "interface", name: "Dep", file: "vendor/dep.ts", external: true }, @@ -203,16 +304,19 @@ export const test_ttscgraph_dump_adapter_rejects_malformed_facts = async () => { project, ); const foo = rich.nodes.find((node) => node.id === "src/a.ts#foo:function"); + const status = rich.nodes.find((node) => node.id === "src/a.ts#Status:type"); + const phase = rich.nodes.find((node) => node.id === "src/a.ts#Phase:enum"); + const options = rich.nodes.find((node) => node.id === "src/a.ts#options:variable"); TestValidator.equals("a qualified name is preserved", foo?.qualifiedName, "pkg.foo"); TestValidator.equals("an implementation span is preserved", foo?.implementation?.startLine, 2); TestValidator.equals( "compiler-resolved literal values are preserved", - foo?.literals, + status?.literals, ['"ready"', '"done"'], ); TestValidator.equals( "compiler-owned enum members are preserved", - foo?.enumMembers, + phase?.enumMembers, [ { name: "Ready", value: '"ready"' }, { name: "Computed" }, @@ -220,7 +324,7 @@ export const test_ttscgraph_dump_adapter_rejects_malformed_facts = async () => { ); TestValidator.equals( "compiler-owned object members are preserved", - foo?.objectMembers, + options?.objectMembers, [ { name: "execute", @@ -248,39 +352,72 @@ export const test_ttscgraph_dump_adapter_rejects_malformed_facts = async () => { rejects( () => adaptTtscGraphDump( - mutate( - (d) => - ((d.nodes[1] as { literals?: unknown }).literals = ["ok", 1]), - ), + mutate((d) => { + const node = d.nodes[1] as { id: string; kind: string; literals?: unknown }; + node.id = "src/a.ts#foo:type"; + node.kind = "type"; + node.literals = ["ok", 1]; + (d.edges[0] as { to: string }).to = node.id; + }), project, ), "a non-string literal value", ); + rejects( + () => + adaptTtscGraphDump( + mutate((d) => { + const node = d.nodes[1] as { id: string; kind: string; enumMembers?: unknown }; + node.id = "src/a.ts#foo:enum"; + node.kind = "enum"; + node.enumMembers = [{ name: "Ready", value: false }]; + (d.edges[0] as { to: string }).to = node.id; + }), + project, + ), + "a non-string enum member value", + ); + rejects( + () => + adaptTtscGraphDump( + mutate((d) => { + const node = d.nodes[1] as { id: string; kind: string; objectMembers?: unknown }; + node.id = "src/a.ts#foo:variable"; + node.kind = "variable"; + node.objectMembers = [{ name: "dynamic", kind: "computed" }]; + (d.edges[0] as { to: string }).to = node.id; + }), + project, + ), + "an unsupported object member kind", + ); + rejects( + () => + adaptTtscGraphDump( + mutate((d) => ((d.nodes[1] as { literals?: unknown }).literals = [])), + project, + ), + "literal facts on a non-type node", + ); rejects( () => adaptTtscGraphDump( mutate( - (d) => - ((d.nodes[1] as { enumMembers?: unknown }).enumMembers = [ - { name: "Ready", value: false }, - ]), + (d) => ((d.nodes[1] as { enumMembers?: unknown }).enumMembers = []), ), project, ), - "a non-string enum member value", + "enum-member facts on a non-enum node", ); rejects( () => adaptTtscGraphDump( mutate( - (d) => - ((d.nodes[1] as { objectMembers?: unknown }).objectMembers = [ - { name: "dynamic", kind: "computed" }, - ]), + (d) => ((d.nodes[1] as { objectMembers?: unknown }).objectMembers = []), ), project, ), - "an unsupported object member kind", + "object-member facts on a non-variable node", ); }; diff --git a/tests/test-graph/src/features/test_ttscgraph_published_schema3_is_an_explicit_compatibility_snapshot.ts b/tests/test-graph/src/features/test_ttscgraph_published_schema3_is_an_explicit_compatibility_snapshot.ts new file mode 100644 index 0000000..caa1933 --- /dev/null +++ b/tests/test-graph/src/features/test_ttscgraph_published_schema3_is_an_explicit_compatibility_snapshot.ts @@ -0,0 +1,65 @@ +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_is_an_explicit_compatibility_snapshot = + 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";', + 'export enum Phase { Ready = "ready", Done = "done" }', + 'export const options = { host: "localhost", connect() {} };', + ].join("\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 schema 3 provider remains compiler-owned rather than falling back", + dump.warnings?.some((warning) => + warning.includes("schema v3 compatibility snapshot"), + ) === true && + dump.warnings.every( + (warning) => !warning.includes("bulk indexing failed"), + ), + ); + TestValidator.predicate( + "schema 3 retains its compiler-resolved literal facts", + dump.nodes.some( + (node) => + node.name === "Status" && + node.literals?.includes('"ready"') === true, + ), + ); + TestValidator.equals( + "schema 3 does not fabricate schema 5 object-member facts", + dump.nodes.find((node) => node.name === "options")?.objectMembers, + undefined, + ); + } 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_viewer_reduce_preserves_the_reference_contract.ts b/tests/test-graph/src/features/test_viewer_reduce_preserves_the_reference_contract.ts index b768811..846c768 100644 --- a/tests/test-graph/src/features/test_viewer_reduce_preserves_the_reference_contract.ts +++ b/tests/test-graph/src/features/test_viewer_reduce_preserves_the_reference_contract.ts @@ -145,6 +145,34 @@ export const test_viewer_reduce_preserves_the_reference_contract = () => { ["file.ts", "file.ts"], ); + const unc = reduce({ + nodes: [ + node("\\\\SERVER\\Share\\project\\src\\a.ts", "A", "class"), + node("//server/share/project/lib/b.ts", "B", "class"), + ], + edges: [ + edge( + "\\\\SERVER\\Share\\project\\src\\a.ts", + "A", + "class", + "//server/share/project/lib/b.ts", + "B", + "class", + "calls", + ), + ], + }); + TestValidator.equals( + "UNC roots are rerooted case-insensitively without losing subdirectories", + unc.nodes.map((entry) => entry.file), + ["src/a.ts", "lib/b.ts"], + ); + TestValidator.equals( + "UNC-rooted node identities are rewritten with their files", + unc.nodes.map((entry) => entry.id.slice(0, entry.id.indexOf("#"))), + ["src/a.ts", "lib/b.ts"], + ); + const hashless = reduce({ nodes: [ { id: "plain-a", name: "A", kind: "class", file: "a.ts" }, diff --git a/tests/test-graph/src/internal/ContractParity.ts b/tests/test-graph/src/internal/ContractParity.ts index 5bc1b62..8e6e2ad 100644 --- a/tests/test-graph/src/internal/ContractParity.ts +++ b/tests/test-graph/src/internal/ContractParity.ts @@ -523,10 +523,19 @@ export namespace ContractParity { Details: [ { reason: - "The generic index extracts bounded source-form literals rather than claiming the compiler-resolved complete union that the TypeScript-only reference can provide.", + "The multi-language product names the active provider rather than claiming every lane has TypeScript-checker authority; unproved enumerable sets remain absent.", layer: "prose", from: "/** The complete value set a type alias or enum admits, in __LANG__ source form (`\"a\"`, `1`, `true`, `null`) — the checker's resolved union members, not the quoted tokens that happened to fit in `signature`. Absent when the type has no enumerable value set. A `signature` is capped at the declaration head, so for a union or enum written across several lines this is the field that carries the members. */", - to: "/** String-literal values found in the declaration signature, such as a union or enum's value set. Returned whole rather than sampled: a symbol's value set is part of its identity, not a slice of its fan-out. */", + to: "/** The complete value set a type alias or enum admits, in __LANG__ source form (`\"a\"`, `1`, `true`, `null`) — the provider-resolved union members, not quoted tokens scraped from `signature`. Absent when the active index cannot prove an enumerable value set. */", + }, + ], + Trace: [ + { + reason: + "Path mode now reports bounded depth and dispatch-hub omissions instead of falsely proving that no connection exists, so truncation applies to both trace forms.", + layer: "prose", + from: "/** In an open trace, true when a bound omitted an eligible node or hop. */", + to: "/** True when a walk or path-search bound omitted an eligible node or hop. */", }, ], Dump: [ From 8dcef3af52d8b99e38991b1ea2a000d602ec1691 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Sun, 19 Jul 2026 16:58:25 +0900 Subject: [PATCH 04/16] Keep source-proof APIs inside repository boundaries Snapshot-carrying build helpers are useful beyond their original wrappers, but their placement broke the one-export-per-source convention and several document tests accidentally depended on the old live-disk default. Give each helper its canonical module and make document-reading tests choose either the produced snapshot or an explicit live reader. Constraint: Detached dumps must remain fail-closed for source-derived facts Rejected: Restore live disk reads as SamchonGraphMemory.from default | would reintroduce time-of-check/time-of-use drift Confidence: high Scope-risk: narrow Directive: Keep build result helpers in matching source files and require explicit provenance at memory construction Tested: @samchon/graph build; @samchon/graph-test build; focused export and engine boundary regressions Not-tested: Full cross-platform suite remains delegated to CI --- packages/graph/src/indexer/buildGraph.ts | 2 +- packages/graph/src/indexer/buildGraphDump.ts | 26 +------------ .../graph/src/indexer/buildGraphResult.ts | 28 +++++++++++++ .../graph/src/indexer/buildStaticGraph.ts | 38 +----------------- .../src/indexer/buildStaticGraphResult.ts | 39 +++++++++++++++++++ .../src/indexer/createResidentGraphSource.ts | 2 +- packages/graph/src/indexer/index.ts | 2 + ...s_answer_what_the_graph_only_half_holds.ts | 6 ++- ...e_engines_hold_their_shape_at_the_edges.ts | 5 +-- ..._engines_cover_their_remaining_branches.ts | 5 +-- 10 files changed, 82 insertions(+), 71 deletions(-) create mode 100644 packages/graph/src/indexer/buildGraphResult.ts create mode 100644 packages/graph/src/indexer/buildStaticGraphResult.ts diff --git a/packages/graph/src/indexer/buildGraph.ts b/packages/graph/src/indexer/buildGraph.ts index 7932382..dbabd80 100644 --- a/packages/graph/src/indexer/buildGraph.ts +++ b/packages/graph/src/indexer/buildGraph.ts @@ -1,6 +1,6 @@ import { SamchonGraphMemory } from "../SamchonGraphMemory"; import { SamchonGraphSourceReader } from "../SamchonGraphSourceReader"; -import { buildGraphResult } from "./buildGraphDump"; +import { buildGraphResult } from "./buildGraphResult"; import { IBuildGraphOptions } from "./IBuildGraphOptions"; export async function buildGraph( diff --git a/packages/graph/src/indexer/buildGraphDump.ts b/packages/graph/src/indexer/buildGraphDump.ts index b480eb1..f2d00ef 100644 --- a/packages/graph/src/indexer/buildGraphDump.ts +++ b/packages/graph/src/indexer/buildGraphDump.ts @@ -1,33 +1,9 @@ -import path from "node:path"; -import typia from "typia"; import { ISamchonGraphDump } from "../structures"; import { IBuildGraphOptions } from "./IBuildGraphOptions"; -import { buildLspGraph } from "./buildLspGraph"; -import { buildStaticGraphResult } from "./buildStaticGraph"; -import { IIndexerResult } from "./IIndexerResult"; +import { buildGraphResult } from "./buildGraphResult"; export async function buildGraphDump( options: IBuildGraphOptions = {}, ): Promise { return (await buildGraphResult(options)).dump; } - -/** Internal one-shot result that keeps source evidence beside its dump. */ -export async function buildGraphResult( - options: IBuildGraphOptions = {}, -): Promise { - const normalized: IBuildGraphOptions = { - ...options, - cwd: path.resolve(options.cwd ?? process.cwd()), - mode: options.mode ?? "auto", - }; - const result = - normalized.mode === "static" - ? buildStaticGraphResult(normalized) - : await buildLspGraph(normalized); - return { ...result, dump: validateDump(result.dump) }; -} - -function validateDump(dump: ISamchonGraphDump): ISamchonGraphDump { - return typia.assert(dump); -} diff --git a/packages/graph/src/indexer/buildGraphResult.ts b/packages/graph/src/indexer/buildGraphResult.ts new file mode 100644 index 0000000..f45347a --- /dev/null +++ b/packages/graph/src/indexer/buildGraphResult.ts @@ -0,0 +1,28 @@ +import path from "node:path"; +import typia from "typia"; + +import { ISamchonGraphDump } from "../structures"; +import { buildLspGraph } from "./buildLspGraph"; +import { buildStaticGraphResult } from "./buildStaticGraphResult"; +import { IBuildGraphOptions } from "./IBuildGraphOptions"; +import { IIndexerResult } from "./IIndexerResult"; + +/** Internal one-shot result that keeps source evidence beside its dump. */ +export async function buildGraphResult( + options: IBuildGraphOptions = {}, +): Promise { + const normalized: IBuildGraphOptions = { + ...options, + cwd: path.resolve(options.cwd ?? process.cwd()), + mode: options.mode ?? "auto", + }; + const result = + normalized.mode === "static" + ? buildStaticGraphResult(normalized) + : await buildLspGraph(normalized); + return { ...result, dump: validateDump(result.dump) }; +} + +function validateDump(dump: ISamchonGraphDump): ISamchonGraphDump { + return typia.assert(dump); +} diff --git a/packages/graph/src/indexer/buildStaticGraph.ts b/packages/graph/src/indexer/buildStaticGraph.ts index 23ec432..a06f7e7 100644 --- a/packages/graph/src/indexer/buildStaticGraph.ts +++ b/packages/graph/src/indexer/buildStaticGraph.ts @@ -1,13 +1,6 @@ import { ISamchonGraphDump } from "../structures"; -import { SamchonGraphSourceReader } from "../SamchonGraphSourceReader"; -import { dedupeEdges } from "./dedupeEdges"; -import { dedupeNodes } from "./dedupeNodes"; -import { finalizeGraph } from "./finalizeGraph"; +import { buildStaticGraphResult } from "./buildStaticGraphResult"; import { IBuildGraphOptions } from "./IBuildGraphOptions"; -import { IIndexerResult } from "./IIndexerResult"; -import { staticGraphParts } from "./staticGraphParts"; -import { wireEdges } from "./wireEdges"; -import { wireNodes } from "./wireNodes"; /** * The static graph as a dump: parse, derive the facts §4k asks of an indexer @@ -20,32 +13,3 @@ export function buildStaticGraph( ): ISamchonGraphDump { return buildStaticGraphResult(options).dump; } - -/** Build one static dump together with the exact source bytes it consumed. */ -export function buildStaticGraphResult( - options: IBuildGraphOptions = {}, -): IIndexerResult { - const parts = staticGraphParts(options); - const finalized = finalizeGraph( - parts.root, - [...parts.sources.keys()], - parts.nodes, - parts.edges, - ); - const dump: ISamchonGraphDump = { - project: parts.root, - languages: parts.languages, - indexer: "static", - nodes: wireNodes(dedupeNodes(finalized.nodes)), - edges: wireEdges(dedupeEdges(finalized.edges)), - warnings: parts.warnings, - }; - return { - dump, - warnings: parts.warnings, - sources: new Map(parts.sources), - source: new SamchonGraphSourceReader(parts.root, { - texts: parts.sources, - }), - }; -} diff --git a/packages/graph/src/indexer/buildStaticGraphResult.ts b/packages/graph/src/indexer/buildStaticGraphResult.ts new file mode 100644 index 0000000..5d7ff44 --- /dev/null +++ b/packages/graph/src/indexer/buildStaticGraphResult.ts @@ -0,0 +1,39 @@ +import { SamchonGraphSourceReader } from "../SamchonGraphSourceReader"; +import { ISamchonGraphDump } from "../structures"; +import { dedupeEdges } from "./dedupeEdges"; +import { dedupeNodes } from "./dedupeNodes"; +import { finalizeGraph } from "./finalizeGraph"; +import { IBuildGraphOptions } from "./IBuildGraphOptions"; +import { IIndexerResult } from "./IIndexerResult"; +import { staticGraphParts } from "./staticGraphParts"; +import { wireEdges } from "./wireEdges"; +import { wireNodes } from "./wireNodes"; + +/** Build one static dump together with the exact source bytes it consumed. */ +export function buildStaticGraphResult( + options: IBuildGraphOptions = {}, +): IIndexerResult { + const parts = staticGraphParts(options); + const finalized = finalizeGraph( + parts.root, + [...parts.sources.keys()], + parts.nodes, + parts.edges, + ); + const dump: ISamchonGraphDump = { + project: parts.root, + languages: parts.languages, + indexer: "static", + nodes: wireNodes(dedupeNodes(finalized.nodes)), + edges: wireEdges(dedupeEdges(finalized.edges)), + warnings: parts.warnings, + }; + return { + dump, + warnings: parts.warnings, + sources: new Map(parts.sources), + source: new SamchonGraphSourceReader(parts.root, { + texts: parts.sources, + }), + }; +} diff --git a/packages/graph/src/indexer/createResidentGraphSource.ts b/packages/graph/src/indexer/createResidentGraphSource.ts index bd16f80..1c7f20c 100644 --- a/packages/graph/src/indexer/createResidentGraphSource.ts +++ b/packages/graph/src/indexer/createResidentGraphSource.ts @@ -14,7 +14,7 @@ import { mergeGraphSlices } from "../provider/mergeGraphSlices"; import { readText, walkSourceFiles } from "../utils/fs"; import { allExtensions } from "./allExtensions"; import { buildLspGraph } from "./buildLspGraph"; -import { buildStaticGraphResult } from "./buildStaticGraph"; +import { buildStaticGraphResult } from "./buildStaticGraphResult"; import { staticGraphParts } from "./staticGraphParts"; import { discoverLanguages } from "./discoverLanguages"; import { IBuildGraphOptions } from "./IBuildGraphOptions"; diff --git a/packages/graph/src/indexer/index.ts b/packages/graph/src/indexer/index.ts index 1069191..ac29ece 100644 --- a/packages/graph/src/indexer/index.ts +++ b/packages/graph/src/indexer/index.ts @@ -2,8 +2,10 @@ export * from "./allExtensions"; export * from "./appendAll"; export * from "./buildGraph"; export * from "./buildGraphDump"; +export * from "./buildGraphResult"; export * from "./buildLspGraph"; export * from "./buildStaticGraph"; +export * from "./buildStaticGraphResult"; export * from "./createResidentGraphSource"; export * from "./decoratorsAbove"; export * from "./dedupeEdges"; diff --git a/tests/test-graph/src/features/test_the_engines_answer_what_the_graph_only_half_holds.ts b/tests/test-graph/src/features/test_the_engines_answer_what_the_graph_only_half_holds.ts index fe42081..e2fb93e 100644 --- a/tests/test-graph/src/features/test_the_engines_answer_what_the_graph_only_half_holds.ts +++ b/tests/test-graph/src/features/test_the_engines_answer_what_the_graph_only_half_holds.ts @@ -3,6 +3,7 @@ import { buildGraphDump, SamchonGraphApplication, SamchonGraphMemory, + SamchonGraphSourceReader, } from "@samchon/graph"; import type { ISamchonGraphDetails, @@ -256,7 +257,10 @@ const scenario_a_tour_over_a_graph_that_is_missing_its_evidence = async () => { exportsOf("src/order/create.ts", "src/order/create.ts#persist:function"), ]; const app = new SamchonGraphApplication( - SamchonGraphMemory.from(dumpOf(root, nodes, edges)), + SamchonGraphMemory.from( + dumpOf(root, nodes, edges), + SamchonGraphSourceReader.live(root), + ), ); const tour = ( await app.inspect_code_graph({ diff --git a/tests/test-graph/src/features/test_the_engines_hold_their_shape_at_the_edges.ts b/tests/test-graph/src/features/test_the_engines_hold_their_shape_at_the_edges.ts index 56cdb91..a01c743 100644 --- a/tests/test-graph/src/features/test_the_engines_hold_their_shape_at_the_edges.ts +++ b/tests/test-graph/src/features/test_the_engines_hold_their_shape_at_the_edges.ts @@ -1,5 +1,6 @@ import { TestValidator } from "@nestia/e2e"; import { + buildGraph, buildGraphDump, SamchonGraphApplication, SamchonGraphMemory, @@ -260,9 +261,7 @@ const scenario_a_doc_comment_at_every_boundary = async () => { "export function tooLong(): void {}", ]); const app = new SamchonGraphApplication( - SamchonGraphMemory.from( - await buildGraphDump({ cwd: root, mode: "static", languages: ["typescript"] }), - ), + await buildGraph({ cwd: root, mode: "static", languages: ["typescript"] }), ); const details = ( await app.inspect_code_graph({ diff --git a/tests/test-graph/src/features/test_the_ported_engines_cover_their_remaining_branches.ts b/tests/test-graph/src/features/test_the_ported_engines_cover_their_remaining_branches.ts index 290ad4e..be5ffc4 100644 --- a/tests/test-graph/src/features/test_the_ported_engines_cover_their_remaining_branches.ts +++ b/tests/test-graph/src/features/test_the_ported_engines_cover_their_remaining_branches.ts @@ -1,5 +1,6 @@ import { TestValidator } from "@nestia/e2e"; import { + buildGraph, buildGraphDump, SamchonGraphApplication, SamchonGraphMemory, @@ -194,9 +195,7 @@ const scenario_a_doc_comment_says_what_a_symbol_is_for = async () => { "export function undocumented(): void {}", ]); const app = new SamchonGraphApplication( - SamchonGraphMemory.from( - await buildGraphDump({ cwd: root, mode: "static", languages: ["typescript"] }), - ), + await buildGraph({ cwd: root, mode: "static", languages: ["typescript"] }), ); const details = ( await app.inspect_code_graph({ From 1ff7c61160d40fec56cd54d11502ad3e7c4816fc Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Sun, 19 Jul 2026 18:31:49 +0900 Subject: [PATCH 05/16] Keep snapshot evidence and resident process ownership fail closed Public source reads now require exact consumed text or a checker digest, so an unproved live disk read cannot counterfeit an audited snapshot. First-build failures close every accumulated bulk and LSP session, while the native client retains ownership of retired child generations through forced exit. Constraint: strict compiler facts may outlive the disk bytes that produced them Rejected: Freeze the first unproved disk read | it proves only read-time consistency, not snapshot identity Confidence: high Scope-risk: moderate Directive: Every session or child created before publication remains owned until returned or observed exiting Tested: graph/test builds; source, lifecycle, native-stall, fake-provider, and coverage regressions; full suite except one independently passing warmup-timeout flake Not-tested: POSIX SIGKILL branch locally; Linux/macOS CI owns it Related: #103 #107 #96 --- .../graph/src/SamchonGraphSourceReader.ts | 18 +- packages/graph/src/indexer/buildLspGraph.ts | 371 ++++++++++-------- .../src/provider/ttscgraph/TtscGraphClient.ts | 98 +++-- .../src/features/test_coverage_edge_cases.ts | 25 +- ...build_abort_closes_accumulated_sessions.ts | 203 ++++++++++ ..._reader_enforces_snapshot_identity_once.ts | 14 +- ...s_answer_what_the_graph_only_half_holds.ts | 9 +- ...aph_native_requests_recover_from_stalls.ts | 124 +++++- .../src/internal/fake-ttscgraph-server.cjs | 14 +- 9 files changed, 643 insertions(+), 233 deletions(-) create mode 100644 tests/test-graph/src/features/test_lsp_first_build_abort_closes_accumulated_sessions.ts diff --git a/packages/graph/src/SamchonGraphSourceReader.ts b/packages/graph/src/SamchonGraphSourceReader.ts index 3eed8d5..9eebb35 100644 --- a/packages/graph/src/SamchonGraphSourceReader.ts +++ b/packages/graph/src/SamchonGraphSourceReader.ts @@ -12,7 +12,6 @@ export class SamchonGraphSourceReader { private readonly project: string; private readonly texts: ReadonlyMap; private readonly checkerDigests: ReadonlyMap; - private readonly allowUnproven: boolean; private readonly read: ReadFile; private readonly cache = new Map(); @@ -22,19 +21,10 @@ export class SamchonGraphSourceReader { ) { this.project = path.resolve(project); this.texts = normalizeTexts(this.project, options.texts); - this.checkerDigests = normalizeDigests( - this.project, - options.digests, - ); - this.allowUnproven = options.allowUnproven === true; + this.checkerDigests = normalizeDigests(this.project, options.digests); this.read = options.read ?? ((file) => fs.readFileSync(file)); } - /** A compatibility reader that freezes the first in-project disk read. */ - public static live(project: string): SamchonGraphSourceReader { - return new SamchonGraphSourceReader(project, { allowUnproven: true }); - } - /** A fail-closed reader for dumps that carry no source provenance. */ public static none(project: string): SamchonGraphSourceReader { return new SamchonGraphSourceReader(project); @@ -62,7 +52,7 @@ export class SamchonGraphSourceReader { } const expected = this.checkerDigests.get(key); - if (expected === undefined && !this.allowUnproven) { + if (expected === undefined) { this.cache.set(key, undefined); return undefined; } @@ -73,7 +63,7 @@ export class SamchonGraphSourceReader { this.cache.set(key, undefined); return undefined; } - if (expected !== undefined && sha256(text) !== expected) { + if (sha256(text) !== expected) { this.cache.set(key, undefined); return undefined; } @@ -93,8 +83,6 @@ export namespace SamchonGraphSourceReader { texts?: ReadonlyMap; /** Compiler snapshot digests, keyed by any path form. */ digests?: ReadonlyMap; - /** Compatibility mode for direct in-memory API callers only. */ - allowUnproven?: boolean; /** Test seam for deterministic read/failure/cache coverage. */ read?: ReadFile; } diff --git a/packages/graph/src/indexer/buildLspGraph.ts b/packages/graph/src/indexer/buildLspGraph.ts index 3aa1544..227f071 100644 --- a/packages/graph/src/indexer/buildLspGraph.ts +++ b/packages/graph/src/indexer/buildLspGraph.ts @@ -13,6 +13,7 @@ import { GraphLanguage } from "../typings"; import { projectRelative, readText, walkSourceFiles } from "../utils/fs"; import { fileFromUri, fileUri, isSubPath } from "../utils/path"; import { IBulkGraphSession } from "../provider/IBulkGraphSession"; +import { isBulkGraphSession } from "../provider/isBulkGraphSession"; import { mergeGraphSlices } from "../provider/mergeGraphSlices"; import { TtscGraphClient } from "../provider/ttscgraph/TtscGraphClient"; import { resolveTtscGraphCommand } from "../provider/ttscgraph/resolveTtscGraphCommand"; @@ -35,14 +36,26 @@ import { staticGraphParts } from "./staticGraphParts"; import { wireEdges } from "./wireEdges"; import { wireNodes } from "./wireNodes"; -const DEFAULT_DEPENDENCIES = { +interface IBuildLspGraphDependencies { + resolveTtscGraphCommand: typeof resolveTtscGraphCommand; + collectTtscGraph: typeof collectTtscGraph; + collectLanguageGraph: typeof collectLanguageGraph; +} + +const DEFAULT_DEPENDENCIES: IBuildLspGraphDependencies = { resolveTtscGraphCommand, + collectTtscGraph, + collectLanguageGraph, }; export async function buildLspGraph( options: IBuildGraphOptions = {}, - dependencies: typeof DEFAULT_DEPENDENCIES = DEFAULT_DEPENDENCIES, + dependencies: Partial = {}, ): Promise { + const resolvedDependencies: IBuildLspGraphDependencies = { + ...DEFAULT_DEPENDENCIES, + ...dependencies, + }; const root = path.resolve(options.cwd ?? process.cwd()); const languages = options.languages ?? discoverLanguages(root, options); const nodes: ISamchonGraphNode[] = []; @@ -70,145 +83,165 @@ export async function buildLspGraph( digests: strictDigests, }); let lspNodeCount = 0; - // Computed once (not per-language) since cpp and c share the same clangd - // compilation database and root. - const compileCommandsDir = - languages.includes("cpp") || languages.includes("c") - ? ensureCompileCommands(root, options.cmakeCommand) - : undefined; - if (languages.includes("dart")) ensurePubDeps(root, options.pubCommand); + try { + // Computed once (not per-language) since cpp and c share the same clangd + // compilation database and root. + const compileCommandsDir = + languages.includes("cpp") || languages.includes("c") + ? ensureCompileCommands(root, options.cmakeCommand) + : undefined; + if (languages.includes("dart")) ensurePubDeps(root, options.pubCommand); - for (const language of languages) { - const files = walkSourceFiles(root, { - extensions: allExtensions([language]), - maxFiles: options.maxFiles, - }); - if (files.length === 0) continue; - if (language === "typescript") { - // The provider decides whether it can honour these options; this loop only - // reports what it decided. The condition used to live here, inline and - // without an `else`, which is how the experiment's caps came to disable - // the compiler-owned lane on every run without a word of explanation. - const refusal = ttscGraphStrictRefusal(options); - if (refusal !== undefined) { - warnings.push(refusal); - } else { - const resolved = dependencies.resolveTtscGraphCommand(root); - if (resolved !== undefined) { - try { - const { result, session } = await collectTtscGraph( - root, - resolved.command, - resolved.args, - options, - ); - appendAll(strictNodes, result.nodes); - appendAll(strictEdges, result.edges); - appendAll(diagnostics, result.diagnostics); - appendAll(warnings, result.warnings); - // The manifest names the files, and the compiler 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. - for (const [file, digest] of result.sources) { - strictFiles.add(file); - strictDigests.set(file, digest); + for (const language of languages) { + const files = walkSourceFiles(root, { + extensions: allExtensions([language]), + maxFiles: options.maxFiles, + }); + if (files.length === 0) continue; + if (language === "typescript") { + // The provider decides whether it can honour these options; this loop only + // reports what it decided. The condition used to live here, inline and + // without an `else`, which is how the experiment's caps came to disable + // the compiler-owned lane on every run without a word of explanation. + const refusal = ttscGraphStrictRefusal(options); + if (refusal !== undefined) { + warnings.push(refusal); + } else { + const resolved = resolvedDependencies.resolveTtscGraphCommand(root); + if (resolved !== undefined) { + try { + const { result, session } = + await resolvedDependencies.collectTtscGraph( + root, + resolved.command, + resolved.args, + options, + ); + appendAll(strictNodes, result.nodes); + appendAll(strictEdges, result.edges); + appendAll(diagnostics, result.diagnostics); + appendAll(warnings, result.warnings); + // The manifest names the files, and the compiler 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. + for (const [file, digest] of result.sources) { + strictFiles.add(file); + strictDigests.set(file, digest); + } + lspNodeCount += result.nodes.length; + if (options.keepAlive) sessions.set(language, session); + continue; + } catch (error) { + if (options.signal?.aborted) throw error; + warnings.push( + `typescript: ttscgraph bulk indexing failed; using ttscserver LSP: ${(error as Error).message}`, + ); } - lspNodeCount += result.nodes.length; - if (options.keepAlive) sessions.set(language, session); - continue; - } catch (error) { - if (options.signal?.aborted) throw error; + } else { warnings.push( - `typescript: ttscgraph bulk indexing failed; using ttscserver LSP: ${(error as Error).message}`, + "typescript: ttscgraph bulk provider was not found; using ttscserver LSP.", ); } - } else { + } + } + const spec = specOf(language); + if (spec?.lsp === undefined) { + warnings.push(`${language}: no built-in LSP server is configured.`); + staticFallbackLanguages.push(language); + continue; + } + const command = options.server ?? spec.lsp.command; + const baseArgs = + options.serverArgs ?? + (isTtscserverCommand(command) + ? [...spec.lsp.args, "--cwd", root] + : spec.lsp.args); + // Appended regardless of a custom serverArgs override — which binary to + // run and which compilation database to hint at are orthogonal, and a + // test/user overriding serverArgs to swap the server binary should not + // also have to know to re-specify this. + const args = + (language === "cpp" || language === "c") && + compileCommandsDir !== undefined + ? [...baseArgs, `--compile-commands-dir=${compileCommandsDir}`] + : baseArgs; + const resolved = resolveCommand(command, root); + if (resolved === undefined) { + warnings.push(`${language}: LSP server not found on PATH: ${command}`); + staticFallbackLanguages.push(language); + continue; + } + // npm installs Windows servers as .cmd shims, which CreateProcess cannot + // spawn directly; run those through cmd.exe so ttscserver, + // pyright-langserver, and friends work from a plain package install. + const spawnable = /\.(cmd|bat)$/i.test(resolved) + ? { command: "cmd.exe", args: ["/d", "/s", "/c", resolved, ...args] } + : { command: resolved, args: [...args] }; + try { + const { result, session } = + await resolvedDependencies.collectLanguageGraph( + root, + language, + spawnable.command, + spawnable.args, + files, + options, + ); + if (result.nodes.length === 0) { warnings.push( - "typescript: ttscgraph bulk provider was not found; using ttscserver LSP.", + `${language}: LSP returned no symbols; using static fallback.`, ); + staticFallbackLanguages.push(language); + if (options.keepAlive) await session.client.close(); + } else { + for (const opened of session.opened.values()) { + sources.set(opened.abs, opened.text); + } + appendAll(nodes, result.nodes); + appendAll(edges, result.edges); + appendAll(diagnostics, result.diagnostics); + appendAll(warnings, result.warnings); + lspNodeCount += result.nodes.length; + if (options.keepAlive) sessions.set(language, session); } - } - } - const spec = specOf(language); - if (spec?.lsp === undefined) { - warnings.push(`${language}: no built-in LSP server is configured.`); - staticFallbackLanguages.push(language); - continue; - } - const command = options.server ?? spec.lsp.command; - const baseArgs = - options.serverArgs ?? - (isTtscserverCommand(command) - ? [...spec.lsp.args, "--cwd", root] - : spec.lsp.args); - // Appended regardless of a custom serverArgs override — which binary to - // run and which compilation database to hint at are orthogonal, and a - // test/user overriding serverArgs to swap the server binary should not - // also have to know to re-specify this. - const args = - (language === "cpp" || language === "c") && compileCommandsDir !== undefined - ? [...baseArgs, `--compile-commands-dir=${compileCommandsDir}`] - : baseArgs; - const resolved = resolveCommand(command, root); - if (resolved === undefined) { - warnings.push(`${language}: LSP server not found on PATH: ${command}`); - staticFallbackLanguages.push(language); - continue; - } - // npm installs Windows servers as .cmd shims, which CreateProcess cannot - // spawn directly; run those through cmd.exe so ttscserver, - // pyright-langserver, and friends work from a plain package install. - const spawnable = /\.(cmd|bat)$/i.test(resolved) - ? { command: "cmd.exe", args: ["/d", "/s", "/c", resolved, ...args] } - : { command: resolved, args: [...args] }; - try { - const { result, session } = await collectLanguageGraph( - root, - language, - spawnable.command, - spawnable.args, - files, - options, - ); - if (result.nodes.length === 0) { + } catch (error) { + if (options.signal?.aborted) throw error; warnings.push( - `${language}: LSP returned no symbols; using static fallback.`, + `${language}: LSP indexing failed: ${(error as Error).message}`, ); staticFallbackLanguages.push(language); - if (options.keepAlive) await session.client.close(); - } else { - for (const opened of session.opened.values()) { - sources.set(opened.abs, opened.text); - } - appendAll(nodes, result.nodes); - appendAll(edges, result.edges); - appendAll(diagnostics, result.diagnostics); - appendAll(warnings, result.warnings); - lspNodeCount += result.nodes.length; - if (options.keepAlive) sessions.set(language, session); } - } catch (error) { - warnings.push( - `${language}: LSP indexing failed: ${(error as Error).message}`, - ); - staticFallbackLanguages.push(language); } - } - // The static lane is merged before the graph is finalized, not after: the - // export surface is followed once across the whole project, so a barrel in one - // lane can still publish a symbol declared in the other. - if (staticFallbackLanguages.length > 0) { - const fallback = staticGraphParts({ - ...options, - cwd: root, - mode: "static", - languages: staticFallbackLanguages, - }); - appendSources(sources, fallback.sources); - if (lspNodeCount === 0) { + // The static lane is merged before the graph is finalized, not after: the + // export surface is followed once across the whole project, so a barrel in one + // lane can still publish a symbol declared in the other. + if (staticFallbackLanguages.length > 0) { + const fallback = staticGraphParts({ + ...options, + cwd: root, + mode: "static", + languages: staticFallbackLanguages, + }); + appendSources(sources, fallback.sources); + if (lspNodeCount === 0) { + return { + dump: staticDump(fallback, warnings), + warnings, + source: snapshotSource(), + ...(options.keepAlive ? { sessions, sources } : {}), + }; + } + appendAll(nodes, fallback.nodes); + appendAll(edges, fallback.edges); + appendAll(warnings, fallback.warnings); + } + + if (nodes.length === 0 && strictNodes.length === 0) { + const fallback = staticGraphParts(options); + appendSources(sources, fallback.sources); return { dump: staticDump(fallback, warnings), warnings, @@ -216,50 +249,60 @@ export async function buildLspGraph( ...(options.keepAlive ? { sessions, sources } : {}), }; } - appendAll(nodes, fallback.nodes); - appendAll(edges, fallback.edges); - appendAll(warnings, fallback.warnings); - } - if (nodes.length === 0 && strictNodes.length === 0) { - const fallback = staticGraphParts(options); - appendSources(sources, fallback.sources); + const finalized = mergeGraphSlices({ + root, + files: [...new Set([...sources.keys(), ...strictFiles])], + genericNodes: nodes, + genericEdges: edges, + strictNodes, + strictEdges, + }); return { - dump: staticDump(fallback, warnings), + dump: { + project: root, + languages: [ + ...new Set( + [...strictNodes, ...nodes].map((node) => node.language), + ), + ], + // Only a static fallback makes the graph a hybrid; a benign warning (e.g. + // the reference cap) on a pure-LSP run must not relabel it. + indexer: staticFallbackLanguages.length > 0 ? "hybrid" : "lsp", + nodes: wireNodes(finalized.nodes), + edges: wireEdges(finalized.edges), + diagnostics, + warnings, + }, warnings, source: snapshotSource(), ...(options.keepAlive ? { sessions, sources } : {}), }; + } catch (error) { + const closeErrors = await closeKeptSessions(sessions); + if (closeErrors.length > 0) { + throw new AggregateError( + [error, ...closeErrors], + "@samchon/graph: indexing failed and accumulated sessions could not all close", + ); + } + throw error; } +} - const finalized = mergeGraphSlices({ - root, - files: [...new Set([...sources.keys(), ...strictFiles])], - genericNodes: nodes, - genericEdges: edges, - strictNodes, - strictEdges, - }); - return { - dump: { - project: root, - languages: [ - ...new Set( - [...strictNodes, ...nodes].map((node) => node.language), - ), - ], - // Only a static fallback makes the graph a hybrid; a benign warning (e.g. - // the reference cap) on a pure-LSP run must not relabel it. - indexer: staticFallbackLanguages.length > 0 ? "hybrid" : "lsp", - nodes: wireNodes(finalized.nodes), - edges: wireEdges(finalized.edges), - diagnostics, - warnings, - }, - warnings, - source: snapshotSource(), - ...(options.keepAlive ? { sessions, sources } : {}), - }; +async function closeKeptSessions( + sessions: ReadonlyMap, +): Promise { + const failures: Error[] = []; + for (const session of new Set(sessions.values())) { + try { + if (isBulkGraphSession(session)) await session.close(); + else await session.client.close(); + } catch (error) { + failures.push(error instanceof Error ? error : new Error(String(error))); + } + } + return failures; } async function collectTtscGraph( @@ -269,7 +312,7 @@ async function collectTtscGraph( options: IBuildGraphOptions, ): Promise<{ result: IBulkGraphSession.ISnapshot; - session: TtscGraphClient; + session: IBulkGraphSession; }> { const session = new TtscGraphClient({ root, command, args }); try { diff --git a/packages/graph/src/provider/ttscgraph/TtscGraphClient.ts b/packages/graph/src/provider/ttscgraph/TtscGraphClient.ts index a217dd9..ea0cd1e 100644 --- a/packages/graph/src/provider/ttscgraph/TtscGraphClient.ts +++ b/packages/graph/src/provider/ttscgraph/TtscGraphClient.ts @@ -13,6 +13,8 @@ interface NativeChild { process: ChildProcessWithoutNullStreams; stdoutChunks: string[]; stderr: string; + exit: Promise; + termination?: Promise; } interface Pending { @@ -34,6 +36,7 @@ export class TtscGraphClient implements IBulkGraphSession { private readonly args: readonly string[]; private readonly requestTimeoutMs: number; private child: NativeChild | undefined; + private readonly ownedChildren = new Set(); private readonly pending = new Map(); private nextId = 1; private queue: Promise = Promise.resolve(); @@ -140,27 +143,17 @@ export class TtscGraphClient implements IBulkGraphSession { }, options.signal); } - /** Close immediately even when a serialized refresh is stalled. */ + /** Begin shutdown immediately and settle after every owned child exits. */ public close(): Promise { if (this.closing !== undefined) return this.closing; this.closed = true; const error = new Error("ttscgraph: session is closed"); const child = this.child; - if (child === undefined) { - this.failPending(error); - this.closing = Promise.resolve(); - return this.closing; - } - this.failChild(child, error); - this.closing = (async () => { - if (await waitForExit(child.process, 2_000)) return; - terminateChild(child.process, true); - /* c8 ignore start */ - if (!(await waitForExit(child.process, 2_000))) { - throw new Error("ttscgraph: owned process did not exit after close"); - } - /* c8 ignore stop */ - })(); + if (child !== undefined) this.failChild(child, error); + this.failPending(error); + this.closing = Promise.all( + [...this.ownedChildren].map((owned) => this.terminate(owned)), + ).then(() => undefined); return this.closing; } @@ -229,8 +222,11 @@ export class TtscGraphClient implements IBulkGraphSession { process: spawned, stdoutChunks: [], stderr: "", + exit: exitOf(spawned), }; this.child = child; + this.ownedChildren.add(child); + void child.exit.then(() => this.ownedChildren.delete(child)); spawned.stdout.setEncoding("utf8"); spawned.stderr.setEncoding("utf8"); spawned.stdout.on("data", (chunk: string) => this.consume(child, chunk)); @@ -318,7 +314,18 @@ export class TtscGraphClient implements IBulkGraphSession { this.child = undefined; this.snapshot = undefined; this.failPending(error, child); - if (terminate) terminateChild(child.process); + if (terminate) void this.terminate(child); + } + + private terminate(child: NativeChild): Promise { + if (child.termination === undefined) { + child.termination = terminateChild(child.process, child.exit); + // A protocol failure retires the child before a caller necessarily asks + // to close the client. Keep the rejection observed here while preserving + // the original promise for close() to report. + void child.termination.catch(() => undefined); + } + return child.termination; } private failPending(error: Error, child?: NativeChild): void { @@ -409,28 +416,27 @@ export namespace TtscGraphClient { function terminateChild( child: ChildProcessWithoutNullStreams, - forceNow = false, -): void { + exit: Promise, +): Promise { if (!child.stdin.destroyed) child.stdin.destroy(); - if (child.exitCode !== null || child.signalCode !== null) return; - try { - child.kill(forceNow ? "SIGKILL" : undefined); - } catch { - return; - } - if (forceNow) return; - const force = setTimeout(() => terminateChild(child, true), TERMINATION_GRACE_MS); - force.unref(); - child.once("exit", () => clearTimeout(force)); + return (async () => { + signalChild(child); + if (await waitForExit(exit, TERMINATION_GRACE_MS)) return; + signalChild(child, "SIGKILL"); + /* c8 ignore start */ + if (!(await waitForExit(exit, 2_000))) { + throw new Error( + "ttscgraph: owned process did not exit after forced termination", + ); + } + /* c8 ignore stop */ + })(); } function waitForExit( - child: ChildProcessWithoutNullStreams, + exit: Promise, timeoutMs: number, ): Promise { - if (child.exitCode !== null || child.signalCode !== null) { - return Promise.resolve(true); - } return new Promise((resolve) => { let settled = false; const finish = (value: boolean): void => { @@ -438,17 +444,37 @@ function waitForExit( if (settled) return; settled = true; clearTimeout(timer); - child.off("exit", exited); - child.off("close", exited); resolve(value); }; - const exited = (): void => finish(true); const timer = setTimeout(() => finish(false), timeoutMs); + timer.unref(); + void exit.then(() => finish(true)); + }); +} + +function exitOf(child: ChildProcessWithoutNullStreams): Promise { + return new Promise((resolve) => { + const exited = (): void => { + child.off("exit", exited); + child.off("close", exited); + resolve(); + }; child.once("exit", exited); child.once("close", exited); }); } +function signalChild( + child: ChildProcessWithoutNullStreams, + signal?: NodeJS.Signals, +): void { + try { + child.kill(signal); + } catch { + return; + } +} + function cancelledError(signal?: AbortSignal, child?: NativeChild): Error { const error = new Error( `ttscgraph: snapshot request cancelled${abortDetail(signal)}${ diff --git a/tests/test-graph/src/features/test_coverage_edge_cases.ts b/tests/test-graph/src/features/test_coverage_edge_cases.ts index d6bf342..6a4fe15 100644 --- a/tests/test-graph/src/features/test_coverage_edge_cases.ts +++ b/tests/test-graph/src/features/test_coverage_edge_cases.ts @@ -649,7 +649,14 @@ export const test_coverage_edge_cases = async () => { }; const branchGraph = SamchonGraphMemory.from( branchDump, - SamchonGraphSourceReader.live(branchRoot), + new SamchonGraphSourceReader(branchRoot, { + texts: new Map( + ["src/a.ts", "src/b.ts", "test/impact.spec.ts"].map((file) => [ + file, + fs.readFileSync(path.join(branchRoot, file), "utf8"), + ]), + ), + }), ); TestValidator.equals("missing incoming edges default to empty", branchGraph.incoming("absent-node"), []); @@ -892,6 +899,10 @@ export const test_coverage_edge_cases = async () => { } catch { // Windows without Developer Mode may deny symlink creation. } + const enumFile = path.join(path.dirname(signatureFile), "member.ts"); + fs.writeFileSync(enumFile, ["VIEW,", "EDIT,", "OTHER,"].join("\n")); + const emptySignatureFile = path.join(path.dirname(signatureFile), "empty.ts"); + fs.writeFileSync(emptySignatureFile, ""); const { signatureOf } = await importLib<{ signatureOf: (graph: SamchonGraphMemory, node: { file: string; name: string; external: boolean; evidence?: { file: string; startLine: number; endLine?: number }; signature?: string }) => string | undefined; }>("operations/signatureOf.js"); @@ -903,7 +914,13 @@ export const test_coverage_edge_cases = async () => { nodes: [], edges: [], }, - SamchonGraphSourceReader.live(path.dirname(signatureFile)), + new SamchonGraphSourceReader(path.dirname(signatureFile), { + texts: new Map([ + ["sample.ts", fs.readFileSync(signatureFile, "utf8")], + ["member.ts", fs.readFileSync(enumFile, "utf8")], + ["empty.ts", ""], + ]), + }), ); TestValidator.equals("blank explicit signature falls back to source span", signatureOf(signatureGraph, { external: false, @@ -918,8 +935,6 @@ export const test_coverage_edge_cases = async () => { name: "sample", evidence: { file: "sample.ts", startLine: 1, endLine: 3 }, })?.includes(") {"), true); - const enumFile = path.join(path.dirname(signatureFile), "member.ts"); - fs.writeFileSync(enumFile, ["VIEW,", "EDIT,", "OTHER,"].join("\n")); TestValidator.equals("a comma-ended declaration cannot absorb neighboring members", signatureOf(signatureGraph, { external: false, file: "member.ts", @@ -932,8 +947,6 @@ export const test_coverage_edge_cases = async () => { name: "missing", evidence: { file: "missing.ts", startLine: 1 }, }), undefined); - const emptySignatureFile = path.join(path.dirname(signatureFile), "empty.ts"); - fs.writeFileSync(emptySignatureFile, ""); TestValidator.equals("empty signature source span returns undefined", signatureOf(signatureGraph, { external: false, file: "empty.ts", diff --git a/tests/test-graph/src/features/test_lsp_first_build_abort_closes_accumulated_sessions.ts b/tests/test-graph/src/features/test_lsp_first_build_abort_closes_accumulated_sessions.ts new file mode 100644 index 0000000..0dd8ad2 --- /dev/null +++ b/tests/test-graph/src/features/test_lsp_first_build_abort_closes_accumulated_sessions.ts @@ -0,0 +1,203 @@ +import { TestValidator } from "@nestia/e2e"; +import { + buildLspGraph, + type GraphLanguage, + type IBulkGraphSession, + type ILspSession, + type ISamchonGraphNode, +} from "@samchon/graph"; +import fs from "node:fs"; +import path from "node:path"; + +import { GraphPaths } from "../internal/GraphPaths"; + +type BuildDependencies = NonNullable[1]>; + +/** A rejected first build still owns every session it opened before rejection. */ +export const test_lsp_first_build_abort_closes_accumulated_sessions = async () => { + const closed = await runAbortedBuild(); + TestValidator.predicate( + "the later lane's abort remains the build rejection", + closed.error === closed.buildError, + ); + TestValidator.equals( + "an unpublished bulk session closes exactly once", + closed.bulkCloseCalls, + 1, + ); + TestValidator.equals( + "an unpublished generic session closes exactly once", + closed.genericCloseCalls, + 1, + ); + + const bulkCloseFailure = "bulk close failed"; + const genericCloseFailure = new Error("generic close failed"); + const failedClose = await runAbortedBuild( + bulkCloseFailure, + genericCloseFailure, + ); + TestValidator.equals( + "one close failure does not skip another accumulated session", + [failedClose.bulkCloseCalls, failedClose.genericCloseCalls], + [1, 1], + ); + TestValidator.predicate( + "cleanup failures retain the build error and normalize close failures", + failedClose.error instanceof AggregateError && + failedClose.error.errors[0] === failedClose.buildError && + failedClose.error.errors[1] instanceof Error && + failedClose.error.errors[1].message === bulkCloseFailure && + failedClose.error.errors[2] === genericCloseFailure, + ); +}; + +async function runAbortedBuild( + bulkCloseFailure?: unknown, + genericCloseFailure?: unknown, +): Promise<{ + error: unknown; + buildError: Error; + bulkCloseCalls: number; + genericCloseCalls: number; +}> { + const root = GraphPaths.createTempDirectory( + "samchon-graph-lsp-accumulated-close-", + ); + fs.writeFileSync(path.join(root, "tsconfig.json"), "{}\n"); + fs.writeFileSync(path.join(root, "index.ts"), "export const value = 1;\n"); + fs.writeFileSync(path.join(root, "main.go"), "package main\nfunc main() {}\n"); + fs.writeFileSync(path.join(root, "app.py"), "def app():\n pass\n"); + installCommand(root, "gopls"); + installCommand(root, "pyright-langserver"); + + const controller = new AbortController(); + const buildError = new Error("later language aborted"); + let bulkCloseCalls = 0; + let genericCloseCalls = 0; + const snapshot = bulkSnapshot(root); + const bulk: IBulkGraphSession = { + kind: "bulk", + language: "typescript", + root, + generation: 1, + current: snapshot, + refresh: async () => ({ + changed: false, + generation: 1, + mode: "unchanged", + snapshot, + }), + close: () => { + bulkCloseCalls += 1; + return bulkCloseFailure === undefined + ? Promise.resolve() + : Promise.reject(bulkCloseFailure); + }, + }; + const generic = { + client: { + close: () => { + genericCloseCalls += 1; + return genericCloseFailure === undefined + ? Promise.resolve() + : Promise.reject(genericCloseFailure); + }, + }, + root, + language: "go", + opened: new Map(), + diagnostics: new Map(), + } as ILspSession; + + const dependencies: BuildDependencies = { + resolveTtscGraphCommand: () => ({ command: process.execPath, args: [] }), + collectTtscGraph: async () => ({ result: snapshot, session: bulk }), + collectLanguageGraph: async (_root, language) => { + if (language === "go") { + return { + result: { + nodes: [graphNode("go", "main.go", "main")], + edges: [], + diagnostics: [], + warnings: [], + }, + session: generic, + }; + } + controller.abort("later language failed"); + throw buildError; + }, + }; + + let error: unknown; + try { + await buildLspGraph( + { + cwd: root, + languages: ["typescript", "go", "python"], + keepAlive: true, + signal: controller.signal, + }, + dependencies, + ); + } catch (caught) { + error = caught; + } + return { + error, + buildError, + bulkCloseCalls, + genericCloseCalls, + }; +} + +function installCommand(root: string, command: string): void { + const directory = path.join(root, "node_modules", ".bin"); + fs.mkdirSync(directory, { recursive: true }); + const file = path.join( + directory, + process.platform === "win32" ? `${command}.cmd` : command, + ); + fs.writeFileSync( + file, + process.platform === "win32" ? "@exit /b 0\r\n" : "#!/bin/sh\nexit 0\n", + ); + if (process.platform !== "win32") fs.chmodSync(file, 0o755); +} + +function bulkSnapshot(root: string): IBulkGraphSession.ISnapshot { + return { + language: "typescript", + nodes: [graphNode("typescript", "index.ts", "value", "variable")], + edges: [], + diagnostics: [], + sources: new Map(), + provenance: { + schemaVersion: 5, + tool: "test", + toolVersion: "1", + compilerVersion: "1", + protocolVersion: 1, + universe: root, + capabilities: [], + }, + warnings: [], + }; +} + +function graphNode( + language: GraphLanguage, + file: string, + name: string, + kind: ISamchonGraphNode["kind"] = "function", +): ISamchonGraphNode { + return { + id: `${file}#${name}:${kind}`, + kind, + language, + name, + file, + external: false, + }; +} diff --git a/tests/test-graph/src/features/test_source_reader_enforces_snapshot_identity_once.ts b/tests/test-graph/src/features/test_source_reader_enforces_snapshot_identity_once.ts index 16c0cfc..e9f031a 100644 --- a/tests/test-graph/src/features/test_source_reader_enforces_snapshot_identity_once.ts +++ b/tests/test-graph/src/features/test_source_reader_enforces_snapshot_identity_once.ts @@ -79,9 +79,11 @@ export const test_source_reader_enforces_snapshot_identity_once = () => { TestValidator.equals("a provenance-free reader omits live source", none.lines("src/a.ts"), undefined); TestValidator.equals( "a parent traversal cannot read outside the project", - SamchonGraphSourceReader.live(root).lines( - path.relative(root, outside).replace(/\\/g, "/"), - ), + new SamchonGraphSourceReader(root, { + digests: new Map([ + [outside, { checkerDigest: sha("export const secret = 2;\n"), diskDigest: "" }], + ]), + }).lines(path.relative(root, outside).replace(/\\/g, "/")), undefined, ); @@ -90,7 +92,11 @@ export const test_source_reader_enforces_snapshot_identity_once = () => { fs.symlinkSync(outside, link, "file"); TestValidator.equals( "a symlink escape cannot read outside the project", - SamchonGraphSourceReader.live(root).lines("src/linked.ts"), + new SamchonGraphSourceReader(root, { + digests: new Map([ + [link, { checkerDigest: sha("export const secret = 2;\n"), diskDigest: "" }], + ]), + }).lines("src/linked.ts"), undefined, ); } catch { diff --git a/tests/test-graph/src/features/test_the_engines_answer_what_the_graph_only_half_holds.ts b/tests/test-graph/src/features/test_the_engines_answer_what_the_graph_only_half_holds.ts index e2fb93e..a1e44f4 100644 --- a/tests/test-graph/src/features/test_the_engines_answer_what_the_graph_only_half_holds.ts +++ b/tests/test-graph/src/features/test_the_engines_answer_what_the_graph_only_half_holds.ts @@ -259,7 +259,14 @@ const scenario_a_tour_over_a_graph_that_is_missing_its_evidence = async () => { const app = new SamchonGraphApplication( SamchonGraphMemory.from( dumpOf(root, nodes, edges), - SamchonGraphSourceReader.live(root), + new SamchonGraphSourceReader(root, { + texts: new Map([ + [ + "src/order/create.ts", + fs.readFileSync(path.join(root, "src/order/create.ts"), "utf8"), + ], + ]), + }), ), ); const tour = ( 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 4a9f18f..a5e3ada 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 @@ -1,4 +1,5 @@ import { TestValidator } from "@nestia/e2e"; +import { type ChildProcess, spawn } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; @@ -9,14 +10,14 @@ import { GraphPaths } from "../internal/GraphPaths"; export const test_ttscgraph_native_requests_recover_from_stalls = async () => { const root = fixture(); const marker = path.join(root, "first-child.txt"); - const client = create(root, marker, 300); + const client = create(root, marker, 1_000); try { const first = client.refresh(); const queued = client.refresh(); const error = await rejectionOf(first); TestValidator.predicate( "a silent native request times out precisely", - error.message.includes("timed out after 300 ms"), + error.message.includes("timed out after 1000 ms"), ); const recovered = await queued; TestValidator.equals( @@ -33,13 +34,15 @@ export const test_ttscgraph_native_requests_recover_from_stalls = async () => { } const closeRoot = fixture(); + const closeRequestLog = path.join(closeRoot, "close-request.txt"); const closeClient = create( closeRoot, path.join(closeRoot, "first-child.txt"), 5_000, + closeRequestLog, ); const stalled = closeClient.refresh(); - await delay(100); + await waitForFile(closeRequestLog); const closed = await Promise.race([ Promise.allSettled([stalled, closeClient.close()]), delay(1_000).then(() => "timeout" as const), @@ -49,16 +52,23 @@ export const test_ttscgraph_native_requests_recover_from_stalls = async () => { closed !== "timeout" && closed[0]?.status === "rejected", ); + /* c8 ignore next -- Windows terminates child processes unconditionally. */ + if (process.platform !== "win32") { + await assertRetiredChildIsClosed(); + } + const abortRoot = fixture(); + const abortRequestLog = path.join(abortRoot, "abort-request.txt"); const abortClient = create( abortRoot, path.join(abortRoot, "first-child.txt"), 5_000, + abortRequestLog, ); try { const controller = new AbortController(); const aborted = abortClient.refresh({ signal: controller.signal }); - await delay(100); + await waitForFile(abortRequestLog); controller.abort("test cancellation"); const error = await rejectionOf(aborted); TestValidator.predicate( @@ -97,6 +107,7 @@ const create = ( root: string, marker: string, requestTimeoutMs: number, + requestLog?: string, ): TtscGraphClient => new TtscGraphClient({ root, @@ -104,10 +115,87 @@ const create = ( args: [ GraphPaths.fakeTtscGraphServer, `--ignore-first-process=${marker}`, + ...(requestLog === undefined ? [] : [`--request-log=${requestLog}`]), ], requestTimeoutMs, }); +/** A detached generation remains owned until its SIGKILL fallback exits. */ +const assertRetiredChildIsClosed = async (): Promise => { + const root = fixture(); + const started = path.join(root, "retired-child.txt"); + const requested = path.join(root, "retired-request.txt"); + const terminated = path.join(root, "retired-sigterm.txt"); + const unrelatedStarted = path.join(root, "unrelated-child.txt"); + const childSource = [ + 'const fs = require("node:fs");', + 'const readline = require("node:readline");', + `fs.writeFileSync(${JSON.stringify(started)}, String(process.pid));`, + "readline.createInterface({ input: process.stdin }).once(\"line\", () =>", + ` fs.writeFileSync(${JSON.stringify(requested)}, "request\\n"));`, + "process.on(\"SIGTERM\", () =>", + ` fs.writeFileSync(${JSON.stringify(terminated)}, "sigterm\\n"));`, + "setInterval(() => undefined, 1_000);", + ].join("\n"); + const client = new TtscGraphClient({ + root, + command: process.execPath, + args: ["-e", childSource], + requestTimeoutMs: 5_000, + }); + const unrelated = spawn( + process.execPath, + [ + "-e", + [ + 'const fs = require("node:fs");', + `fs.writeFileSync(${JSON.stringify(unrelatedStarted)}, String(process.pid));`, + "setInterval(() => undefined, 1_000);", + ].join("\n"), + ], + { stdio: "ignore", windowsHide: true }, + ); + try { + await waitForFile(unrelatedStarted); + const controller = new AbortController(); + const stalled = client.refresh({ signal: controller.signal }); + await waitForFile(requested); + const pid = Number(fs.readFileSync(started, "utf8")); + controller.abort("retire stubborn generation"); + await rejectionOf(stalled); + await waitForFile(terminated); + + const firstClose = client.close(); + const secondClose = client.close(); + TestValidator.equals( + "retired-generation close remains idempotent", + firstClose === secondClose, + true, + ); + const closed = await Promise.race([ + firstClose.then(() => "closed" as const), + delay(3_000).then(() => "timeout" as const), + ]); + TestValidator.equals( + "close awaits the retired child's SIGKILL fallback", + closed, + "closed", + ); + TestValidator.equals( + "close returns only after the retired child exits", + isProcessAlive(pid), + false, + ); + TestValidator.equals( + "close does not terminate an unrelated Node process", + isProcessAlive(unrelated.pid!), + true, + ); + } finally { + await Promise.allSettled([client.close(), stop(unrelated)]); + } +}; + const fixture = (): string => { const root = GraphPaths.createTempDirectory( "samchon-graph-ttscgraph-timeout-", @@ -131,3 +219,31 @@ const rejectionOf = async (promise: Promise): Promise => { const delay = (milliseconds: number): Promise => new Promise((resolve) => setTimeout(resolve, milliseconds)); + +const waitForFile = async (file: string): Promise => { + const deadline = Date.now() + 5_000; + while (!fs.existsSync(file)) { + if (Date.now() >= deadline) { + throw new Error(`timed out waiting for ${file}`); + } + await delay(10); + } +}; + +const isProcessAlive = (pid: number): boolean => { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +}; + +const stop = async (child: ChildProcess): Promise => { + if (child.exitCode !== null || child.signalCode !== null) return; + const exited = new Promise((resolve) => { + child.once("exit", () => resolve()); + }); + child.kill("SIGKILL"); + await exited; +}; diff --git a/tests/test-graph/src/internal/fake-ttscgraph-server.cjs b/tests/test-graph/src/internal/fake-ttscgraph-server.cjs index b9ccef8..5e25106 100644 --- a/tests/test-graph/src/internal/fake-ttscgraph-server.cjs +++ b/tests/test-graph/src/internal/fake-ttscgraph-server.cjs @@ -60,9 +60,10 @@ const CAPABILITIES = [ "diagnostics", ].filter((capability) => capability !== dropped); -// Every workspace file the fake program loaded. The manifest must cover every -// file the nodes below name, because that is exactly what the client checks. +// 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 BUNDLED_FILES = ["bundled:///libs/lib.es2015.collection.d.ts"]; const digestOf = (text) => crypto.createHash("sha256").update(text).digest("hex"); @@ -85,7 +86,14 @@ const readProjectFile = (rel) => { * read still has a perfectly well-defined digest here. */ const manifest = (drift) => - WORKSPACE_FILES.map((file) => { + [...WORKSPACE_FILES, ...BUNDLED_FILES].map((file) => { + if (BUNDLED_FILES.includes(file)) { + return { + file, + checkerDigest: digestOf(`${file}:checker${drift ?? ""}`), + diskDigest: "", + }; + } const text = readProjectFile(file); return { file, From e4329b80cf2cef998b421f6c3a2cc19b21f6cc10 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Sun, 19 Jul 2026 18:32:09 +0900 Subject: [PATCH 06/16] Make bounded semantic claims match the canonical facts returned A member cap now retracts whole-member audit language, omitted dispatch-hub candidates set truncation only when they remain in scope, and schema-v3 input cannot smuggle schema-v5 member relations. Remove the unused public name-matching override heuristic so checker-owned relations remain the sole semantic source. Constraint: ttscgraph schema 5 is canonical; schema 3 is compatibility-only Rejected: Infer member overrides by same-name containment | spelling and ownership do not prove checker identity Confidence: high Scope-risk: moderate Directive: Any bounded response must report its eligible omissions through audit or truncated Tested: focused audit, trace, adapter, contract-parity, source-export tests; full suite with one independently passing warmup-timeout flake Not-tested: published ttsc 0.19.4 integration pending its authorized release Related: #105 #106 #108 #96 --- README.md | 11 +- packages/graph/src/SamchonGraphApplication.ts | 2 +- packages/graph/src/indexer/index.ts | 1 - packages/graph/src/indexer/overrideEdges.ts | 69 ----------- .../src/operations/RESULT_AUDIT_DETAILS.ts | 7 +- packages/graph/src/operations/runTrace.ts | 40 +++--- .../provider/ttscgraph/adaptTtscGraphDump.ts | 34 ++++++ .../structures/ISamchonGraphApplication.ts | 5 +- .../test_result_audits_before_the_facts.ts | 11 ++ ...st_trace_reports_dispatch_hub_omissions.ts | 81 ++++++++++++ ...ph_dump_adapter_rejects_malformed_facts.ts | 115 ++++++++++++++++++ .../test-graph/src/internal/ContractParity.ts | 2 +- 12 files changed, 283 insertions(+), 95 deletions(-) delete mode 100644 packages/graph/src/indexer/overrideEdges.ts create mode 100644 tests/test-graph/src/features/test_trace_reports_dispatch_hub_omissions.ts diff --git a/README.md b/README.md index a09e6be..cc08e88 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ A language server improves the graph with semantically resolved edges. Install t | Language | Server | Install | |---|---|---| -| TypeScript | `ttscserver` (0.18+) | `npm i -D ttsc typescript` | +| TypeScript | `ttscgraph` / `ttscserver` | `npm i -D ttsc@^0.19.4 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,8 @@ 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. +TypeScript uses the compiler-owned `ttscgraph` snapshot first. 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 5 is the complete contract; older schema 3 producers are accepted only as an explicitly warned compatibility snapshot and do not carry the newer member-relation and object-member 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. ## Benchmark @@ -131,7 +133,7 @@ One-time cost per repository. The server re-scans only changed files after that kotlin-language-server, jdtls, and csharp-ls are particularly slow: each resolves the whole project before answering anything. -Closing that gap needs what `@ttsc/graph` already does for TypeScript: a compiler-native indexer instead of a generic LSP server. Not done here. +TypeScript already closes that gap through the compiler-owned `ttscgraph` snapshot. The remaining languages use their listed language servers until their compiler-owned bulk providers land. ### Reproduction @@ -318,8 +320,9 @@ export namespace ISamchonGraphApplication { * The audit is operation-aware. For the walks from a named handle (`trace`, * `overview`) it reports the structure held for the named handles, bounded * where `truncated` says. For `details` it reports the two halves of a - * resolved symbol: its own shape returned whole, its fan-out returned as a - * slice with `trace` for the rest. For ranked operations (`lookup`, + * resolved symbol: its own shape returned whole unless the caller explicitly + * capped members, and its fan-out returned as a slice with `trace` for the + * rest. 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. diff --git a/packages/graph/src/SamchonGraphApplication.ts b/packages/graph/src/SamchonGraphApplication.ts index c0e7d00..48c4e62 100644 --- a/packages/graph/src/SamchonGraphApplication.ts +++ b/packages/graph/src/SamchonGraphApplication.ts @@ -87,7 +87,7 @@ export class SamchonGraphApplication implements ISamchonGraphApplication { case "details": { const r = runDetails(graph, props.request); return { - audit: RESULT_AUDIT_DETAILS(graph.indexer), + audit: RESULT_AUDIT_DETAILS(graph.indexer, props.request.memberLimit), next: r.next, result: r.result, }; diff --git a/packages/graph/src/indexer/index.ts b/packages/graph/src/indexer/index.ts index ac29ece..e7db125 100644 --- a/packages/graph/src/indexer/index.ts +++ b/packages/graph/src/indexer/index.ts @@ -27,7 +27,6 @@ export * from "./languageIdOf"; export * from "./languageOf"; export * from "./markClosures"; export * from "./markIgnored"; -export * from "./overrideEdges"; export * from "./reexportsOf"; export * from "./refreshLanguageSession"; export * from "./resolveModuleFile"; diff --git a/packages/graph/src/indexer/overrideEdges.ts b/packages/graph/src/indexer/overrideEdges.ts deleted file mode 100644 index 892fb4d..0000000 --- a/packages/graph/src/indexer/overrideEdges.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { ISamchonGraphEdge, ISamchonGraphNode } from "../structures"; -import { GraphEdgeKind } from "../typings"; - -// Members whose declaration a subtype can re-declare: a method, and a -// function-valued property or field (`onClick = () => ...`), which is how a -// class satisfies an interface member without writing a method. -const IMPLEMENTATION_MEMBER_KINDS = new Set([ - "method", - "property", - "field", -]); - -/** - * Link a member to the supertype member it re-declares, given a graph that - * already carries `contains` (owner -> member) and `extends`/`implements` - * (subtype -> supertype) edges. - * - * The member-level edge mirrors the type-level relation it came from: a class - * that `implements` an interface *implements* its members, one that `extends` a - * base *overrides* them. That distinction is what `details` reports back under - * `implementedBy`, and together the two kinds are what a forward trace - * dispatches through when a call lands on a declaration with no body (§3a) — an - * abstract base and an interface are the same dead end to a walk that follows - * what executes. - */ -export function overrideEdges( - nodes: readonly ISamchonGraphNode[], - edges: readonly ISamchonGraphEdge[], -): ISamchonGraphEdge[] { - const byId = new Map(nodes.map((node) => [node.id, node])); - const membersByOwner = new Map>(); - for (const edge of edges) { - if (edge.kind !== "contains") continue; - const member = byId.get(edge.to); - if (member === undefined || !IMPLEMENTATION_MEMBER_KINDS.has(member.kind)) - continue; - let members = membersByOwner.get(edge.from); - if (members === undefined) { - members = new Map(); - membersByOwner.set(edge.from, members); - } - members.set(member.name, member); - } - - const out: ISamchonGraphEdge[] = []; - for (const edge of edges) { - const kind: GraphEdgeKind | undefined = - edge.kind === "implements" - ? "implements" - : edge.kind === "extends" - ? "overrides" - : undefined; - if (kind === undefined) continue; - const subMembers = membersByOwner.get(edge.from); - const superMembers = membersByOwner.get(edge.to); - if (subMembers === undefined || superMembers === undefined) continue; - for (const [name, subMember] of subMembers) { - const superMember = superMembers.get(name); - if (superMember === undefined) continue; - out.push({ - from: subMember.id, - to: superMember.id, - kind, - evidence: subMember.implementation ?? subMember.evidence, - }); - } - } - return out; -} diff --git a/packages/graph/src/operations/RESULT_AUDIT_DETAILS.ts b/packages/graph/src/operations/RESULT_AUDIT_DETAILS.ts index a45a6e5..fe63de5 100644 --- a/packages/graph/src/operations/RESULT_AUDIT_DETAILS.ts +++ b/packages/graph/src/operations/RESULT_AUDIT_DETAILS.ts @@ -4,7 +4,12 @@ import { indexOf } from "./indexOf"; /** Audit for details, whose identity and fan-out have different bounds. */ export function RESULT_AUDIT_DETAILS( indexer: ISamchonGraphDump["indexer"], + memberLimit?: number, ): string { + const memberCoverage = + memberLimit === undefined || !Number.isFinite(memberLimit) + ? "its recorded members, values, and signature — is returned whole" + : "its values and signature are returned whole, while members are returned only up to the caller's explicit cap; this result makes no whole-member claim"; return ` AUDITED BEFORE RETURNING. READ FIRST. @@ -13,7 +18,7 @@ synced to, then checked every returned name, span, edge, signature, member, and against that same index. Each returned fact is exactly what that index holds. This is the structure the graph records for the handles you named. What a symbol is — -its recorded members, values, and signature — is returned whole; this does not claim a +${memberCoverage}; this does not claim a generic fallback proved facts it never indexed. What a symbol reaches or is reached by — its calls, type references, implementers, and under \`neighbors\` its dependents — is a short orientation slice because that grows with usage; \`trace\` follows the relationship diff --git a/packages/graph/src/operations/runTrace.ts b/packages/graph/src/operations/runTrace.ts index 3dbff8e..8da1000 100644 --- a/packages/graph/src/operations/runTrace.ts +++ b/packages/graph/src/operations/runTrace.ts @@ -211,10 +211,24 @@ export function runTrace( while (queue.length > 0) { const next: Array<{ id: string; depth: number }> = []; for (const { id, depth } of queue) { - const candidates = traceEdges(graph, id, reverse, focus); + const selected = traceEdges(graph, id, reverse, focus); + if ( + selected.omitted.some( + (edge) => + eligibleTraceEndpoint( + graph, + edge, + reverse, + focus, + includeExternal, + ) !== undefined, + ) + ) { + truncated = true; + } if (depth >= maxDepth) { if ( - candidates.some( + selected.edges.some( (edge) => eligibleTraceEndpoint( graph, @@ -229,7 +243,7 @@ export function runTrace( } continue; } - const edges = orderedEdges(graph, candidates, direction, reverse); + const edges = orderedEdges(graph, selected.edges, direction, reverse); for (const edge of edges) { const endpoint = eligibleTraceEndpoint( graph, @@ -302,10 +316,13 @@ function traceEdges( id: string, reverse: boolean, focus: ISamchonGraphTrace.IRequest["focus"], -): readonly ISamchonGraphEdge[] { - return reverse - ? graph.incoming(id) - : [...graph.outgoing(id), ...dispatchEdges(graph, id, focus)]; +): { edges: ISamchonGraphEdge[]; omitted: ISamchonGraphEdge[] } { + if (reverse) return { edges: [...graph.incoming(id)], omitted: [] }; + const ordinary = [...graph.outgoing(id)]; + const dispatch = dispatchCandidates(graph, id, focus); + return dispatch.length >= DISPATCH_HUB + ? { edges: ordinary, omitted: dispatch } + : { edges: [...ordinary, ...dispatch], omitted: [] }; } /** The endpoint an unbounded trace would represent, if any. */ @@ -710,15 +727,6 @@ function summary( * implementation — which is the fact, since the call site named the base and * the runtime lands in the override. */ -function dispatchEdges( - graph: SamchonGraphMemory, - id: string, - focus: ISamchonGraphTrace.IRequest["focus"], -): ISamchonGraphEdge[] { - const out = dispatchCandidates(graph, id, focus); - return out.length >= DISPATCH_HUB ? [] : out; -} - function dispatchCandidates( graph: SamchonGraphMemory, id: string, diff --git a/packages/graph/src/provider/ttscgraph/adaptTtscGraphDump.ts b/packages/graph/src/provider/ttscgraph/adaptTtscGraphDump.ts index 34298b7..a1ff3d1 100644 --- a/packages/graph/src/provider/ttscgraph/adaptTtscGraphDump.ts +++ b/packages/graph/src/provider/ttscgraph/adaptTtscGraphDump.ts @@ -85,6 +85,7 @@ export function adaptTtscGraphDump( const rawEdges = arrayOf(dump.edges, "dump.edges"); const moduleIds = new Map(); const rawIds = new Set(); + const nodeKindById = new Map(); const sourceFileById = new Map(); const nodes: ISamchonGraphNode[] = []; const files = new Set(); @@ -96,6 +97,7 @@ export function adaptTtscGraphDump( if (rawIds.has(id)) throw new Error(`ttscgraph: duplicate node id: ${id}`); rawIds.add(id); const kind = nodeKindOf(raw.kind, `dump.nodes[${index}].kind`); + nodeKindById.set(id, kind); const file = stringOf(raw.file, `dump.nodes[${index}].file`); const external = booleanOf(raw.external, `dump.nodes[${index}].external`); validateGraphFile(file, `dump.nodes[${index}].file`, external); @@ -219,6 +221,13 @@ export function adaptTtscGraphDump( } const from = moduleIds.get(rawFrom) ?? rawFrom; const kind = edgeKindOf(raw.kind, `dump.edges[${index}].kind`); + validateEdgeSchema( + schemaVersion as number, + kind, + nodeKindById.get(rawFrom)!, + nodeKindById.get(rawTo)!, + `dump.edges[${index}]`, + ); const key = `${kind}\0${from}\0${rawTo}`; if (edgeKeys.has(key)) { throw new Error( @@ -588,6 +597,31 @@ function edgeKindOf(value: unknown, label: string): GraphEdgeKind { return kind; } +function validateEdgeSchema( + schemaVersion: number, + kind: GraphEdgeKind, + fromKind: GraphNodeKind, + toKind: GraphNodeKind, + label: string, +): void { + if (schemaVersion === ITtscGraphSnapshot.DUMP_SCHEMA_VERSION) return; + // Schema v3 used `implements` only for class heritage. Schema v5 added the + // checker-owned member form and introduced `overrides` alongside it. + if (kind === "overrides") { + throw new Error( + `ttscgraph: ${label} overrides is not part of schema v${String(schemaVersion)}`, + ); + } + const containerImplements = + fromKind === "class" && + (toKind === "class" || toKind === "interface"); + if (kind === "implements" && !containerImplements) { + throw new Error( + `ttscgraph: ${label} member implements is not part of schema v${String(schemaVersion)} (${fromKind} -> ${toKind})`, + ); + } +} + function modifierOf( value: unknown, label: string, diff --git a/packages/graph/src/structures/ISamchonGraphApplication.ts b/packages/graph/src/structures/ISamchonGraphApplication.ts index 96c9658..97a81d6 100644 --- a/packages/graph/src/structures/ISamchonGraphApplication.ts +++ b/packages/graph/src/structures/ISamchonGraphApplication.ts @@ -169,8 +169,9 @@ export namespace ISamchonGraphApplication { * The audit is operation-aware. For the walks from a named handle (`trace`, * `overview`) it reports the structure held for the named handles, bounded * where `truncated` says. For `details` it reports the two halves of a - * resolved symbol: its own shape returned whole, its fan-out returned as a - * slice with `trace` for the rest. For ranked operations (`lookup`, + * resolved symbol: its own shape returned whole unless the caller explicitly + * capped members, and its fan-out returned as a slice with `trace` for the + * rest. 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. 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 774eec0..c5f7485 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 @@ -87,6 +87,17 @@ export const test_result_audits_before_the_facts = async () => { details.audit, RESULT_AUDIT_DETAILS("static"), ); + const cappedDetails = await ContractGraph.call(app, { + type: "details", + handles: ["Root.Service"], + memberLimit: 1, + }); + TestValidator.predicate( + "an explicit member cap makes the audit deny whole-member coverage", + cappedDetails.audit === RESULT_AUDIT_DETAILS("static", 1) && + cappedDetails.audit.includes("explicit cap") && + !cappedDetails.audit.includes("members, values, and signature — is returned whole"), + ); // The audit states its evidence before it instructs, and the instruction it // does give hands the stop rule to `next` rather than claiming it. diff --git a/tests/test-graph/src/features/test_trace_reports_dispatch_hub_omissions.ts b/tests/test-graph/src/features/test_trace_reports_dispatch_hub_omissions.ts new file mode 100644 index 0000000..92bc7fd --- /dev/null +++ b/tests/test-graph/src/features/test_trace_reports_dispatch_hub_omissions.ts @@ -0,0 +1,81 @@ +import { TestValidator } from "@nestia/e2e"; +import { + type ISamchonGraphDump, + type ISamchonGraphNode, + type ISamchonGraphTrace, + SamchonGraphApplication, + SamchonGraphMemory, +} from "@samchon/graph"; + +export const test_trace_reports_dispatch_hub_omissions = async () => { + const internal = await inspect(false); + TestValidator.equals( + "an omitted in-scope dispatch fanout is reported", + internal.truncated, + true, + ); + TestValidator.equals( + "a dispatch hub stays a leaf in the bounded response", + [internal.hops.length, internal.reached.length], + [0, 0], + ); + + const external = await inspect(true); + TestValidator.equals( + "excluded external dispatch candidates are not omissions", + external.truncated, + false, + ); +}; + +const inspect = async (external: boolean): Promise => { + const app = new SamchonGraphApplication(SamchonGraphMemory.from(dump(external))); + return ( + await app.inspect_code_graph({ + question: "where does this declaration dispatch", + draft: { reason: "Trace dispatch completeness.", type: "trace" }, + review: "Trace dispatch completeness.", + request: { type: "trace", from: BASE }, + }) + ).result as ISamchonGraphTrace; +}; + +const BASE = "src/base.ts#Base.run:method"; +const implementations = Array.from({ length: 12 }, (_, index) => ({ + implementation: `src/impl-${String(index)}.ts#Impl${String(index)}.run:method`, + leaf: `src/impl-${String(index)}.ts#work${String(index)}:function`, + file: `src/impl-${String(index)}.ts`, +})); + +const dump = (external: boolean): ISamchonGraphDump => ({ + project: "/trace-dispatch-hub", + languages: ["typescript"], + indexer: "lsp", + nodes: [ + node(BASE, "Base.run", "src/base.ts", false), + ...implementations.flatMap(({ implementation, leaf, file }) => [ + node(implementation, implementation.split("#")[1]!.split(":")[0]!, file, external), + node(leaf, leaf.split("#")[1]!.split(":")[0]!, file, external), + ]), + ], + edges: implementations.flatMap(({ implementation, leaf }) => [ + { from: implementation, to: BASE, kind: "implements" as const }, + { from: implementation, to: leaf, kind: "calls" as const }, + ]), +}); + +const node = ( + id: string, + name: string, + file: string, + external: boolean, +): ISamchonGraphNode => ({ + id, + kind: id.endsWith(":method") ? "method" : "function", + language: "typescript", + name, + qualifiedName: name, + file, + external, + evidence: { file, startLine: 1, endLine: 1 }, +}); 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 6f70660..5feab10 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 @@ -63,6 +63,41 @@ export const test_ttscgraph_dump_adapter_rejects_malformed_facts = async () => { change(dump); return dump; }; + const relationDump = (schemaVersion: number) => { + const dump = good(); + dump.provenance.schemaVersion = schemaVersion; + dump.nodes.push( + { + id: "src/a.ts#Worker:class", + kind: "class", + name: "Worker", + file: "src/a.ts", + external: false, + }, + { + id: "src/a.ts#Contract:interface", + kind: "interface", + name: "Contract", + file: "src/a.ts", + external: false, + }, + { + id: "src/a.ts#Worker.execute:method", + kind: "method", + name: "execute", + file: "src/a.ts", + external: false, + }, + { + id: "src/a.ts#Contract.execute:method", + kind: "method", + name: "execute", + file: "src/a.ts", + external: false, + }, + ); + return dump; + }; // A healthy dump adapts without throwing (the negative twin of every case). TestValidator.predicate( @@ -81,6 +116,69 @@ export const test_ttscgraph_dump_adapter_rejects_malformed_facts = async () => { warning.includes("object-literal member facts"), ), ); + const compatibleHeritage = relationDump(3); + compatibleHeritage.edges.push({ + from: "src/a.ts#Worker:class", + to: "src/a.ts#Contract:interface", + kind: "implements", + }); + TestValidator.predicate( + "schema 3 retains container implements heritage", + adaptTtscGraphDump(compatibleHeritage, project).edges.some( + (edge) => + edge.kind === "implements" && + edge.from === "src/a.ts#Worker:class" && + edge.to === "src/a.ts#Contract:interface", + ), + ); + rejectsWithMessage( + () => { + const dump = relationDump(3); + dump.edges.push({ + from: "src/a.ts#Worker.execute:method", + to: "src/a.ts#Contract.execute:method", + kind: "implements", + }); + return adaptTtscGraphDump(dump, project); + }, + "schema 3 member implements", + "member implements is not part of schema v3", + ); + rejectsWithMessage( + () => { + const dump = relationDump(3); + dump.edges.push({ + from: "src/a.ts#Worker.execute:method", + to: "src/a.ts#Contract.execute:method", + kind: "overrides", + }); + return adaptTtscGraphDump(dump, project); + }, + "schema 3 overrides", + "overrides is not part of schema v3", + ); + const currentRelations = relationDump(5); + currentRelations.edges.push( + { + from: "src/a.ts#Worker.execute:method", + to: "src/a.ts#Contract.execute:method", + kind: "implements", + }, + { + from: "src/a.ts#Worker.execute:method", + to: "src/a.ts#Contract.execute:method", + kind: "overrides", + }, + ); + const adaptedCurrentRelations = adaptTtscGraphDump( + currentRelations, + project, + ); + TestValidator.predicate( + "schema 5 retains checker-owned member relations", + adaptedCurrentRelations.edges.some((edge) => edge.kind === "implements") && + adaptedCurrentRelations.edges.some((edge) => edge.kind === "overrides"), + ); const crossRootFile = path .resolve(project, "..", "shared", "index.d.ts") .replace(/\\/g, "/"); @@ -430,3 +528,20 @@ function rejects(task: () => unknown, label: string): void { } TestValidator.predicate(`${label} is rejected`, error instanceof Error); } + +function rejectsWithMessage( + task: () => unknown, + label: string, + message: string, +): void { + let error: unknown; + try { + task(); + } catch (caught) { + error = caught; + } + TestValidator.predicate( + `${label} is rejected at its schema boundary`, + error instanceof Error && error.message.includes(message), + ); +} diff --git a/tests/test-graph/src/internal/ContractParity.ts b/tests/test-graph/src/internal/ContractParity.ts index 8e6e2ad..08d7e0c 100644 --- a/tests/test-graph/src/internal/ContractParity.ts +++ b/tests/test-graph/src/internal/ContractParity.ts @@ -510,7 +510,7 @@ export namespace ContractParity { "The operation-aware audit says the same thing with the product's actual compact wording: a walk returns the held structure for its named handles, and details splits whole identity from sliced fan-out.", layer: "prose", from: "For the walks from a named handle (`trace`, `overview`) it reports the result as the structure the graph holds, bounded where `truncated` says. For `details` it reports the two halves of a resolved symbol: its own shape returned whole, its fan-out returned as a slice with `trace` for the rest.", - to: "For the walks from a named handle (`trace`, `overview`) it reports the structure held for the named handles, bounded where `truncated` says. For `details` it reports the two halves of a resolved symbol: its own shape returned whole, its fan-out returned as a slice with `trace` for the rest.", + to: "For the walks from a named handle (`trace`, `overview`) it reports the structure held for the named handles, bounded where `truncated` says. For `details` it reports the two halves of a resolved symbol: its own shape returned whole unless the caller explicitly capped members, and its fan-out returned as a slice with `trace` for the rest.", }, { reason: From 2be0d31694225fa8732cc958d7c798660d9b278a Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Sun, 19 Jul 2026 19:36:05 +0900 Subject: [PATCH 07/16] Keep compiler truth intact across every graph lifecycle Carry the canonical ttsc source manifest without reopening compiler-owned files, bind universe roots and diagnostics to their proven snapshot, and let shutdown cancellation reach native and generic LSP work before unpublished sessions can leak. Static Scala and Swift ownership fixes close the language-specific gaps found by the independent review, while reducer and source utility cleanup remove unreachable or duplicated behavior. Constraint: Canonical ttscgraph output is the authority for compiler-owned TypeScript facts and source identity. Rejected: Re-read strict manifest files during generic finalization | external and bundled identities are not generic source capabilities and a second disk read violates snapshot ownership. Confidence: high Scope-risk: broad Directive: Do not pass bulk-provider manifest identities into source-derived generic export analysis; strict slices already carry compiler-resolved exports. Tested: pnpm coverage (all tests; statements, branches, functions, and lines 100%); focused generic/native cancellation, resident refresh, source identity, Scala, Swift, and dump-adapter tests. Not-tested: npm publication of ttsc 0.19.4 and paid agent benchmark runs remain separately authorized release/cost gates. --- .../src/indexer/staticGraphParts.ts | 36 +++ .../graph/src/indexer/IBuildGraphOptions.ts | 11 +- packages/graph/src/indexer/buildGraph.ts | 1 + packages/graph/src/indexer/buildLspGraph.ts | 53 ++-- .../src/indexer/createResidentGraphSource.ts | 12 +- packages/graph/src/indexer/scanSession.ts | 14 +- packages/graph/src/lsp/LspClient.ts | 56 +++-- .../graph/src/provider/IBulkGraphSession.ts | 6 +- .../src/provider/ttscgraph/TtscGraphClient.ts | 17 +- .../provider/ttscgraph/adaptTtscGraphDump.ts | 85 +++++-- packages/graph/src/reduce.ts | 1 - packages/graph/src/utils/fs.ts | 1 - packages/graph/src/utils/readLines.ts | 5 - tests/benchmark/graph/viewer.mjs | 1 - ...details_consume_snapshot_identity_facts.ts | 2 + ...rvers_that_break_the_shutdown_handshake.ts | 27 +- ...build_abort_closes_accumulated_sessions.ts | 92 +++++++ ...se_interrupts_stalled_generic_lsp_build.ts | 143 +++++++++++ ...nt_close_reaches_a_stalled_bulk_refresh.ts | 51 ++++ ...erves_scala3_declarations_and_ownership.ts | 20 +- ..._reader_enforces_snapshot_identity_once.ts | 58 +++++ ...ask_literals_and_reject_ambiguous_names.ts | 2 +- ..._extensions_preserve_semantic_ownership.ts | 84 +++++++ ...e_engines_hold_their_shape_at_the_edges.ts | 69 +++++- ...est_trace_reports_only_actual_omissions.ts | 23 +- ...raph_bulk_session_stays_alive_when_kept.ts | 49 +++- ...ph_dump_adapter_rejects_malformed_facts.ts | 232 +++++++++++++++++- ...aph_native_requests_recover_from_stalls.ts | 175 ++++++++++++- ...proves_its_program_without_reading_disk.ts | 6 +- ...vider_rejects_malformed_serve_responses.ts | 12 +- ...raph_provider_surfaces_process_failures.ts | 52 ++++ ...reduce_preserves_the_reference_contract.ts | 51 ++++ .../src/internal/fake-lsp-server.cjs | 30 ++- .../src/internal/fake-ttscgraph-server.cjs | 16 +- 34 files changed, 1377 insertions(+), 116 deletions(-) delete mode 100644 packages/graph/src/utils/readLines.ts create mode 100644 tests/test-graph/src/features/test_resident_close_interrupts_stalled_generic_lsp_build.ts create mode 100644 tests/test-graph/src/features/test_swift_cross_file_extensions_preserve_semantic_ownership.ts diff --git a/packages/graph-sitter/src/indexer/staticGraphParts.ts b/packages/graph-sitter/src/indexer/staticGraphParts.ts index f9b941e..d68e891 100644 --- a/packages/graph-sitter/src/indexer/staticGraphParts.ts +++ b/packages/graph-sitter/src/indexer/staticGraphParts.ts @@ -116,6 +116,7 @@ export function graphSitterParts( } connectProjectWideCppOwners(declarationsByFile, edges); + connectProjectWideSwiftOwners(declarationsByFile, edges); for (const [rel, declarations] of declarationsByFile) { const abs = absolutePathByRelativePath.get(rel)!; @@ -522,6 +523,8 @@ function declarationHeader( if (language === "kotlin") return KotlinDeclarations.kotlinDeclarationHeader(lines, start); if (language === "php") return PhpDeclarations.phpDeclarationHeader(lines, start); + if (language === "scala") + return ScalaDeclarations.scalaDeclarationHeader(lines, start); if (language === "swift") return SwiftDeclarations.swiftDeclarationHeader(lines, start); return lines[start]!; @@ -654,6 +657,39 @@ function connectProjectWideCppOwners( } } +/** Connect a Swift extension to its uniquely declared cross-file receiver. */ +function connectProjectWideSwiftOwners( + declarationsByFile: ReadonlyMap, + edges: ISamchonGraphEdge[], +): void { + const declarations = [...declarationsByFile.values()] + .flat() + .filter((declaration) => declaration.node.language === "swift"); + const types = new Map(); + for (const declaration of declarations) { + if (!isSwiftExtendable(declaration.node.kind)) continue; + push( + types, + declaration.node.qualifiedName ?? declaration.node.name, + declaration, + ); + } + for (const declaration of declarations) { + if (declaration.ownerId !== undefined || declaration.ownerName === undefined) + continue; + const candidates = types.get(declaration.ownerName); + if (candidates?.length !== 1) continue; + const owner = candidates[0]!; + declaration.ownerId = owner.node.id; + edges.push({ + from: owner.node.id, + to: declaration.node.id, + kind: "contains", + evidence: declaration.node.evidence, + }); + } +} + function cppQualifiedOwners( lexical: readonly string[], explicit: readonly string[], diff --git a/packages/graph/src/indexer/IBuildGraphOptions.ts b/packages/graph/src/indexer/IBuildGraphOptions.ts index 68d474f..4e1fc4c 100644 --- a/packages/graph/src/indexer/IBuildGraphOptions.ts +++ b/packages/graph/src/indexer/IBuildGraphOptions.ts @@ -55,12 +55,13 @@ export interface IBuildGraphOptions { */ keepAlive?: boolean; /** - * Abort an in-flight compiler-owned bulk snapshot request. + * Abort an in-flight compiler-owned snapshot or generic LSP build. * - * Resident graph sources use this to make shutdown reach an owned native - * provider immediately instead of waiting behind its serialized refresh. - * Generic LSP settlement is tracked separately and does not yet consume this - * signal. + * Resident graph sources use this to make shutdown reach an owned provider + * immediately instead of waiting behind its serialized refresh. Generic LSP + * initialization, indexing readiness, and graph requests all consume it; an + * aborted build closes the unpublished language-server session before it + * rejects. */ signal?: AbortSignal; /** diff --git a/packages/graph/src/indexer/buildGraph.ts b/packages/graph/src/indexer/buildGraph.ts index dbabd80..b74b75e 100644 --- a/packages/graph/src/indexer/buildGraph.ts +++ b/packages/graph/src/indexer/buildGraph.ts @@ -9,6 +9,7 @@ export async function buildGraph( const result = await buildGraphResult(options); return SamchonGraphMemory.from( result.dump, + /* c8 ignore next -- both concrete indexer result builders attach a reader. */ result.source ?? SamchonGraphSourceReader.none(result.dump.project), ); } diff --git a/packages/graph/src/indexer/buildLspGraph.ts b/packages/graph/src/indexer/buildLspGraph.ts index 227f071..037c11f 100644 --- a/packages/graph/src/indexer/buildLspGraph.ts +++ b/packages/graph/src/indexer/buildLspGraph.ts @@ -67,12 +67,6 @@ export async function buildLspGraph( const staticFallbackLanguages: GraphLanguage[] = []; const sessions = new Map(); const sources = new Map(); - // Files a strict provider proved, held apart from `sources` because only - // `sources` carries text and a strict provider deliberately hands over none. - // Both sets name files the build indexed; only one of them can answer "what - // did this text say", and the freshness hashes that ask that question already - // skip every bulk language. - const strictFiles = new Set(); const strictDigests = new Map< string, IBulkGraphSession.ISourceDigest @@ -127,7 +121,6 @@ export async function buildLspGraph( // text for — deriving export edges — is work this provider has // already done against the real checker. for (const [file, digest] of result.sources) { - strictFiles.add(file); strictDigests.set(file, digest); } lspNodeCount += result.nodes.length; @@ -252,7 +245,10 @@ export async function buildLspGraph( const finalized = mergeGraphSlices({ root, - files: [...new Set([...sources.keys(), ...strictFiles])], + // Only generic lanes need source-derived export edges. Strict providers + // already publish compiler-resolved exports, and their complete manifest + // can contain external or virtual identities that must never be reopened. + files: [...sources.keys()], genericNodes: nodes, genericEdges: edges, strictNodes, @@ -458,19 +454,25 @@ async function openLanguageSession( }); try { - await client.request("initialize", { - processId: process.pid, - rootUri: fileUri(root), - initializationOptions: options.initializationOptions, - capabilities: { - window: { workDoneProgress: true }, - textDocument: { - documentSymbol: { hierarchicalDocumentSymbolSupport: true }, - references: { dynamicRegistration: false }, + await client.request( + "initialize", + { + processId: process.pid, + rootUri: fileUri(root), + initializationOptions: options.initializationOptions, + capabilities: { + window: { workDoneProgress: true }, + textDocument: { + documentSymbol: { hierarchicalDocumentSymbolSupport: true }, + references: { dynamicRegistration: false }, + }, }, + workspaceFolders: [{ uri: fileUri(root), name: path.basename(root) }], }, - workspaceFolders: [{ uri: fileUri(root), name: path.basename(root) }], - }); + undefined, + options.signal, + ); + throwIfAborted(options.signal, "initialization"); client.notify("initialized", {}); const quietMs = options.lspReadyQuietMs ?? 1_500; @@ -491,6 +493,7 @@ async function openLanguageSession( () => activeProgress.size, quietMs, options.lspReadyTimeoutMs, + options.signal, ), }; const didOpenFence = session.progressVersion!(); @@ -545,6 +548,7 @@ async function waitForIndexing( activeProgressCount: () => number, quietMs: number, timeoutMs: number | undefined, + signal: AbortSignal | undefined, ): Promise { const start = Date.now(); // A work-done `begin` remains active until its matching `end`, even when the @@ -563,6 +567,7 @@ async function waitForIndexing( // A lazy reference request uses `allowStart=false`; it waits only if the // request advanced the generation, avoiding another unconditional delay. for (;;) { + throwIfAborted(signal, "indexing readiness"); const now = Date.now(); if (timeoutMs !== undefined && now - start >= timeoutMs) return; if (activeProgressCount() === 0) { @@ -580,6 +585,16 @@ async function waitForIndexing( } } +function throwIfAborted( + signal: AbortSignal | undefined, + phase: string, +): void { + if (signal?.aborted !== true) return; + const error = new Error(`LSP request aborted: ${phase}`); + error.name = "AbortError"; + throw error; +} + function convertDiagnostic(file: string, diagnostic: IDiagnostic): ISamchonGraphDiagnostic { return { file, diff --git a/packages/graph/src/indexer/createResidentGraphSource.ts b/packages/graph/src/indexer/createResidentGraphSource.ts index 1c7f20c..596c2b2 100644 --- a/packages/graph/src/indexer/createResidentGraphSource.ts +++ b/packages/graph/src/indexer/createResidentGraphSource.ts @@ -116,10 +116,6 @@ export function createResidentGraphSource( const diagnostics: ISamchonGraphDiagnostic[] = []; const warnings: string[] = []; const sources = new Map(); - // Files a strict provider proved. Kept apart from `sources` for the same - // reason as in `buildLspGraph`: a bulk provider publishes a manifest of what - // its checker read, never the text, so there is no text to put here. - const strictFiles = new Set(); const generations = new Map(current.generations); for (const [language, session] of current.sessions) { @@ -139,9 +135,6 @@ export function createResidentGraphSource( // forward: a diagnostic belongs to the generation that produced it. diagnostics.push(...refresh.snapshot.diagnostics); warnings.push(...refresh.snapshot.warnings); - for (const file of refresh.snapshot.sources.keys()) { - strictFiles.add(file); - } generations.set(language, refresh.generation); continue; } @@ -174,7 +167,10 @@ export function createResidentGraphSource( const finalized = mergeGraphSlices({ root, - files: [...new Set([...sources.keys(), ...strictFiles])], + // Bulk slices already contain compiler-resolved export edges. Reopening + // their manifest would break snapshot ownership and can target virtual or + // external identities; only generic source text belongs in this pass. + files: [...sources.keys()], genericNodes: nodes, genericEdges: edges, strictNodes, diff --git a/packages/graph/src/indexer/scanSession.ts b/packages/graph/src/indexer/scanSession.ts index ab65b02..0fd6f12 100644 --- a/packages/graph/src/indexer/scanSession.ts +++ b/packages/graph/src/indexer/scanSession.ts @@ -57,6 +57,8 @@ export async function scanSession( const symbols = await client.request( "textDocument/documentSymbol", { textDocument: { uri: fileUri(openedFile.abs) } }, + undefined, + options.signal, ); const converted = convertSymbols( language, @@ -124,6 +126,7 @@ export async function scanSession( client, referenceParams(referenceTargets[0]!), options.lspWarmupTimeoutMs, + options.signal, ); if ( progressFence !== undefined && @@ -139,6 +142,7 @@ export async function scanSession( client, referenceParams(referenceTargets[0]!), options.lspWarmupTimeoutMs, + options.signal, ); } if (warm === "timeout") referencesUnavailable = true; @@ -149,7 +153,12 @@ export async function scanSession( referenceTargets.slice(1), options.lspConcurrency ?? 16, async (target) => { - const refs = await safeReferences(client, referenceParams(target)); + const refs = await safeReferences( + client, + referenceParams(target), + undefined, + options.signal, + ); return refs === "timeout" ? null : refs; }, ); @@ -682,6 +691,7 @@ async function safeReferences( client: LspClient, params: unknown, timeoutMs?: number, + signal?: AbortSignal, ): Promise { for (let attempt = 0; attempt < 3; attempt++) { try { @@ -689,8 +699,10 @@ async function safeReferences( "textDocument/references", params, timeoutMs, + signal, ); } catch (error) { + if ((error as Error).name === "AbortError") throw error; if ((error as Error).message.startsWith("LSP request timed out")) { return "timeout"; } diff --git a/packages/graph/src/lsp/LspClient.ts b/packages/graph/src/lsp/LspClient.ts index 93c1d50..2ca2ccb 100644 --- a/packages/graph/src/lsp/LspClient.ts +++ b/packages/graph/src/lsp/LspClient.ts @@ -5,6 +5,8 @@ interface IRequest { resolve: (value: unknown) => void; reject: (error: Error) => void; timer: NodeJS.Timeout | undefined; + signal?: AbortSignal; + abort?: () => void; } export class LspClient { @@ -49,25 +51,37 @@ export class LspClient { method: string, params: unknown, timeoutMs?: number, + signal?: AbortSignal, ): Promise { + if (signal?.aborted) throw abortedError(method); // Requests are unlimited when neither the client nor this call specifies a - // deadline. Bounded callers can still prevent a non-answering server from - // holding an experiment forever. + // deadline or cancellation signal. Bounded callers can still prevent a + // non-answering server from holding an experiment or resident shutdown + // forever without changing the normal unlimited request contract. const id = this.nextId++; const payload = { jsonrpc: "2.0", id, method, params }; const promise = new Promise((resolve, reject) => { const effectiveTimeoutMs = timeoutMs ?? this.timeoutMs; - const timer = effectiveTimeoutMs === undefined - ? undefined - : setTimeout(() => { - this.pending.delete(id); - reject(new Error(`LSP request timed out: ${method}`)); - }, effectiveTimeoutMs); - this.pending.set(id, { + const pending: IRequest = { resolve: (value) => resolve(value as T), reject, - timer, - }); + timer: undefined, + signal, + }; + this.pending.set(id, pending); + if (effectiveTimeoutMs !== undefined) { + pending.timer = setTimeout(() => { + this.deletePending(id, pending); + reject(new Error(`LSP request timed out: ${method}`)); + }, effectiveTimeoutMs); + } + if (signal !== undefined) { + pending.abort = () => { + this.deletePending(id, pending); + reject(abortedError(method)); + }; + signal.addEventListener("abort", pending.abort, { once: true }); + } }); this.write(payload); return promise; @@ -200,8 +214,7 @@ export class LspClient { if (message.id !== undefined) { const pending = this.pending.get(message.id); if (pending === undefined) return; - this.pending.delete(message.id); - if (pending.timer !== undefined) clearTimeout(pending.timer); + this.deletePending(message.id, pending); if (message.error !== undefined) { pending.reject( new Error(message.error.message ?? "LSP request failed."), @@ -219,9 +232,22 @@ export class LspClient { private rejectAll(error: Error): void { for (const [id, request] of this.pending) { - this.pending.delete(id); - if (request.timer !== undefined) clearTimeout(request.timer); + this.deletePending(id, request); request.reject(error); } } + + private deletePending(id: number, request: IRequest): void { + this.pending.delete(id); + if (request.timer !== undefined) clearTimeout(request.timer); + if (request.abort !== undefined) { + request.signal!.removeEventListener("abort", request.abort); + } + } +} + +function abortedError(method: string): Error { + const error = new Error(`LSP request aborted: ${method}`); + error.name = "AbortError"; + return error; } diff --git a/packages/graph/src/provider/IBulkGraphSession.ts b/packages/graph/src/provider/IBulkGraphSession.ts index f7ae8a0..b1d7a4b 100644 --- a/packages/graph/src/provider/IBulkGraphSession.ts +++ b/packages/graph/src/provider/IBulkGraphSession.ts @@ -44,8 +44,10 @@ export namespace IBulkGraphSession { diagnostics: ISamchonGraphDiagnostic[]; /** - * The manifest of files this snapshot's facts were computed from, keyed by - * absolute path. + * The complete manifest of files this snapshot's facts were computed from. + * Project-relative identities are resolved to absolute paths; already + * absolute external identities and virtual `bundled:///` identities retain + * the producer's canonical spelling. * * This used to be the files' text, read off the disk by the client after * the compiler had answered. That is what a bulk provider must not do: the diff --git a/packages/graph/src/provider/ttscgraph/TtscGraphClient.ts b/packages/graph/src/provider/ttscgraph/TtscGraphClient.ts index ea0cd1e..59769bc 100644 --- a/packages/graph/src/provider/ttscgraph/TtscGraphClient.ts +++ b/packages/graph/src/provider/ttscgraph/TtscGraphClient.ts @@ -177,12 +177,16 @@ export class TtscGraphClient implements IBulkGraphSession { signal, }; pending.timer.unref(); + // Own the request before installing the listener. Abort dispatch is + // synchronous, so a signal that aborts during registration must find and + // settle this pending entry instead of retiring the child around an + // orphaned promise. + this.pending.set(id, pending); if (signal !== undefined) { pending.abort = () => this.failChild(child, cancelledError(signal, child)); signal.addEventListener("abort", pending.abort, { once: true }); } - this.pending.set(id, pending); if (signal?.aborted) { pending.abort!(); return; @@ -190,10 +194,14 @@ export class TtscGraphClient implements IBulkGraphSession { child.process.stdin.write(`${JSON.stringify({ id })}\n`, (error) => { if (error === null || error === undefined) return; if (this.pending.get(id) !== pending) return; + /* 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. */ this.failChild( child, new Error(`ttscgraph: could not request snapshot: ${error.message}`), ); + /* c8 ignore stop */ }); }); } @@ -341,6 +349,7 @@ export class TtscGraphClient implements IBulkGraphSession { pending: Pending, result: ITtscGraphSnapshot | Error, ): void { + /* c8 ignore next -- callers retrieved this entry or are iterating it. */ if (this.pending.get(id) !== pending) return; this.pending.delete(id); clearTimeout(pending.timer); @@ -365,11 +374,13 @@ export class TtscGraphClient implements IBulkGraphSession { let settled = false; const result = new Promise((resolve, reject) => { resolveResult = (value) => { + /* c8 ignore next -- cancelled queued tasks never call this resolver. */ if (settled) return; settled = true; resolve(value); }; rejectResult = (error) => { + /* c8 ignore next -- cancelled queued tasks never call this rejecter. */ if (settled) return; settled = true; reject(error); @@ -422,6 +433,7 @@ function terminateChild( return (async () => { signalChild(child); if (await waitForExit(exit, TERMINATION_GRACE_MS)) return; + /* c8 ignore next -- Windows exits on the first signal; POSIX tests this. */ signalChild(child, "SIGKILL"); /* c8 ignore start */ if (!(await waitForExit(exit, 2_000))) { @@ -468,11 +480,14 @@ function signalChild( child: ChildProcessWithoutNullStreams, signal?: NodeJS.Signals, ): void { + /* c8 ignore start -- an owned handle and fixed valid signals make throwing + * unreachable; kill reports failure as false or through child exit state. */ try { child.kill(signal); } catch { return; } + /* c8 ignore stop */ } function cancelledError(signal?: AbortSignal, child?: NativeChild): Error { diff --git a/packages/graph/src/provider/ttscgraph/adaptTtscGraphDump.ts b/packages/graph/src/provider/ttscgraph/adaptTtscGraphDump.ts index a1ff3d1..621e273 100644 --- a/packages/graph/src/provider/ttscgraph/adaptTtscGraphDump.ts +++ b/packages/graph/src/provider/ttscgraph/adaptTtscGraphDump.ts @@ -88,7 +88,6 @@ export function adaptTtscGraphDump( const nodeKindById = new Map(); const sourceFileById = new Map(); const nodes: ISamchonGraphNode[] = []; - const files = new Set(); const factFiles = new Set(); for (let index = 0; index < rawNodes.length; index++) { @@ -104,7 +103,6 @@ export function adaptTtscGraphDump( validateNodeId(id, file, kind); sourceFileById.set(id, file); factFiles.add(file); - if (!external && file !== "") files.add(file); if (kind === "module") { // `validateGraphFile` above already rejects an empty file for every node, // so a module reaching here always names a file. @@ -257,15 +255,14 @@ export function adaptTtscGraphDump( } } const sources = new Map(); - // The workspace file set still comes from the nodes, exactly as before: the - // manifest is every file the *program* loaded, which includes the bundled - // libs and every `node_modules` declaration the checker pulled in, and those - // are not this project's sources. What the manifest changes is that the set - // is now provable — each of these files must be one the same program loaded, - // and it arrives with the digest of the text the checker actually read. - for (const file of [...files].sort(compareText)) { - const digest = manifest.get(file)!; - sources.set(path.resolve(expectedRoot, file), digest); + // 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]) => + compareText(left, right), + )) { + sources.set(sourceManifestKey(expectedRoot, file), digest); } const capabilities = stringArrayOf( @@ -380,11 +377,18 @@ function universeOf(value: unknown): string { ); } push("configs"); + const configFiles = new Set(); for (let index = 0; index < configs.length; index++) { const label = `dump.provenance.universe.configs[${index}]`; const config = objectOf(configs[index], label); const file = stringOf(config.file, `${label}.file`); validateGraphFile(file, `${label}.file`); + if (configFiles.has(file)) { + throw new Error( + `ttscgraph: duplicate build-universe config identity: ${file}`, + ); + } + configFiles.add(file); push(file); push( validateDigest( @@ -395,13 +399,27 @@ function universeOf(value: unknown): string { } const roots = arrayOf(universe.roots, "dump.provenance.universe.roots"); push("roots"); + const rootsByConfig = new Map>(); for (let index = 0; index < roots.length; index++) { const label = `dump.provenance.universe.roots[${index}]`; const root = objectOf(roots[index], label); const config = stringOf(root.config, `${label}.config`); validateGraphFile(config, `${label}.config`); + if (!configFiles.has(config)) { + throw new Error( + `ttscgraph: ${label}.config names an unknown build-universe config: ${config}`, + ); + } const file = stringOf(root.file, `${label}.file`); validateGraphFile(file, `${label}.file`, true); + const configRoots = rootsByConfig.get(config); + if (configRoots?.has(file) === true) { + throw new Error( + `ttscgraph: duplicate build-universe root pair: ${config} -> ${file}`, + ); + } + if (configRoots === undefined) rootsByConfig.set(config, new Set([file])); + else configRoots.add(file); push(config); push(file); } @@ -447,14 +465,17 @@ function diagnosticsOf( const label = `dump.diagnostics[${index}]`; const entry = objectOf(item, label); const file = stringOf(entry.file, `${label}.file`); - validateGraphFile(file, `${label}.file`, true); - // The same one-program test the facts pass. A finding about a file the - // program never loaded did not come from this generation, and a checker - // that reports one is not describing the graph shipped beside it. - if (!manifest.has(file)) { - throw new Error( - `ttscgraph: ${label} reports ${file}, which the dump's own source manifest never loaded`, - ); + const fileless = file === ""; + if (!fileless) { + validateGraphFile(file, `${label}.file`, true); + // The same one-program test the facts pass. A finding about a file the + // program never loaded did not come from this generation, and a checker + // that reports one is not describing the graph shipped beside it. + if (!manifest.has(file)) { + throw new Error( + `ttscgraph: ${label} reports ${file}, which the dump's own source manifest never loaded`, + ); + } } const severity = stringOf(entry.category, `${label}.category`); if (!DIAGNOSTIC_SEVERITIES.has(severity)) { @@ -462,8 +483,12 @@ function diagnosticsOf( } return { file, - line: integerOf(entry.line, `${label}.line`), - column: integerOf(entry.column, `${label}.column`), + line: fileless + ? zeroOf(entry.line, `${label}.line`) + : integerOf(entry.line, `${label}.line`), + column: fileless + ? zeroOf(entry.column, `${label}.column`) + : integerOf(entry.column, `${label}.column`), code: integerOf(entry.code, `${label}.code`), message: stringOf(entry.message, `${label}.message`), severity: severity as ISamchonGraphDiagnostic["severity"], @@ -581,6 +606,15 @@ function integerOf(value: unknown, label: string): number { return value as number; } +function zeroOf(value: unknown, label: string): 0 { + if (value !== 0) { + throw new Error( + `ttscgraph: ${label} must be zero for a fileless diagnostic`, + ); + } + return 0; +} + function nodeKindOf(value: unknown, label: string): GraphNodeKind { const kind = stringOf(value, label) as GraphNodeKind; if (!NODE_KINDS.has(kind)) { @@ -614,7 +648,7 @@ function validateEdgeSchema( } const containerImplements = fromKind === "class" && - (toKind === "class" || toKind === "interface"); + (toKind === "class" || toKind === "interface" || toKind === "type"); if (kind === "implements" && !containerImplements) { throw new Error( `ttscgraph: ${label} member implements is not part of schema v${String(schemaVersion)} (${fromKind} -> ${toKind})`, @@ -836,6 +870,12 @@ function isNormalizedBundledFile(file: string): boolean { ); } +function sourceManifestKey(project: string, file: string): string { + return isNormalizedBundledFile(file) || isNormalizedAbsoluteFile(file) + ? file + : path.resolve(project, file); +} + function validateNodeId(id: string, file: string, kind: GraphNodeKind): void { const hash = id.indexOf("#"); if ( @@ -860,5 +900,6 @@ function samePath(left: string, right: string): boolean { } function compareText(left: string, right: string): number { + /* c8 ignore next -- manifestOf rejects duplicate source identities. */ return left < right ? -1 : left > right ? 1 : 0; } diff --git a/packages/graph/src/reduce.ts b/packages/graph/src/reduce.ts index 5047db9..a8ef550 100644 --- a/packages/graph/src/reduce.ts +++ b/packages/graph/src/reduce.ts @@ -73,7 +73,6 @@ function directoryOf(file: string): string { } function commonRoot(directories: string[]): string { - if (directories.length === 0) return ""; let parts = posix(directories[0]!).split("/"); const caseInsensitive = directories.every(isWindowsPath); for (const directory of directories.slice(1)) { diff --git a/packages/graph/src/utils/fs.ts b/packages/graph/src/utils/fs.ts index 37d958a..528486a 100644 --- a/packages/graph/src/utils/fs.ts +++ b/packages/graph/src/utils/fs.ts @@ -1,6 +1,5 @@ export * from "./DEFAULT_IGNORES"; export * from "./IWalkOptions"; export * from "./projectRelative"; -export * from "./readLines"; export * from "./readText"; export * from "./walkSourceFiles"; diff --git a/packages/graph/src/utils/readLines.ts b/packages/graph/src/utils/readLines.ts deleted file mode 100644 index 79fd13d..0000000 --- a/packages/graph/src/utils/readLines.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { readText } from "./readText"; - -export function readLines(file: string): string[] | undefined { - return readText(file)?.split(/\r?\n/); -} diff --git a/tests/benchmark/graph/viewer.mjs b/tests/benchmark/graph/viewer.mjs index bdf0f7f..871b64f 100644 --- a/tests/benchmark/graph/viewer.mjs +++ b/tests/benchmark/graph/viewer.mjs @@ -30,7 +30,6 @@ const PUBLIC_GRAPH_DIR = path.join(REPO_ROOT, "tests", "benchmark", "results", " /** Longest shared prefix of POSIX-normalized directories. */ function commonRoot(directories) { - if (directories.length === 0) return ""; let parts = posix(directories[0]).split("/"); const caseInsensitive = directories.every(isWindowsPath); for (const directory of directories.slice(1)) { diff --git a/tests/test-graph/src/features/test_details_consume_snapshot_identity_facts.ts b/tests/test-graph/src/features/test_details_consume_snapshot_identity_facts.ts index 5a34bb8..fa7f3d4 100644 --- a/tests/test-graph/src/features/test_details_consume_snapshot_identity_facts.ts +++ b/tests/test-graph/src/features/test_details_consume_snapshot_identity_facts.ts @@ -45,6 +45,7 @@ export const test_details_consume_snapshot_identity_facts = async () => { signature: "execute(input: Input): Output", }, { name: "label", kind: "property", line: 12 }, + { name: "status", kind: "property", signature: "status: State" }, ], }, ], @@ -75,6 +76,7 @@ export const test_details_consume_snapshot_identity_facts = async () => { signature: "execute(input: Input): Output", }, { name: "label", kind: "property", line: 12 }, + { name: "status", kind: "property", signature: "status: State" }, ]); TestValidator.predicate( "details audit describes complete identity and sliced fan-out separately", 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 99c98be..6e50110 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 @@ -5,7 +5,12 @@ import { pathToFileURL } from "node:url"; import { GraphPaths } from "../internal/GraphPaths"; interface ILspClient { - request(method: string, params: unknown, timeoutMs?: number): Promise; + request( + method: string, + params: unknown, + timeoutMs?: number, + signal?: AbortSignal, + ): Promise; close(): Promise; } @@ -80,4 +85,24 @@ export const test_lsp_client_closes_servers_that_break_the_shutdown_handshake = // new handshake with a process that is gone. await abrupt.close(); await stubborn.close(); + + // An already-cancelled request never enters the wire or waits for the + // otherwise-unlimited default deadline. The client still owns its child and + // closes it normally, which is the negative twin of aborting an in-flight + // request in the resident-source regression. + const cancelled = new LspClient(process.execPath, [ + GraphPaths.fakeLspServer, + ]); + const controller = new AbortController(); + controller.abort(); + let cancellation: Error | undefined; + await cancelled + .request("initialize", {}, undefined, controller.signal) + .catch((error: Error) => void (cancellation = error)); + TestValidator.equals( + "an already-cancelled unlimited request rejects as an abort", + cancellation?.name, + "AbortError", + ); + await cancelled.close(); }; diff --git a/tests/test-graph/src/features/test_lsp_first_build_abort_closes_accumulated_sessions.ts b/tests/test-graph/src/features/test_lsp_first_build_abort_closes_accumulated_sessions.ts index 0dd8ad2..fbd35c3 100644 --- a/tests/test-graph/src/features/test_lsp_first_build_abort_closes_accumulated_sessions.ts +++ b/tests/test-graph/src/features/test_lsp_first_build_abort_closes_accumulated_sessions.ts @@ -50,8 +50,100 @@ export const test_lsp_first_build_abort_closes_accumulated_sessions = async () = failedClose.error.errors[1].message === bulkCloseFailure && failedClose.error.errors[2] === genericCloseFailure, ); + + await assertStrictProviderCancellationBoundary(); }; +async function assertStrictProviderCancellationBoundary(): Promise { + const root = GraphPaths.createTempDirectory( + "samchon-graph-strict-provider-abort-", + ); + fs.writeFileSync(path.join(root, "tsconfig.json"), "{}\n"); + fs.writeFileSync(path.join(root, "index.ts"), "export const value = 1;\n"); + installCommand(root, "ttscserver"); + + const session = { + client: { close: async () => undefined }, + root, + language: "typescript", + opened: new Map(), + diagnostics: new Map(), + } as ILspSession; + const strictError = new Error("strict provider cancelled"); + const cancelled = new AbortController(); + let genericCalls = 0; + let error: unknown; + try { + await buildLspGraph( + { + cwd: root, + languages: ["typescript"], + signal: cancelled.signal, + }, + { + resolveTtscGraphCommand: () => ({ command: process.execPath, args: [] }), + collectTtscGraph: async () => { + cancelled.abort("strict provider cancelled"); + throw strictError; + }, + collectLanguageGraph: async () => { + genericCalls += 1; + return { + result: { + nodes: [graphNode("typescript", "index.ts", "value", "variable")], + edges: [], + diagnostics: [], + warnings: [], + }, + session, + }; + }, + }, + ); + } catch (caught) { + error = caught; + } + TestValidator.predicate( + "strict-provider cancellation is rethrown without generic fallback", + error === strictError && genericCalls === 0, + ); + + const live = new AbortController(); + const fallbackError = new Error("strict provider unavailable"); + const fallback = await buildLspGraph( + { + cwd: root, + languages: ["typescript"], + signal: live.signal, + }, + { + resolveTtscGraphCommand: () => ({ command: process.execPath, args: [] }), + collectTtscGraph: async () => { + throw fallbackError; + }, + collectLanguageGraph: async () => { + genericCalls += 1; + return { + result: { + nodes: [graphNode("typescript", "index.ts", "value", "variable")], + edges: [], + diagnostics: [], + warnings: [], + }, + session, + }; + }, + }, + ); + TestValidator.predicate( + "a live signal still permits documented generic fallback", + genericCalls === 1 && + fallback.warnings.some((warning) => + warning.includes(fallbackError.message), + ), + ); +} + async function runAbortedBuild( bulkCloseFailure?: unknown, genericCloseFailure?: unknown, diff --git a/tests/test-graph/src/features/test_resident_close_interrupts_stalled_generic_lsp_build.ts b/tests/test-graph/src/features/test_resident_close_interrupts_stalled_generic_lsp_build.ts new file mode 100644 index 0000000..8caa206 --- /dev/null +++ b/tests/test-graph/src/features/test_resident_close_interrupts_stalled_generic_lsp_build.ts @@ -0,0 +1,143 @@ +import { TestValidator } from "@nestia/e2e"; +import { createResidentGraphSource } from "@samchon/graph"; +import fs from "node:fs"; +import path from "node:path"; + +import { GraphPaths } from "../internal/GraphPaths"; + +/** A resident close reaches generic LSP work that has not published a session. */ +export const test_resident_close_interrupts_stalled_generic_lsp_build = + async () => { + await exercise("initialize", ["--hang-method=initialize"]); + await exercise("readiness", ["--hang-progress-lifecycle"]); + await exercise("symbols", [ + "--hang-method=textDocument/documentSymbol", + ]); + await exercise("references", [ + "--hang-method=textDocument/references", + ]); + }; + +const exercise = async (phase: string, serverArgs: string[]): Promise => { + const root = GraphPaths.createTempDirectory( + `samchon-graph-stalled-lsp-${phase}-`, + ); + fs.writeFileSync(path.join(root, "main.py"), "answer = 1\n"); + const pidFile = path.join(root, "lsp.pid"); + const progressFile = path.join(root, "progress.started"); + const hangFile = path.join(root, "request.stalled"); + const previousPidFile = process.env.SAMCHON_GRAPH_FAKE_LSP_PID_FILE; + const previousProgressFile = + process.env.SAMCHON_GRAPH_FAKE_LSP_PROGRESS_FILE; + const previousHangFile = process.env.SAMCHON_GRAPH_FAKE_LSP_HANG_FILE; + process.env.SAMCHON_GRAPH_FAKE_LSP_PID_FILE = pidFile; + process.env.SAMCHON_GRAPH_FAKE_LSP_PROGRESS_FILE = progressFile; + process.env.SAMCHON_GRAPH_FAKE_LSP_HANG_FILE = hangFile; + + let pid: number | undefined; + try { + const resident = createResidentGraphSource({ + cwd: root, + languages: ["python"], + server: process.execPath, + serverArgs: [GraphPaths.fakeLspServer, ...serverArgs], + lspReadyQuietMs: 10, + }); + const loading = resident.load(); + await waitForFile(pidFile); + pid = Number(fs.readFileSync(pidFile, "utf8")); + await waitForFile(phase === "readiness" ? progressFile : hangFile); + + const settled = await settleWithin( + Promise.allSettled([loading, resident.close()]), + 5_000, + () => terminate(pid!), + ); + TestValidator.equals( + `${phase} load rejects after shutdown`, + settled[0].status, + "rejected", + ); + TestValidator.equals( + `${phase} shutdown settles`, + settled[1].status, + "fulfilled", + ); + await waitForExit(pid); + TestValidator.equals( + `${phase} child exits after shutdown`, + isProcessAlive(pid), + false, + ); + } finally { + if (pid !== undefined) terminate(pid); + restoreEnv("SAMCHON_GRAPH_FAKE_LSP_PID_FILE", previousPidFile); + restoreEnv( + "SAMCHON_GRAPH_FAKE_LSP_PROGRESS_FILE", + previousProgressFile, + ); + restoreEnv("SAMCHON_GRAPH_FAKE_LSP_HANG_FILE", previousHangFile); + } +}; + +const waitForFile = async (file: string): Promise => { + const deadline = Date.now() + 5_000; + while (!fs.existsSync(file)) { + if (Date.now() >= deadline) { + throw new Error(`fake LSP did not announce ${file}`); + } + await delay(10); + } +}; + +const settleWithin = async ( + task: Promise, + timeoutMs: number, + onTimeout: () => void, +): Promise => { + let timer: NodeJS.Timeout | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => { + onTimeout(); + reject( + new Error(`resident LSP shutdown exceeded ${String(timeoutMs)} ms`), + ); + }, timeoutMs); + }); + try { + return await Promise.race([task, timeout]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } +}; + +const waitForExit = async (pid: number): Promise => { + const deadline = Date.now() + 2_000; + while (isProcessAlive(pid) && Date.now() < deadline) await delay(10); +}; + +const isProcessAlive = (pid: number): boolean => { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +}; + +const terminate = (pid: number): void => { + if (!isProcessAlive(pid)) return; + try { + process.kill(pid); + } catch { + return; + } +}; + +const restoreEnv = (name: string, value: string | undefined): void => { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; +}; + +const delay = (milliseconds: number): Promise => + new Promise((resolve) => setTimeout(resolve, milliseconds)); diff --git a/tests/test-graph/src/features/test_resident_close_reaches_a_stalled_bulk_refresh.ts b/tests/test-graph/src/features/test_resident_close_reaches_a_stalled_bulk_refresh.ts index 2ed7a33..f5eea11 100644 --- a/tests/test-graph/src/features/test_resident_close_reaches_a_stalled_bulk_refresh.ts +++ b/tests/test-graph/src/features/test_resident_close_reaches_a_stalled_bulk_refresh.ts @@ -96,4 +96,55 @@ export const test_resident_close_reaches_a_stalled_bulk_refresh = async () => { closeResult.status, "fulfilled", ); + + let failedCloseCalls = 0; + const unpublished: IBulkGraphSession = { + kind: "bulk", + language: "typescript", + root, + generation: 0, + current: undefined, + refresh: async () => { + throw new Error("unpublished session cannot refresh"); + }, + close: () => { + failedCloseCalls += 1; + return Promise.reject("synthetic close failure"); + }, + }; + const failed = createResidentGraphSource( + { cwd: root, languages: ["typescript"] }, + { + buildLspGraph: async () => ({ + dump: { + project: root, + languages: ["typescript"], + indexer: "lsp", + nodes: [], + edges: [], + }, + warnings: [], + sessions: new Map([["typescript", unpublished]]), + sources: new Map(), + }), + }, + ); + await failed.load(); + const firstFailedClose = failed.close(); + const repeatedFailedClose = failed.close(); + let closeError: unknown; + try { + await firstFailedClose; + } catch (error) { + closeError = error; + } + TestValidator.predicate( + "resident close is idempotent even when the owned provider rejects", + firstFailedClose === repeatedFailedClose && failedCloseCalls === 1, + ); + TestValidator.predicate( + "a non-Error provider close failure is normalized and reported", + closeError instanceof Error && + closeError.message === "synthetic close failure", + ); }; diff --git a/tests/test-graph/src/features/test_scala_static_preserves_scala3_declarations_and_ownership.ts b/tests/test-graph/src/features/test_scala_static_preserves_scala3_declarations_and_ownership.ts index d57885d..99b3c1b 100644 --- a/tests/test-graph/src/features/test_scala_static_preserves_scala3_declarations_and_ownership.ts +++ b/tests/test-graph/src/features/test_scala_static_preserves_scala3_declarations_and_ownership.ts @@ -48,7 +48,8 @@ export const test_scala_static_preserves_scala3_declarations_and_ownership = "end Api", "", "abstract class Repository", - "final class DefaultRepository extends Repository:", + "final class DefaultRepository extends", + " Repository:", " def load(): String = Api.invoke()", "", "opaque type Token = String", @@ -207,19 +208,22 @@ export const test_scala_static_preserves_scala3_declarations_and_ownership = ), ); TestValidator.predicate( - "Scala inheritance and qualified calls resolve against their real owners", + "a multiline Scala extends clause emits the canonical inheritance edge", dump.edges.some( (edge) => edge.kind === "extends" && edge.from === named("DefaultRepository")?.id && edge.to === named("Repository")?.id, + ), + ); + TestValidator.predicate( + "Scala qualified calls resolve against their real owners", + dump.edges.some( + (edge) => + edge.kind === "calls" && + edge.from === named("Main")?.id && + edge.to === named("Api.invoke")?.id, ) && - dump.edges.some( - (edge) => - edge.kind === "calls" && - edge.from === named("Main")?.id && - edge.to === named("Api.invoke")?.id, - ) && dump.edges.some( (edge) => edge.kind === "calls" && diff --git a/tests/test-graph/src/features/test_source_reader_enforces_snapshot_identity_once.ts b/tests/test-graph/src/features/test_source_reader_enforces_snapshot_identity_once.ts index e9f031a..1e3d4f5 100644 --- a/tests/test-graph/src/features/test_source_reader_enforces_snapshot_identity_once.ts +++ b/tests/test-graph/src/features/test_source_reader_enforces_snapshot_identity_once.ts @@ -39,6 +39,22 @@ export const test_source_reader_enforces_snapshot_identity_once = () => { "export const snapshot = 1;", "", ]); + const defaultReader = new SamchonGraphSourceReader(root, { + digests: new Map([ + [ + file, + { + checkerDigest: sha("export const snapshot = 1;\n"), + diskDigest: sha("export const snapshot = 1;\n"), + }, + ], + ]), + }); + TestValidator.equals( + "the default reader proves matching bytes without a test seam", + defaultReader.lines("src/a.ts"), + ["export const snapshot = 1;", ""], + ); fs.writeFileSync(file, "export const changed = 2;\n"); TestValidator.equals("a successful adjudication is immutable", reader.lines("src/a.ts"), [ "export const snapshot = 1;", @@ -46,6 +62,7 @@ export const test_source_reader_enforces_snapshot_identity_once = () => { ]); TestValidator.equals("a successful file is read once", reads, 1); + let mismatchReads = 0; const mismatch = new SamchonGraphSourceReader(root, { digests: new Map([ [ @@ -56,10 +73,51 @@ export const test_source_reader_enforces_snapshot_identity_once = () => { }, ], ]), + read: (target) => { + mismatchReads += 1; + return fs.readFileSync(target); + }, }); TestValidator.equals("changed disk bytes fail closed", mismatch.lines("src/a.ts"), undefined); fs.writeFileSync(file, "export const snapshot = 1;\n"); TestValidator.equals("a failed adjudication is cached", mismatch.lines("src/a.ts"), undefined); + TestValidator.equals( + "a digest refusal is adjudicated only once", + mismatchReads, + 1, + ); + + let failedReads = 0; + const unreadable = new SamchonGraphSourceReader(root, { + digests: new Map([ + [ + file, + { + checkerDigest: sha("export const snapshot = 1;\n"), + diskDigest: sha("export const snapshot = 1;\n"), + }, + ], + ]), + read: () => { + failedReads += 1; + throw new Error("synthetic read failure"); + }, + }); + TestValidator.equals( + "a disk read error fails closed", + unreadable.lines("src/a.ts"), + undefined, + ); + TestValidator.equals( + "a disk read error remains cached", + unreadable.lines("src/a.ts"), + undefined, + ); + TestValidator.equals( + "an unreadable snapshot file is attempted only once", + failedReads, + 1, + ); const exact = new SamchonGraphSourceReader(root, { texts: new Map([ diff --git a/tests/test-graph/src/features/test_static_dependencies_mask_literals_and_reject_ambiguous_names.ts b/tests/test-graph/src/features/test_static_dependencies_mask_literals_and_reject_ambiguous_names.ts index d5634f6..8aef049 100644 --- a/tests/test-graph/src/features/test_static_dependencies_mask_literals_and_reject_ambiguous_names.ts +++ b/tests/test-graph/src/features/test_static_dependencies_mask_literals_and_reject_ambiguous_names.ts @@ -46,7 +46,7 @@ async function validateResolutionEvidence(): Promise { "}", "class Caller {", " misroute() { foreign(); }", - " inherited() { super.foreign(); }", + " inherited() { super.foreign(); base.foreign(); }", "}", "function unknownReceiver(client: unknown) { client.foreign(); }", "function topLevelCannotCallMethod() { foreign(); }", diff --git a/tests/test-graph/src/features/test_swift_cross_file_extensions_preserve_semantic_ownership.ts b/tests/test-graph/src/features/test_swift_cross_file_extensions_preserve_semantic_ownership.ts new file mode 100644 index 0000000..6e72c7f --- /dev/null +++ b/tests/test-graph/src/features/test_swift_cross_file_extensions_preserve_semantic_ownership.ts @@ -0,0 +1,84 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { TestValidator } from "@nestia/e2e"; +import { buildGraphDump } from "@samchon/graph"; + +import { GraphPaths } from "../internal/GraphPaths"; + +export const test_swift_cross_file_extensions_preserve_semantic_ownership = + async () => { + const root = GraphPaths.createTempDirectory("samchon-swift-extension-"); + const write = (file: string, source: string): void => { + fs.writeFileSync(path.join(root, file), source); + }; + + write("Box.swift", "struct Box {}\n"); + write( + "Box+Extension.swift", + ["extension Box {", " func doubled() -> Int { 2 }", "}"].join("\n"), + ); + write( + "Local.swift", + [ + "struct Local {}", + "extension Local {", + " func sameFile() {}", + "}", + ].join("\n"), + ); + write("OtherLocal.swift", "struct Local {}\n"); + write("SharedA.swift", "struct Shared {}\n"); + write("SharedB.swift", "struct Shared {}\n"); + write( + "Shared+Extension.swift", + ["extension Shared {", " func ambiguous() {}", "}"].join("\n"), + ); + + const dump = await buildGraphDump({ + cwd: root, + mode: "static", + languages: ["swift"], + }); + const node = (file: string, name: string) => + dump.nodes.find((candidate) => + candidate.file === file && candidate.name === name + ); + const containedBy = (owner: string | undefined, child: string | undefined) => + dump.edges.some( + (edge) => + edge.kind === "contains" && + edge.from === owner && + edge.to === child, + ); + + const box = node("Box.swift", "Box"); + const doubled = node("Box+Extension.swift", "doubled"); + TestValidator.predicate( + "a cross-file Swift extension attaches its method to the unique receiver", + doubled?.qualifiedName === "Box.doubled" && + containedBy(box?.id, doubled.id), + ); + + const local = node("Local.swift", "Local"); + const otherLocal = node("OtherLocal.swift", "Local"); + const sameFile = node("Local.swift", "sameFile"); + TestValidator.predicate( + "a same-file receiver wins even when another file declares the same name", + containedBy(local?.id, sameFile?.id) && + !containedBy(otherLocal?.id, sameFile?.id), + ); + + const ambiguous = node("Shared+Extension.swift", "ambiguous"); + const shared = dump.nodes.filter( + (candidate) => candidate.name === "Shared" && candidate.kind === "class", + ); + TestValidator.predicate( + "an ambiguous cross-file receiver remains transparent", + ambiguous?.qualifiedName === "Shared.ambiguous" && + shared.length === 2 && + shared.every((candidate) => + !containedBy(candidate.id, ambiguous.id) + ), + ); + }; diff --git a/tests/test-graph/src/features/test_the_engines_hold_their_shape_at_the_edges.ts b/tests/test-graph/src/features/test_the_engines_hold_their_shape_at_the_edges.ts index a01c743..9ad1b23 100644 --- a/tests/test-graph/src/features/test_the_engines_hold_their_shape_at_the_edges.ts +++ b/tests/test-graph/src/features/test_the_engines_hold_their_shape_at_the_edges.ts @@ -4,6 +4,7 @@ import { buildGraphDump, SamchonGraphApplication, SamchonGraphMemory, + SamchonGraphSourceReader, } from "@samchon/graph"; import type { ISamchonGraphDetails, @@ -259,6 +260,9 @@ const scenario_a_doc_comment_at_every_boundary = async () => { ` * ${"a very long first sentence that runs well past what an index should carry ".repeat(4)}and then finally stops.`, " */", "export function tooLong(): void {}", + "", + "/** */", + "export function emptyDoc(): void {}", ]); const app = new SamchonGraphApplication( await buildGraph({ cwd: root, mode: "static", languages: ["typescript"] }), @@ -268,7 +272,10 @@ const scenario_a_doc_comment_at_every_boundary = async () => { question: "what do these do", draft: { reason: "Details.", type: "details" }, review: "Details.", - request: { type: "details", handles: ["first", "noPeriod", "tooLong"] }, + request: { + type: "details", + handles: ["first", "noPeriod", "tooLong", "emptyDoc"], + }, }) ).result as ISamchonGraphDetails; const docOf = (name: string): string | undefined => @@ -289,6 +296,66 @@ const scenario_a_doc_comment_at_every_boundary = async () => { "and a sentence that runs away is cut, not carried", (docOf("tooLong")?.length ?? 0) <= 201 && docOf("tooLong")?.endsWith("…") === true, ); + TestValidator.equals( + "an empty doc block contributes no invented summary", + docOf("emptyDoc"), + undefined, + ); + + const malformed = new SamchonGraphApplication( + SamchonGraphMemory.from( + { + project: root, + languages: ["typescript"], + indexer: "lsp", + nodes: [ + { + id: "src/orphaned.ts#orphanedClose:function", + kind: "function", + language: "typescript", + name: "orphanedClose", + file: "src/orphaned.ts", + external: false, + evidence: { startLine: 2 }, + }, + { + id: "src/stale.ts#stale:function", + kind: "function", + language: "typescript", + name: "stale", + file: "src/stale.ts", + external: false, + evidence: { startLine: 20 }, + }, + ], + edges: [], + }, + new SamchonGraphSourceReader(root, { + texts: new Map([ + ["src/orphaned.ts", "*/\nexport function orphanedClose(): void {}\n"], + [ + "src/stale.ts", + "/** Documentation for the declaration actually in this file. */\nexport function actual(): void {}\n", + ], + ]), + }), + ), + ); + const malformedDetails = ( + await malformed.inspect_code_graph({ + question: "do malformed spans carry documentation", + draft: { reason: "Details.", type: "details" }, + review: "Details.", + request: { + type: "details", + handles: ["orphanedClose", "stale"], + }, + }) + ).result as ISamchonGraphDetails; + TestValidator.predicate( + "orphaned closers and stale spans cannot borrow unrelated documentation", + malformedDetails.nodes.every((node) => node.doc === undefined), + ); }; /** diff --git a/tests/test-graph/src/features/test_trace_reports_only_actual_omissions.ts b/tests/test-graph/src/features/test_trace_reports_only_actual_omissions.ts index 6887961..9d02b50 100644 --- a/tests/test-graph/src/features/test_trace_reports_only_actual_omissions.ts +++ b/tests/test-graph/src/features/test_trace_reports_only_actual_omissions.ts @@ -2,13 +2,19 @@ import { TestValidator } from "@nestia/e2e"; import { SamchonGraphApplication, SamchonGraphMemory, + SamchonGraphSourceReader, type ISamchonGraphDump, type ISamchonGraphTrace, } from "@samchon/graph"; /** Trace existence and completeness are separate facts. */ export const test_trace_reports_only_actual_omissions = async () => { - const graph = SamchonGraphMemory.from(dump()); + const graph = SamchonGraphMemory.from( + dump(), + new SamchonGraphSourceReader("/trace-truth", { + texts: new Map([["src/dispatch.ts", "run(): void;\n"]]), + }), + ); const app = new SamchonGraphApplication(graph); const inspect = async ( request: ISamchonGraphTrace.IRequest, @@ -70,6 +76,11 @@ export const test_trace_reports_only_actual_omissions = async () => { (directDispatch.result as ISamchonGraphTrace).path?.map((entry) => entry.id), [DISPATCH_BASE, DISPATCH_IMPLEMENTATIONS[12]!], ); + TestValidator.equals( + "a resolved path keeps the implementation signature held by the snapshot", + (directDispatch.result as ISamchonGraphTrace).path?.[1]?.signature, + "run(): void;", + ); const boundedDispatch = await inspect({ type: "trace", @@ -129,7 +140,13 @@ const dump = (): ISamchonGraphDump => ({ ...CHAIN.map((id, index) => node(id, `step${String(index)}`)), node(DISPATCH_BASE, "Base.run", "method"), ...DISPATCH_IMPLEMENTATIONS.flatMap((id, index) => [ - node(id, `Impl${String(index)}.run`, "method"), + node( + id, + `Impl${String(index)}.run`, + "method", + false, + index === 12, + ), node(`${id}.body`, `body${String(index)}`), ]), node(EXTERNAL_DISPATCH_BASE, "ExternalBase.run", "method"), @@ -163,6 +180,7 @@ const node = ( name: string, kind: ISamchonGraphDump.INode["kind"] = "function", external = false, + withEvidence = false, ): ISamchonGraphDump.INode => ({ id, kind, @@ -170,4 +188,5 @@ const node = ( name, file: id.slice(0, id.indexOf("#")), external, + ...(withEvidence ? { evidence: { startLine: 1, endLine: 1 } } : {}), }); diff --git a/tests/test-graph/src/features/test_ttscgraph_bulk_session_stays_alive_when_kept.ts b/tests/test-graph/src/features/test_ttscgraph_bulk_session_stays_alive_when_kept.ts index f3f96a3..f75f0f1 100644 --- a/tests/test-graph/src/features/test_ttscgraph_bulk_session_stays_alive_when_kept.ts +++ b/tests/test-graph/src/features/test_ttscgraph_bulk_session_stays_alive_when_kept.ts @@ -28,18 +28,43 @@ export const test_ttscgraph_bulk_session_stays_alive_when_kept = async () => { // reuses the same warm compiler process instead of paying a full reindex // again. The marker proves the retained session is the one this build owns. const marker = path.join(root, "closed.txt"); - const result = await buildLspGraph( - { - cwd: root, - languages: ["typescript"], - keepAlive: true, - }, - { - resolveTtscGraphCommand: () => ({ - command: process.execPath, - args: [GraphPaths.fakeTtscGraphServer, `--marker=${marker}`], - }), - }, + const strictFiles = new Set([ + path.join(root, "src", "index.ts"), + path.join(root, "src", "core", "order.ts"), + path.join(root, "src", "empty.ts"), + ]); + const originalReadFileSync = fs.readFileSync; + const reopened: string[] = []; + fs.readFileSync = ((...args: unknown[]) => { + const file = String(args[0]); + if (strictFiles.has(file) || file.startsWith("bundled:///")) { + reopened.push(file); + } + return Reflect.apply(originalReadFileSync, fs, args); + }) as typeof fs.readFileSync; + let result: Awaited>; + try { + result = await buildLspGraph( + { + cwd: root, + languages: ["typescript"], + keepAlive: true, + }, + { + resolveTtscGraphCommand: () => ({ + command: process.execPath, + args: [GraphPaths.fakeTtscGraphServer, `--marker=${marker}`], + }), + }, + ); + } finally { + fs.readFileSync = originalReadFileSync; + } + + TestValidator.equals( + "strict source identities are not reopened after the compiler publishes them", + reopened, + [], ); TestValidator.equals( 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 5feab10..cff3a18 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 @@ -113,9 +113,21 @@ export const test_ttscgraph_dump_adapter_rejects_malformed_facts = async () => { compatible.warnings.some( (warning) => warning.includes("schema v3 compatibility snapshot") && - warning.includes("object-literal member facts"), + warning.includes("object-literal member facts"), ), ); + rejectsWithMessage( + () => + adaptTtscGraphDump( + mutate((d) => { + d.provenance.schemaVersion = 3; + (d.nodes[1] as { objectMembers?: unknown }).objectMembers = []; + }), + project, + ), + "schema 3 object members", + "objectMembers is not part of schema v3", + ); const compatibleHeritage = relationDump(3); compatibleHeritage.edges.push({ from: "src/a.ts#Worker:class", @@ -131,6 +143,28 @@ export const test_ttscgraph_dump_adapter_rejects_malformed_facts = async () => { edge.to === "src/a.ts#Contract:interface", ), ); + const compatibleAliasHeritage = relationDump(3); + compatibleAliasHeritage.nodes.push({ + id: "src/a.ts#ContractAlias:type", + kind: "type", + name: "ContractAlias", + file: "src/a.ts", + external: false, + }); + compatibleAliasHeritage.edges.push({ + from: "src/a.ts#Worker:class", + to: "src/a.ts#ContractAlias:type", + kind: "implements", + }); + TestValidator.predicate( + "schema 3 retains class-to-type-alias implements heritage", + adaptTtscGraphDump(compatibleAliasHeritage, project).edges.some( + (edge) => + edge.kind === "implements" && + edge.from === "src/a.ts#Worker:class" && + edge.to === "src/a.ts#ContractAlias:type", + ), + ); rejectsWithMessage( () => { const dump = relationDump(3); @@ -205,6 +239,192 @@ export const test_ttscgraph_dump_adapter_rejects_malformed_facts = async () => { (node) => node.file === crossRootFile, ), ); + const bundledFile = "bundled:///libs/lib.es2015.collection.d.ts"; + const uncFile = "//server/share/types/external.d.ts"; + const posixFile = "/opt/types/external.d.ts"; + const completeManifest = good(); + completeManifest.provenance.sources.push( + { + file: crossRootFile, + checkerDigest: sha(`${crossRootFile}:checker`), + diskDigest: sha(`${crossRootFile}:disk`), + }, + { + file: bundledFile, + checkerDigest: sha(`${bundledFile}:checker`), + diskDigest: "", + }, + { + file: uncFile, + checkerDigest: sha(`${uncFile}:checker`), + diskDigest: sha(`${uncFile}:disk`), + }, + { + file: posixFile, + checkerDigest: sha(`${posixFile}:checker`), + diskDigest: sha(`${posixFile}:disk`), + }, + ); + const completeSources = adaptTtscGraphDump( + completeManifest, + project, + ).sources; + TestValidator.predicate( + "the complete compiler manifest preserves every canonical file identity", + completeSources.has(path.resolve(project, "src/a.ts")) && + completeSources.has(crossRootFile) && + completeSources.has(bundledFile) && + completeSources.has(uncFile) && + completeSources.has(posixFile), + ); + TestValidator.equals( + "a virtual bundled source keeps its checker digest", + completeSources.get(bundledFile), + { + checkerDigest: sha(`${bundledFile}:checker`), + diskDigest: "", + }, + ); + + const globalDiagnostic = good(); + globalDiagnostic.diagnostics.push({ + file: "", + line: 0, + column: 0, + code: 18003, + category: "error", + message: "No inputs were found in config file", + }); + TestValidator.equals( + "a canonical fileless compiler diagnostic is retained", + adaptTtscGraphDump(globalDiagnostic, project).diagnostics, + [ + { + file: "", + line: 0, + column: 0, + code: 18003, + message: "No inputs were found in config file", + severity: "error", + }, + ], + ); + rejects( + () => + adaptTtscGraphDump( + mutate((d) => + d.diagnostics.push({ + file: "", + line: 1, + column: 0, + code: 18003, + category: "error", + message: "malformed global diagnostic", + }), + ), + project, + ), + "a fileless diagnostic with a nonzero line", + ); + rejects( + () => + adaptTtscGraphDump( + mutate((d) => + d.diagnostics.push({ + file: "", + line: 0, + column: 1, + code: 18003, + category: "error", + message: "malformed global diagnostic", + }), + ), + project, + ), + "a fileless diagnostic with a nonzero column", + ); + rejects( + () => + adaptTtscGraphDump( + mutate((d) => + d.diagnostics.push({ + file: "src/a.ts", + line: 0, + column: 1, + code: 2322, + category: "error", + message: "malformed file diagnostic", + }), + ), + project, + ), + "a file-backed diagnostic with a zero line", + ); + rejects( + () => + adaptTtscGraphDump( + mutate((d) => + d.diagnostics.push({ + file: "src/a.ts", + line: 1, + column: 0, + code: 2322, + category: "error", + message: "malformed file diagnostic", + }), + ), + project, + ), + "a file-backed diagnostic with a zero column", + ); + + const referencedProject = good(); + referencedProject.provenance.universe.configs.push({ + file: "tsconfig.reference.json", + digest: sha("tsconfig.reference.json"), + }); + referencedProject.provenance.universe.roots.push({ + config: "tsconfig.reference.json", + file: "src/a.ts", + }); + TestValidator.predicate( + "two configs may canonically name the same root file", + adaptTtscGraphDump(referencedProject, project).provenance.universe !== "", + ); + rejects( + () => + adaptTtscGraphDump( + mutate((d) => + d.provenance.universe.configs.push({ + ...d.provenance.universe.configs[0]!, + }), + ), + project, + ), + "a duplicate config identity in the build universe", + ); + rejects( + () => + adaptTtscGraphDump( + mutate((d) => { + d.provenance.universe.roots[0]!.config = "tsconfig.missing.json"; + }), + project, + ), + "a root attributed to an unknown config", + ); + rejects( + () => + adaptTtscGraphDump( + mutate((d) => + d.provenance.universe.roots.push({ + ...d.provenance.universe.roots[0]!, + }), + ), + project, + ), + "a duplicate config and root-file pair", + ); // Identity and project scope. rejects(() => adaptTtscGraphDump("not-an-object", project), "a non-object dump"); @@ -212,6 +432,16 @@ export const test_ttscgraph_dump_adapter_rejects_malformed_facts = async () => { () => adaptTtscGraphDump(mutate((d) => (d.project = `${project}-other`)), project), "a dump whose project does not match the requested root", ); + rejects( + () => + adaptTtscGraphDump( + mutate((d) => { + d.provenance.sources[0]!.file = "C:\\workspace\\src\\a.ts"; + }), + project, + ), + "a source identity with non-canonical backslashes", + ); // Structural types. rejects(() => adaptTtscGraphDump(mutate((d) => ((d as { nodes: unknown }).nodes = "x")), project), "a non-array nodes field"); 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 a5e3ada..d72f0e0 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 @@ -42,14 +42,22 @@ export const test_ttscgraph_native_requests_recover_from_stalls = async () => { closeRequestLog, ); const stalled = closeClient.refresh(); + const queuedBehindStall = closeClient.refresh(); await waitForFile(closeRequestLog); const closed = await Promise.race([ - Promise.allSettled([stalled, closeClient.close()]), + Promise.allSettled([ + stalled, + queuedBehindStall, + closeClient.close(), + ]), delay(1_000).then(() => "timeout" as const), ]); TestValidator.predicate( - "close settles without queueing behind a stalled refresh", - closed !== "timeout" && closed[0]?.status === "rejected", + "close rejects active and queued refreshes without waiting for the stall", + closed !== "timeout" && + closed[0]?.status === "rejected" && + closed[1]?.status === "rejected" && + closed[2]?.status === "fulfilled", ); /* c8 ignore next -- Windows terminates child processes unconditionally. */ @@ -84,6 +92,123 @@ export const test_ttscgraph_native_requests_recover_from_stalls = async () => { await abortClient.close(); } + const queuedAbortRoot = fixture(); + const queuedAbortLog = path.join(queuedAbortRoot, "queued-abort-request.txt"); + const queuedAbortClient = create( + queuedAbortRoot, + path.join(queuedAbortRoot, "first-child.txt"), + 5_000, + queuedAbortLog, + ); + const active = queuedAbortClient.refresh(); + try { + await waitForFile(queuedAbortLog); + const controller = new AbortController(); + const queued = queuedAbortClient.refresh({ signal: controller.signal }); + controller.abort(new Error("queued cancellation")); + const error = await rejectionOf(queued); + TestValidator.predicate( + "an aborted queued refresh settles before it owns a child", + error.name === "AbortError" && error.message.includes("queued cancellation"), + ); + TestValidator.equals( + "queued cancellation writes no native request", + fs.readFileSync(queuedAbortLog, "utf8"), + "1\n", + ); + } finally { + const closing = queuedAbortClient.close(); + await rejectionOf(active); + await closing; + } + + const immediateRoot = fixture(); + const immediateMarker = path.join(immediateRoot, "pre-aborted-child.txt"); + const immediate = create(immediateRoot, immediateMarker, 5_000); + const immediateController = new AbortController(); + immediateController.abort({ + toString: () => { + throw new Error("reason cannot be rendered"); + }, + }); + const immediateError = await rejectionOf( + immediate.refresh({ signal: immediateController.signal }), + ); + TestValidator.predicate( + "an already-aborted refresh fails without rendering a hostile reason", + immediateError.name === "AbortError" && + immediateError.message === "ttscgraph: snapshot request cancelled", + ); + await immediate.close(); + TestValidator.equals( + "an already-aborted refresh spawns no child", + fs.existsSync(immediateMarker), + false, + ); + + const handoffRoot = fixture(); + const handoffClient = new TtscGraphClient({ + root: handoffRoot, + command: process.execPath, + args: [GraphPaths.fakeTtscGraphServer], + }); + try { + const controller = new AbortController(); + const error = await rejectionOf( + handoffClient.refresh({ signal: abortAtQueueHandoff(controller) }), + ); + TestValidator.predicate( + "an abort at the queue-to-request handoff is observed before spawn", + error.name === "AbortError" && error.message.includes("handoff cancellation"), + ); + } finally { + await handoffClient.close(); + } + + const registrationRoot = fixture(); + const registrationClient = new TtscGraphClient({ + root: registrationRoot, + command: process.execPath, + args: [GraphPaths.fakeTtscGraphServer], + requestTimeoutMs: 5_000, + }); + try { + const controller = new AbortController(); + const signal = abortDuringRequestRegistration(controller); + const error = await Promise.race([ + rejectionOf(registrationClient.refresh({ signal })), + delay(1_000).then(() => { + throw new Error("post-registration abort left the refresh pending"); + }), + ]); + TestValidator.predicate( + "an abort dispatched during request registration settles its owned request", + error.name === "AbortError" && + error.message === "ttscgraph: snapshot request cancelled", + ); + } finally { + await registrationClient.close(); + } + + const reorderedCapabilities = new TtscGraphClient({ + root: fixture(), + command: process.execPath, + args: [ + GraphPaths.fakeTtscGraphServer, + "--reverse-capabilities", + "--duplicate-capability", + ], + }); + try { + TestValidator.equals( + "capability identity tolerates reordered repeated claims on both sides", + (await reorderedCapabilities.refresh()).generation, + 1, + ); + } finally { + await reorderedCapabilities.close(); + } + for (const value of [0, -1, 1.5, Number.NaN, 2_147_483_648]) { let error: unknown; try { @@ -103,6 +228,50 @@ export const test_ttscgraph_native_requests_recover_from_stalls = async () => { } }; +const abortAtQueueHandoff = (controller: AbortController): AbortSignal => + new Proxy(controller.signal, { + get(target, property) { + if (property === "removeEventListener") { + return ( + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | EventListenerOptions, + ): void => { + target.removeEventListener(type, listener, options); + controller.abort("handoff cancellation"); + }; + } + const value: unknown = Reflect.get(target, property, target); + return typeof value === "function" ? value.bind(target) : value; + }, + }); + +const abortDuringRequestRegistration = ( + controller: AbortController, +): AbortSignal => { + let registrations = 0; + return new Proxy(controller.signal, { + get(target, property) { + if (property === "reason") return undefined; + if (property === "addEventListener") { + return ( + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | AddEventListenerOptions, + ): void => { + target.addEventListener(type, listener, options); + registrations += 1; + if (registrations === 2) { + controller.abort("registration cancellation"); + } + }; + } + const value: unknown = Reflect.get(target, property, target); + return typeof value === "function" ? value.bind(target) : value; + }, + }); +}; + const create = ( root: string, marker: string, diff --git a/tests/test-graph/src/features/test_ttscgraph_provider_proves_its_program_without_reading_disk.ts b/tests/test-graph/src/features/test_ttscgraph_provider_proves_its_program_without_reading_disk.ts index 9a63028..0a59858 100644 --- a/tests/test-graph/src/features/test_ttscgraph_provider_proves_its_program_without_reading_disk.ts +++ b/tests/test-graph/src/features/test_ttscgraph_provider_proves_its_program_without_reading_disk.ts @@ -25,8 +25,9 @@ import { GraphPaths } from "../internal/GraphPaths"; * sources are never written to disk at all. Every earlier version of this client * would hand back an empty source map here, because every `readText` returns * undefined and the file is dropped. This one hands back the producer's - * workspace manifest entries intact, because the producer is the only party - * that ever knew. + * complete program manifest intact, because the producer is the only party + * that ever knew. That includes virtual bundled libraries which deliberately + * have no disk path. */ export const test_ttscgraph_provider_proves_its_program_without_reading_disk = async () => { @@ -50,6 +51,7 @@ export const test_ttscgraph_provider_proves_its_program_without_reading_disk = "a snapshot whose files are absent from disk still names every one of them", [...initial.snapshot.sources.keys()].sort(), [ + "bundled:///libs/lib.es2015.collection.d.ts", path.join(root, "src", "core", "order.ts"), path.join(root, "src", "empty.ts"), path.join(root, "src", "index.ts"), diff --git a/tests/test-graph/src/features/test_ttscgraph_provider_rejects_malformed_serve_responses.ts b/tests/test-graph/src/features/test_ttscgraph_provider_rejects_malformed_serve_responses.ts index b91f106..7c44564 100644 --- a/tests/test-graph/src/features/test_ttscgraph_provider_rejects_malformed_serve_responses.ts +++ b/tests/test-graph/src/features/test_ttscgraph_provider_rejects_malformed_serve_responses.ts @@ -27,6 +27,11 @@ export const test_ttscgraph_provider_rejects_malformed_serve_responses = // A non-JSON serve line is a framing fault the client parses itself, so it // is surfaced as an error rather than resolving a request. await assertRejected(root, "--nonjson", "a non-JSON serve line"); + await assertRejected( + root, + ["--nonjson", "--late-after-nonjson"], + "late output from a retired protocol generation", + ); // A well-formed frame carrying an id no request is waiting on cannot be // routed, and an unroutable frame fails every outstanding request rather // than hanging until the process exits. @@ -42,13 +47,16 @@ export const test_ttscgraph_provider_rejects_malformed_serve_responses = async function assertRejected( root: string, - serveFlag: string, + serveFlag: string | readonly string[], label: string, ): Promise { const client = new TtscGraphClient({ root, command: process.execPath, - args: [GraphPaths.fakeTtscGraphServer, serveFlag], + args: [ + GraphPaths.fakeTtscGraphServer, + ...(typeof serveFlag === "string" ? [serveFlag] : serveFlag), + ], }); try { let error: unknown; diff --git a/tests/test-graph/src/features/test_ttscgraph_provider_surfaces_process_failures.ts b/tests/test-graph/src/features/test_ttscgraph_provider_surfaces_process_failures.ts index 2bff200..e7a4d90 100644 --- a/tests/test-graph/src/features/test_ttscgraph_provider_surfaces_process_failures.ts +++ b/tests/test-graph/src/features/test_ttscgraph_provider_surfaces_process_failures.ts @@ -61,6 +61,11 @@ export const test_ttscgraph_provider_surfaces_process_failures = async () => { // The child already exited, so close resolves immediately without touching it. await dying.close(); + // Windows named pipes retain the inherited read handle until process exit, + // so they surface this condition through the already-covered exit listener. + /* c8 ignore next */ + if (process.platform !== "win32") await assertClosedRequestPipe(root); + // A process that exits without diagnostics still rejects, and a later refresh // reports the process is gone without inventing an empty snapshot. const silent = new TtscGraphClient({ @@ -117,6 +122,53 @@ function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +async function waitForFile(file: string): Promise { + const deadline = Date.now() + 5_000; + while (!fs.existsSync(file)) { + if (Date.now() >= deadline) throw new Error(`timed out waiting for ${file}`); + await delay(10); + } +} + +async function assertClosedRequestPipe(root: string): Promise { + // A producer can remain alive after closing the request side of its pipe. + // The next refresh must reject the write failure and retire that generation + // instead of waiting for the snapshot timeout. + const stdinClosed = path.join(root, "stdin-closed.txt"); + const client = new TtscGraphClient({ + root, + command: process.execPath, + args: [ + GraphPaths.fakeTtscGraphServer, + "--close-stdin-after-first", + `--marker=${stdinClosed}`, + ], + requestTimeoutMs: 5_000, + }); + try { + await client.refresh(); + await waitForFile(stdinClosed); + const writeFailure = await rejectionOf(client.refresh()); + TestValidator.predicate( + "a closed native request pipe rejects before the snapshot timeout", + (writeFailure.message.includes("could not request snapshot") || + writeFailure.message.includes("stdin failed")) && + !writeFailure.message.includes("timed out"), + ); + } finally { + await client.close(); + } +} + +async function rejectionOf(task: Promise): Promise { + try { + await task; + } catch (error) { + if (error instanceof Error) return error; + } + throw new Error("expected promise to reject"); +} + async function rejects(task: Promise, label: string): Promise { let error: unknown; try { diff --git a/tests/test-graph/src/features/test_viewer_reduce_preserves_the_reference_contract.ts b/tests/test-graph/src/features/test_viewer_reduce_preserves_the_reference_contract.ts index 846c768..d265cd5 100644 --- a/tests/test-graph/src/features/test_viewer_reduce_preserves_the_reference_contract.ts +++ b/tests/test-graph/src/features/test_viewer_reduce_preserves_the_reference_contract.ts @@ -145,6 +145,57 @@ export const test_viewer_reduce_preserves_the_reference_contract = () => { ["file.ts", "file.ts"], ); + const posixRoot = reduce({ + nodes: [ + node("/a.ts", "A", "class"), + node("/b.ts", "B", "class"), + ], + edges: [edge("/a.ts", "A", "class", "/b.ts", "B", "class", "calls")], + }); + TestValidator.equals( + "files at the POSIX root retain their basenames", + posixRoot.nodes.map((entry) => entry.file), + ["a.ts", "b.ts"], + ); + + const caseSensitive = reduce({ + nodes: [ + node("/work/A/a.ts", "A", "class"), + node("/work/a/b.ts", "B", "class"), + ], + edges: [ + edge( + "/work/A/a.ts", + "A", + "class", + "/work/a/b.ts", + "B", + "class", + "calls", + ), + ], + }); + TestValidator.equals( + "POSIX common roots remain case-sensitive", + caseSensitive.nodes.map((entry) => entry.file), + ["A/a.ts", "a/b.ts"], + ); + + const mixedPathForms = reduce({ + nodes: [ + node("/work/a.ts", "A", "class"), + node("bare.ts", "B", "class"), + ], + edges: [ + edge("/work/a.ts", "A", "class", "bare.ts", "B", "class", "calls"), + ], + }); + TestValidator.equals( + "mixed legacy paths fall back to portable basenames", + mixedPathForms.nodes.map((entry) => entry.file), + ["a.ts", "bare.ts"], + ); + const unc = reduce({ nodes: [ node("\\\\SERVER\\Share\\project\\src\\a.ts", "A", "class"), diff --git a/tests/test-graph/src/internal/fake-lsp-server.cjs b/tests/test-graph/src/internal/fake-lsp-server.cjs index 1182244..3df3782 100644 --- a/tests/test-graph/src/internal/fake-lsp-server.cjs +++ b/tests/test-graph/src/internal/fake-lsp-server.cjs @@ -30,6 +30,7 @@ const options = { javaFlat: false, omitChildren: false, progress: false, + hangProgressLifecycle: false, progressLifecycle: false, referenceProgressLifecycle: false, rustImpls: false, @@ -56,6 +57,9 @@ if (process.env.SAMCHON_GRAPH_FAKE_LSP_ARGS_FILE) { if (process.env.SAMCHON_GRAPH_FAKE_LSP_CWD_FILE) { fs.writeFileSync(process.env.SAMCHON_GRAPH_FAKE_LSP_CWD_FILE, process.cwd()); } +if (process.env.SAMCHON_GRAPH_FAKE_LSP_PID_FILE) { + fs.writeFileSync(process.env.SAMCHON_GRAPH_FAKE_LSP_PID_FILE, String(process.pid)); +} // Delay only the FIRST textDocument/references response by this many ms, then // answer the rest immediately — models a server that builds its reference index // lazily on the first call and serves the rest from cache. @@ -127,6 +131,8 @@ for (const arg of process.argv.slice(2)) { options.omitChildren = true; } else if (arg === "--progress") { options.progress = true; + } else if (arg === "--hang-progress-lifecycle") { + options.hangProgressLifecycle = true; } else if (arg === "--progress-lifecycle") { options.progressLifecycle = true; } else if (arg.startsWith("--late-progress-lifecycle=")) { @@ -196,7 +202,15 @@ process.stdin.on("data", (chunk) => { }); function handle(message) { - if (message.method === hangMethod) return; + if (message.method === hangMethod) { + if (process.env.SAMCHON_GRAPH_FAKE_LSP_HANG_FILE) { + fs.writeFileSync( + process.env.SAMCHON_GRAPH_FAKE_LSP_HANG_FILE, + message.method, + ); + } + return; + } if (message.method === "initialize") { if (options.exitOnInitialize) process.exit(7); if (options.stderr) process.stderr.write("fake-lsp progress\n"); @@ -252,6 +266,20 @@ function handle(message) { }); }, 500); } + if (options.hangProgressLifecycle && !progressLifecycleStarted) { + progressLifecycleStarted = true; + request("window/workDoneProgress/create", { token: "stalled-index" }); + notify("$/progress", { + token: "stalled-index", + value: { kind: "begin", title: "indexing forever" }, + }); + if (process.env.SAMCHON_GRAPH_FAKE_LSP_PROGRESS_FILE) { + fs.writeFileSync( + process.env.SAMCHON_GRAPH_FAKE_LSP_PROGRESS_FILE, + "started", + ); + } + } if (lateProgressLifecycleMs > 0 && !progressLifecycleStarted) { // csharp-ls may acknowledge didOpen, then begin solution loading well // after the historical fixed 300ms grace. References stay incomplete diff --git a/tests/test-graph/src/internal/fake-ttscgraph-server.cjs b/tests/test-graph/src/internal/fake-ttscgraph-server.cjs index 5e25106..b61de15 100644 --- a/tests/test-graph/src/internal/fake-ttscgraph-server.cjs +++ b/tests/test-graph/src/internal/fake-ttscgraph-server.cjs @@ -48,6 +48,10 @@ const splitFrame = args.includes("--split-frame"); const nonJson = args.includes("--nonjson"); const unknownId = args.includes("--unknown-id"); const firstUnchanged = args.includes("--first-unchanged"); +const closeStdinAfterFirst = args.includes("--close-stdin-after-first"); +const lateAfterNonJson = args.includes("--late-after-nonjson"); +const reverseCapabilities = args.includes("--reverse-capabilities"); +const duplicateCapability = args.includes("--duplicate-capability"); const envelopeCapabilityMismatch = args.includes( "--envelope-capability-mismatch", ); @@ -59,6 +63,8 @@ const CAPABILITIES = [ "diskDigests", "diagnostics", ].filter((capability) => capability !== dropped); +if (reverseCapabilities) CAPABILITIES.reverse(); +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. @@ -225,7 +231,9 @@ if (exitSilently) { // what has to survive it. const emit = (response) => { if (nonJson) { - process.stdout.write("this is not a ttscgraph frame\n"); + process.stdout.write("this is not a ttscgraph frame\n", () => { + if (lateAfterNonJson) process.stdout.write("late retired output\n"); + }); return; } const routed = unknownId ? { ...response, id: response.id + 1000 } : response; @@ -322,6 +330,12 @@ input.on("line", (line) => { }); } emit(response); + if (closeStdinAfterFirst && requests === 1) { + input.close(); + process.stdin.destroy(); + fs.closeSync(0); + setInterval(() => undefined, 1_000); + } }); input.on("close", () => { if (marker !== undefined) fs.writeFileSync(marker, "closed\n"); From 984c31450760b297c428968d882eabc2cd8d5a2a Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Sun, 19 Jul 2026 21:38:51 +0900 Subject: [PATCH 08/16] Preserve the released overrideEdges compatibility export Restore the previously shipped `@deprecated` `overrideEdges(nodes, edges)` root export and re-export it through the indexer barrel, so downstream code importing it from `@samchon/graph` keeps compiling. The core compiler-owned indexing path does not call it. Closes #114 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018G2eNarjGmcWmEjFTW2Qcz --- packages/graph/src/indexer/index.ts | 1 + packages/graph/src/indexer/overrideEdges.ts | 71 ++++++++++++++++ ..._edges_remains_a_deprecated_root_export.ts | 83 +++++++++++++++++++ 3 files changed, 155 insertions(+) create mode 100644 packages/graph/src/indexer/overrideEdges.ts create mode 100644 tests/test-graph/src/features/test_override_edges_remains_a_deprecated_root_export.ts diff --git a/packages/graph/src/indexer/index.ts b/packages/graph/src/indexer/index.ts index e7db125..ac29ece 100644 --- a/packages/graph/src/indexer/index.ts +++ b/packages/graph/src/indexer/index.ts @@ -27,6 +27,7 @@ export * from "./languageIdOf"; export * from "./languageOf"; export * from "./markClosures"; export * from "./markIgnored"; +export * from "./overrideEdges"; export * from "./reexportsOf"; export * from "./refreshLanguageSession"; export * from "./resolveModuleFile"; diff --git a/packages/graph/src/indexer/overrideEdges.ts b/packages/graph/src/indexer/overrideEdges.ts new file mode 100644 index 0000000..8f37156 --- /dev/null +++ b/packages/graph/src/indexer/overrideEdges.ts @@ -0,0 +1,71 @@ +import { ISamchonGraphEdge, ISamchonGraphNode } from "../structures"; +import { GraphEdgeKind } from "../typings"; + +// Members whose declaration a subtype can re-declare: a method, and a +// function-valued property or field (`onClick = () => ...`), which is how a +// class satisfies an interface member without writing a method. +const IMPLEMENTATION_MEMBER_KINDS = new Set([ + "method", + "property", + "field", +]); + +/** + * Link a member to the supertype member it re-declares, given a graph that + * already carries `contains` (owner -> member) and `extends`/`implements` + * (subtype -> supertype) edges. + * + * The member-level edge mirrors the type-level relation it came from: a class + * that `implements` an interface *implements* its members, one that `extends` a + * base *overrides* them. That distinction is what `details` reports back under + * `implementedBy`, and together the two kinds are what a forward trace + * dispatches through when a call lands on a declaration with no body. + * + * @deprecated This heuristic is retained only for compatibility with the + * released root API. Core indexing uses compiler- or language-server-owned + * override facts and must not add the result of this function automatically. + */ +export function overrideEdges( + nodes: readonly ISamchonGraphNode[], + edges: readonly ISamchonGraphEdge[], +): ISamchonGraphEdge[] { + const byId = new Map(nodes.map((node) => [node.id, node])); + const membersByOwner = new Map>(); + for (const edge of edges) { + if (edge.kind !== "contains") continue; + const member = byId.get(edge.to); + if (member === undefined || !IMPLEMENTATION_MEMBER_KINDS.has(member.kind)) + continue; + let members = membersByOwner.get(edge.from); + if (members === undefined) { + members = new Map(); + membersByOwner.set(edge.from, members); + } + members.set(member.name, member); + } + + const out: ISamchonGraphEdge[] = []; + for (const edge of edges) { + const kind: GraphEdgeKind | undefined = + edge.kind === "implements" + ? "implements" + : edge.kind === "extends" + ? "overrides" + : undefined; + if (kind === undefined) continue; + const subMembers = membersByOwner.get(edge.from); + const superMembers = membersByOwner.get(edge.to); + if (subMembers === undefined || superMembers === undefined) continue; + for (const [name, subMember] of subMembers) { + const superMember = superMembers.get(name); + if (superMember === undefined) continue; + out.push({ + from: subMember.id, + to: superMember.id, + kind, + evidence: subMember.implementation ?? subMember.evidence, + }); + } + } + return out; +} diff --git a/tests/test-graph/src/features/test_override_edges_remains_a_deprecated_root_export.ts b/tests/test-graph/src/features/test_override_edges_remains_a_deprecated_root_export.ts new file mode 100644 index 0000000..8af04ea --- /dev/null +++ b/tests/test-graph/src/features/test_override_edges_remains_a_deprecated_root_export.ts @@ -0,0 +1,83 @@ +import { + ISamchonGraphEdge, + ISamchonGraphNode, + overrideEdges, +} from "@samchon/graph"; +import { TestValidator } from "@nestia/e2e"; + +export const test_override_edges_remains_a_deprecated_root_export = () => { + const node = ( + id: string, + kind: ISamchonGraphNode["kind"], + name: string, + implementation = false, + ): ISamchonGraphNode => ({ + id, + kind, + language: "typescript", + name, + file: "src/example.ts", + external: false, + evidence: { file: "src/example.ts", startLine: 1 }, + ...(implementation + ? { implementation: { file: "src/example.ts", startLine: 2 } } + : {}), + }); + const nodes = [ + node("Contract", "interface", "Contract"), + node("Contract.run", "method", "run"), + node("Contract.value", "property", "value"), + node("Base", "class", "Base"), + node("Base.save", "method", "save"), + node("Worker", "class", "Worker"), + node("Worker.run", "method", "run", true), + node("Worker.value", "field", "value"), + node("Worker.extra", "method", "extra"), + node("Child", "class", "Child"), + node("Child.save", "method", "save"), + node("Orphan", "class", "Orphan"), + node("Orphan.run", "method", "run"), + ]; + const edges: ISamchonGraphEdge[] = [ + { from: "module", to: "Contract", kind: "imports" }, + { from: "Contract", to: "missing", kind: "contains" }, + { from: "Contract", to: "Base", kind: "contains" }, + { from: "Contract", to: "Contract.run", kind: "contains" }, + { from: "Contract", to: "Contract.value", kind: "contains" }, + { from: "Base", to: "Base.save", kind: "contains" }, + { from: "Worker", to: "Worker.run", kind: "contains" }, + { from: "Worker", to: "Worker.value", kind: "contains" }, + { from: "Worker", to: "Worker.extra", kind: "contains" }, + { from: "Child", to: "Child.save", kind: "contains" }, + { from: "Orphan", to: "Orphan.run", kind: "contains" }, + { from: "Worker", to: "Contract", kind: "implements" }, + { from: "Child", to: "Base", kind: "extends" }, + { from: "MissingSubtype", to: "Contract", kind: "implements" }, + { from: "Orphan", to: "MissingSupertype", kind: "extends" }, + ]; + + TestValidator.equals( + "the released root helper remains callable with its original behavior", + overrideEdges(nodes, edges), + [ + { + from: "Worker.run", + to: "Contract.run", + kind: "implements", + evidence: { file: "src/example.ts", startLine: 2 }, + }, + { + from: "Worker.value", + to: "Contract.value", + kind: "implements", + evidence: { file: "src/example.ts", startLine: 1 }, + }, + { + from: "Child.save", + to: "Base.save", + kind: "overrides", + evidence: { file: "src/example.ts", startLine: 1 }, + }, + ], + ); +}; From 3416d3a3aff5708887300088462517d32e8e0748 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Sun, 19 Jul 2026 21:38:53 +0900 Subject: [PATCH 09/16] Reconnect cross-file Swift extension members to their owner Collect Swift `extension` declarations project-wide and emit conformance edges so members declared in a separate file rejoin their owning type; extend inheritance-edge evidence and accept `enum` conformances. Ambiguous receivers stay unassigned. Closes #112 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018G2eNarjGmcWmEjFTW2Qcz --- .../src/indexer/staticGraphParts.ts | 73 +++++++++++++++++-- ..._extensions_preserve_semantic_ownership.ts | 27 ++++++- ...inheritance_links_classes_and_protocols.ts | 13 ++++ 3 files changed, 106 insertions(+), 7 deletions(-) diff --git a/packages/graph-sitter/src/indexer/staticGraphParts.ts b/packages/graph-sitter/src/indexer/staticGraphParts.ts index d68e891..8fddf84 100644 --- a/packages/graph-sitter/src/indexer/staticGraphParts.ts +++ b/packages/graph-sitter/src/indexer/staticGraphParts.ts @@ -39,6 +39,13 @@ interface IDeclaration { ownerName?: string; } +interface ISwiftExtension { + file: string; + receiverName: string; + header: string; + evidence: ISamchonGraphEvidence; +} + interface IParsedDeclaration { kind: GraphNodeKind; name: string; @@ -71,6 +78,7 @@ export function graphSitterParts( const edges: ISamchonGraphEdge[] = []; const sources = new Map(); const declarationsByFile = new Map(); + const swiftExtensions: ISwiftExtension[] = []; const byName = new Map(); const externalNodes = new Map(); const absolutePathByRelativePath = new Map( @@ -88,7 +96,12 @@ export function graphSitterParts( const lines = text.split(/\r?\n/); const rel = input.relativePath; const language = input.language; - const declarations = declarationsOf(rel, language, lines); + const declarations = declarationsOf( + rel, + language, + lines, + swiftExtensions, + ); declarationsByFile.set(rel, declarations); for (const declaration of declarations) { nodes.push(declaration.node); @@ -116,7 +129,12 @@ export function graphSitterParts( } connectProjectWideCppOwners(declarationsByFile, edges); - connectProjectWideSwiftOwners(declarationsByFile, edges); + connectProjectWideSwiftOwners( + declarationsByFile, + swiftExtensions, + byName, + edges, + ); for (const [rel, declarations] of declarationsByFile) { const abs = absolutePathByRelativePath.get(rel)!; @@ -249,6 +267,7 @@ function declarationsOf( file: string, language: GraphLanguage, lines: readonly string[], + swiftExtensions: ISwiftExtension[], ): IDeclaration[] { const declarations: IDeclaration[] = []; // Owners that a whole-file scan names rather than nests, indexed by the @@ -326,6 +345,17 @@ function declarationsOf( // `impl` block does, resolved below once the file has been scanned. if (parsed.extensionOwner !== undefined) { const endIndex = declarationEndIndexOf(language, lexicalLines, i); + swiftExtensions.push({ + file, + receiverName: parsed.extensionOwner, + header: header.trim(), + evidence: { + file, + startLine: i + 1, + startCol: Math.max(1, line.indexOf(parsed.extensionOwner) + 1), + endLine: i + 1, + }, + }); if (endIndex > i) { const owner = swiftTypes.get(parsed.extensionOwner); ownerStack.push({ @@ -657,9 +687,15 @@ function connectProjectWideCppOwners( } } -/** Connect a Swift extension to its uniquely declared cross-file receiver. */ +/** + * Connect Swift extension members and conformance headers to one resolved + * receiver. A same-file declaration is authoritative when unique there; + * otherwise the qualified receiver must be unique across the project. + */ function connectProjectWideSwiftOwners( declarationsByFile: ReadonlyMap, + extensions: readonly ISwiftExtension[], + byName: Map, edges: ISamchonGraphEdge[], ): void { const declarations = [...declarationsByFile.values()] @@ -688,6 +724,27 @@ function connectProjectWideSwiftOwners( evidence: declaration.node.evidence, }); } + for (const extension of extensions) { + const candidates = types.get(extension.receiverName) ?? []; + const local = candidates.filter( + (candidate) => candidate.node.file === extension.file, + ); + const owner = + local.length === 1 + ? local[0] + : local.length === 0 && candidates.length === 1 + ? candidates[0] + : undefined; + if (owner === undefined) continue; + edges.push( + ...inheritanceEdges( + owner.node, + extension.header, + byName, + extension.evidence, + ), + ); + } } function cppQualifiedOwners( @@ -1305,8 +1362,14 @@ function inheritanceEdges( source: ISamchonGraphNode, header: string, byName: Map, + evidence: ISamchonGraphEvidence | undefined = source.evidence, ): ISamchonGraphEdge[] { - if (source.kind !== "class" && source.kind !== "interface") return []; + if ( + source.kind !== "class" && + source.kind !== "interface" && + !(source.language === "swift" && source.kind === "enum") + ) + return []; const out: ISamchonGraphEdge[] = []; const seen = new Set(); for (const supertype of source.language === "swift" @@ -1331,7 +1394,7 @@ function inheritanceEdges( from: source.id, to: target.id, kind: relation, - evidence: source.evidence, + evidence, }); } return out; diff --git a/tests/test-graph/src/features/test_swift_cross_file_extensions_preserve_semantic_ownership.ts b/tests/test-graph/src/features/test_swift_cross_file_extensions_preserve_semantic_ownership.ts index 6e72c7f..cbd4603 100644 --- a/tests/test-graph/src/features/test_swift_cross_file_extensions_preserve_semantic_ownership.ts +++ b/tests/test-graph/src/features/test_swift_cross_file_extensions_preserve_semantic_ownership.ts @@ -14,9 +14,10 @@ export const test_swift_cross_file_extensions_preserve_semantic_ownership = }; write("Box.swift", "struct Box {}\n"); + write("Runner.swift", "protocol Runner {}\n"); write( "Box+Extension.swift", - ["extension Box {", " func doubled() -> Int { 2 }", "}"].join("\n"), + ["extension Box: Runner {", " func doubled() -> Int { 2 }", "}"].join("\n"), ); write( "Local.swift", @@ -32,7 +33,7 @@ export const test_swift_cross_file_extensions_preserve_semantic_ownership = write("SharedB.swift", "struct Shared {}\n"); write( "Shared+Extension.swift", - ["extension Shared {", " func ambiguous() {}", "}"].join("\n"), + ["extension Shared: Runner {", " func ambiguous() {}", "}"].join("\n"), ); const dump = await buildGraphDump({ @@ -53,12 +54,23 @@ export const test_swift_cross_file_extensions_preserve_semantic_ownership = ); const box = node("Box.swift", "Box"); + const runner = node("Runner.swift", "Runner"); const doubled = node("Box+Extension.swift", "doubled"); TestValidator.predicate( "a cross-file Swift extension attaches its method to the unique receiver", doubled?.qualifiedName === "Box.doubled" && containedBy(box?.id, doubled.id), ); + TestValidator.predicate( + "a cross-file Swift extension preserves conformance on its unique receiver", + dump.edges.some( + (edge) => + edge.kind === "implements" && + edge.from === box?.id && + edge.to === runner?.id && + edge.evidence?.file === "Box+Extension.swift", + ), + ); const local = node("Local.swift", "Local"); const otherLocal = node("OtherLocal.swift", "Local"); @@ -81,4 +93,15 @@ export const test_swift_cross_file_extensions_preserve_semantic_ownership = !containedBy(candidate.id, ambiguous.id) ), ); + TestValidator.predicate( + "an ambiguous Swift extension does not assign conformance to either receiver", + shared.every((candidate) => + !dump.edges.some( + (edge) => + edge.kind === "implements" && + edge.from === candidate.id && + edge.to === runner?.id, + ) + ), + ); }; diff --git a/tests/test-graph/src/features/test_swift_static_inheritance_links_classes_and_protocols.ts b/tests/test-graph/src/features/test_swift_static_inheritance_links_classes_and_protocols.ts index 61dd0ea..b9188a1 100644 --- a/tests/test-graph/src/features/test_swift_static_inheritance_links_classes_and_protocols.ts +++ b/tests/test-graph/src/features/test_swift_static_inheritance_links_classes_and_protocols.ts @@ -22,6 +22,7 @@ export const test_swift_static_inheritance_links_classes_and_protocols = "class Base {}", "protocol Runner {}", "class Child: Base, Runner {}", + "enum Outcome: Runner { case ready }", ].join("\n"), ); const dump = await buildGraphDump({ @@ -37,6 +38,9 @@ export const test_swift_static_inheritance_links_classes_and_protocols = (node) => node.name === "Base" && node.kind === "class", ); const runner = dump.nodes.find((node) => node.name === "Runner"); + const outcome = dump.nodes.find( + (node) => node.name === "Outcome" && node.kind === "enum", + ); TestValidator.predicate( "a swift class extends the class in its inheritance list", @@ -56,4 +60,13 @@ export const test_swift_static_inheritance_links_classes_and_protocols = edge.to === runner?.id, ), ); + TestValidator.predicate( + "a swift enum preserves its protocol conformance", + dump.edges.some( + (edge) => + edge.kind === "implements" && + edge.from === outcome?.id && + edge.to === runner?.id, + ), + ); }; From 4c9588954832fdaccbbb22f35cb69e0bea5149bb Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Sun, 19 Jul 2026 21:39:04 +0900 Subject: [PATCH 10/16] Refuse the published schema-3 ttscgraph snapshot as an unproved manifest Narrow `SUPPORTED_DUMP_SCHEMA_VERSIONS` to [5] and delete the schema-3 edge/objectMember compatibility gates: a schema-3 dump is refused before it can seed the graph, and the client falls back honestly to LSP while still returning project declarations. Also adds a canonical `C:/...` manifest fixture so Linux exercises the Windows-drive identity branch (#113). Closes #108 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018G2eNarjGmcWmEjFTW2Qcz --- .../provider/ttscgraph/ITtscGraphSnapshot.ts | 21 ++--- .../provider/ttscgraph/adaptTtscGraphDump.ts | 44 --------- ...ph_dump_adapter_rejects_malformed_facts.ts | 90 ++++--------------- ...h_published_schema3_falls_back_honestly.ts | 53 +++++++++++ ...3_is_an_explicit_compatibility_snapshot.ts | 65 -------------- 5 files changed, 82 insertions(+), 191 deletions(-) create mode 100644 tests/test-graph/src/features/test_ttscgraph_published_schema3_falls_back_honestly.ts delete mode 100644 tests/test-graph/src/features/test_ttscgraph_published_schema3_is_an_explicit_compatibility_snapshot.ts diff --git a/packages/graph/src/provider/ttscgraph/ITtscGraphSnapshot.ts b/packages/graph/src/provider/ttscgraph/ITtscGraphSnapshot.ts index e4ddf34..3de825d 100644 --- a/packages/graph/src/provider/ttscgraph/ITtscGraphSnapshot.ts +++ b/packages/graph/src/provider/ttscgraph/ITtscGraphSnapshot.ts @@ -102,22 +102,23 @@ export namespace ITtscGraphSnapshot { * Independent of {@link PROTOCOL_VERSION}: one versions the NDJSON envelope, * the other the graph document inside a changed frame. Keep this equal to * `DumpSchemaVersion` in ttsc's `internal/graph/provenance.go` at canonical - * commit `2b724664e`. That schema is newer than the latest published ttsc at - * the time of this pin, so normal resolution fails closed and callers may - * provide that exact binary through `TTSC_GRAPH_BINARY` until it is released. + * commit `4c5e11eab`. That revision also makes `SourceTexts` cover every + * resident `TSProgram` source, including external declarations and virtual + * bundled libraries, so the schema's manifest can attest every emitted fact. + * Until that revision is released, callers may provide its exact binary + * through `TTSC_GRAPH_BINARY`; older resolved binaries fail closed. */ export const DUMP_SCHEMA_VERSION = 5; /** - * Body schemas this adapter can read without inventing missing facts. + * Body schemas whose manifest proves every fact the adapter receives. * - * Schema 3 is the latest published producer's contract. It already carries - * the one-Program manifest, diagnostics, literals, and enum members, but - * predates checker-owned member relations and object-literal member facts. - * The adapter accepts it as an explicitly warned compatibility snapshot; - * schema 5 remains the complete canonical contract. + * Schema 3 can emit compiler and library facts whose files are absent from + * its source manifest, so accepting it would make the adapter choose between + * dropping semantic facts and trusting an unproved snapshot. Schema 5 makes + * the manifest complete and is therefore the minimum honest contract. */ - export const SUPPORTED_DUMP_SCHEMA_VERSIONS: readonly number[] = [3, 5]; + export const SUPPORTED_DUMP_SCHEMA_VERSIONS: readonly number[] = [5]; /** * What the compiler did, as opposed to what the transport did. diff --git a/packages/graph/src/provider/ttscgraph/adaptTtscGraphDump.ts b/packages/graph/src/provider/ttscgraph/adaptTtscGraphDump.ts index 621e273..63d583a 100644 --- a/packages/graph/src/provider/ttscgraph/adaptTtscGraphDump.ts +++ b/packages/graph/src/provider/ttscgraph/adaptTtscGraphDump.ts @@ -70,11 +70,6 @@ export function adaptTtscGraphDump( ); } const warnings: string[] = []; - if (schemaVersion !== ITtscGraphSnapshot.DUMP_SCHEMA_VERSION) { - warnings.push( - `typescript: ttscgraph schema v${String(schemaVersion)} compatibility snapshot predates checker-owned member implements/overrides and object-literal member facts; those facts are absent. Use a schema v${String(ITtscGraphSnapshot.DUMP_SCHEMA_VERSION)} producer through TTSC_GRAPH_BINARY for the complete canonical graph.`, - ); - } const project = stringOf(dump.project, "dump.project"); if (!samePath(project, expectedRoot)) { throw new Error( @@ -85,7 +80,6 @@ export function adaptTtscGraphDump( const rawEdges = arrayOf(dump.edges, "dump.edges"); const moduleIds = new Map(); const rawIds = new Set(); - const nodeKindById = new Map(); const sourceFileById = new Map(); const nodes: ISamchonGraphNode[] = []; const factFiles = new Set(); @@ -96,7 +90,6 @@ export function adaptTtscGraphDump( if (rawIds.has(id)) throw new Error(`ttscgraph: duplicate node id: ${id}`); rawIds.add(id); const kind = nodeKindOf(raw.kind, `dump.nodes[${index}].kind`); - nodeKindById.set(id, kind); const file = stringOf(raw.file, `dump.nodes[${index}].file`); const external = booleanOf(raw.external, `dump.nodes[${index}].external`); validateGraphFile(file, `dump.nodes[${index}].file`, external); @@ -152,11 +145,6 @@ export function adaptTtscGraphDump( node.enumMembers = enumMembersOf(raw.enumMembers, id); } if (raw.objectMembers !== undefined) { - if (schemaVersion !== ITtscGraphSnapshot.DUMP_SCHEMA_VERSION) { - throw new Error( - `ttscgraph: ${id}.objectMembers is not part of schema v${String(schemaVersion)}`, - ); - } if (kind !== "variable") { throw new Error( `ttscgraph: ${id}.objectMembers is only valid on variable nodes`, @@ -219,13 +207,6 @@ export function adaptTtscGraphDump( } const from = moduleIds.get(rawFrom) ?? rawFrom; const kind = edgeKindOf(raw.kind, `dump.edges[${index}].kind`); - validateEdgeSchema( - schemaVersion as number, - kind, - nodeKindById.get(rawFrom)!, - nodeKindById.get(rawTo)!, - `dump.edges[${index}]`, - ); const key = `${kind}\0${from}\0${rawTo}`; if (edgeKeys.has(key)) { throw new Error( @@ -631,31 +612,6 @@ function edgeKindOf(value: unknown, label: string): GraphEdgeKind { return kind; } -function validateEdgeSchema( - schemaVersion: number, - kind: GraphEdgeKind, - fromKind: GraphNodeKind, - toKind: GraphNodeKind, - label: string, -): void { - if (schemaVersion === ITtscGraphSnapshot.DUMP_SCHEMA_VERSION) return; - // Schema v3 used `implements` only for class heritage. Schema v5 added the - // checker-owned member form and introduced `overrides` alongside it. - if (kind === "overrides") { - throw new Error( - `ttscgraph: ${label} overrides is not part of schema v${String(schemaVersion)}`, - ); - } - const containerImplements = - fromKind === "class" && - (toKind === "class" || toKind === "interface" || toKind === "type"); - if (kind === "implements" && !containerImplements) { - throw new Error( - `ttscgraph: ${label} member implements is not part of schema v${String(schemaVersion)} (${fromKind} -> ${toKind})`, - ); - } -} - function modifierOf( value: unknown, label: string, 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 cff3a18..679f2ae 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 @@ -104,92 +104,31 @@ export const test_ttscgraph_dump_adapter_rejects_malformed_facts = async () => { "a well-formed dump adapts cleanly", adaptTtscGraphDump(good(), project).nodes.length === 2, ); - const compatible = adaptTtscGraphDump( - mutate((d) => (d.provenance.schemaVersion = 3)), - project, - ); - TestValidator.predicate( - "published schema 3 is accepted with its missing canonical facts stated", - compatible.warnings.some( - (warning) => - warning.includes("schema v3 compatibility snapshot") && - warning.includes("object-literal member facts"), - ), - ); rejectsWithMessage( () => adaptTtscGraphDump( mutate((d) => { d.provenance.schemaVersion = 3; - (d.nodes[1] as { objectMembers?: unknown }).objectMembers = []; }), project, ), - "schema 3 object members", - "objectMembers is not part of schema v3", - ); - const compatibleHeritage = relationDump(3); - compatibleHeritage.edges.push({ - from: "src/a.ts#Worker:class", - to: "src/a.ts#Contract:interface", - kind: "implements", - }); - TestValidator.predicate( - "schema 3 retains container implements heritage", - adaptTtscGraphDump(compatibleHeritage, project).edges.some( - (edge) => - edge.kind === "implements" && - edge.from === "src/a.ts#Worker:class" && - edge.to === "src/a.ts#Contract:interface", - ), - ); - const compatibleAliasHeritage = relationDump(3); - compatibleAliasHeritage.nodes.push({ - id: "src/a.ts#ContractAlias:type", - kind: "type", - name: "ContractAlias", - file: "src/a.ts", - external: false, - }); - compatibleAliasHeritage.edges.push({ - from: "src/a.ts#Worker:class", - to: "src/a.ts#ContractAlias:type", - kind: "implements", - }); - TestValidator.predicate( - "schema 3 retains class-to-type-alias implements heritage", - adaptTtscGraphDump(compatibleAliasHeritage, project).edges.some( - (edge) => - edge.kind === "implements" && - edge.from === "src/a.ts#Worker:class" && - edge.to === "src/a.ts#ContractAlias:type", - ), - ); - rejectsWithMessage( - () => { - const dump = relationDump(3); - dump.edges.push({ - from: "src/a.ts#Worker.execute:method", - to: "src/a.ts#Contract.execute:method", - kind: "implements", - }); - return adaptTtscGraphDump(dump, project); - }, - "schema 3 member implements", - "member implements is not part of schema v3", + "schema 3 snapshot", + "dump is schema v3, this client reads v5", ); rejectsWithMessage( () => { const dump = relationDump(3); - dump.edges.push({ - from: "src/a.ts#Worker.execute:method", - to: "src/a.ts#Contract.execute:method", - kind: "overrides", + dump.nodes.push({ + id: "vendor/typescript/lib/lib.es5.d.ts#Array:interface", + kind: "interface", + name: "Array", + file: "vendor/typescript/lib/lib.es5.d.ts", + external: true, }); return adaptTtscGraphDump(dump, project); }, - "schema 3 overrides", - "overrides is not part of schema v3", + "schema 3 snapshot carrying an external fact absent from its manifest", + "dump is schema v3, this client reads v5", ); const currentRelations = relationDump(5); currentRelations.edges.push( @@ -242,6 +181,7 @@ export const test_ttscgraph_dump_adapter_rejects_malformed_facts = async () => { const bundledFile = "bundled:///libs/lib.es2015.collection.d.ts"; const uncFile = "//server/share/types/external.d.ts"; const posixFile = "/opt/types/external.d.ts"; + const windowsFile = "C:/workspace/types/external.d.ts"; const completeManifest = good(); completeManifest.provenance.sources.push( { @@ -264,6 +204,11 @@ export const test_ttscgraph_dump_adapter_rejects_malformed_facts = async () => { checkerDigest: sha(`${posixFile}:checker`), diskDigest: sha(`${posixFile}:disk`), }, + { + file: windowsFile, + checkerDigest: sha(`${windowsFile}:checker`), + diskDigest: sha(`${windowsFile}:disk`), + }, ); const completeSources = adaptTtscGraphDump( completeManifest, @@ -275,7 +220,8 @@ export const test_ttscgraph_dump_adapter_rejects_malformed_facts = async () => { completeSources.has(crossRootFile) && completeSources.has(bundledFile) && completeSources.has(uncFile) && - completeSources.has(posixFile), + completeSources.has(posixFile) && + completeSources.has(windowsFile), ); TestValidator.equals( "a virtual bundled source keeps its checker digest", 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 new file mode 100644 index 0000000..081bed0 --- /dev/null +++ b/tests/test-graph/src/features/test_ttscgraph_published_schema3_falls_back_honestly.ts @@ -0,0 +1,53 @@ +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 published schema 3 snapshot is refused before the LSP fallback", + dump.warnings?.some( + (warning) => + warning.includes("bulk indexing failed") && + warning.includes("dump is schema v3, this client reads v5"), + ) === true && + dump.warnings.every( + (warning) => !warning.includes("schema v3 compatibility snapshot"), + ), + ); + TestValidator.predicate( + "the fallback still 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_published_schema3_is_an_explicit_compatibility_snapshot.ts b/tests/test-graph/src/features/test_ttscgraph_published_schema3_is_an_explicit_compatibility_snapshot.ts deleted file mode 100644 index caa1933..0000000 --- a/tests/test-graph/src/features/test_ttscgraph_published_schema3_is_an_explicit_compatibility_snapshot.ts +++ /dev/null @@ -1,65 +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_is_an_explicit_compatibility_snapshot = - 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";', - 'export enum Phase { Ready = "ready", Done = "done" }', - 'export const options = { host: "localhost", connect() {} };', - ].join("\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 schema 3 provider remains compiler-owned rather than falling back", - dump.warnings?.some((warning) => - warning.includes("schema v3 compatibility snapshot"), - ) === true && - dump.warnings.every( - (warning) => !warning.includes("bulk indexing failed"), - ), - ); - TestValidator.predicate( - "schema 3 retains its compiler-resolved literal facts", - dump.nodes.some( - (node) => - node.name === "Status" && - node.literals?.includes('"ready"') === true, - ), - ); - TestValidator.equals( - "schema 3 does not fabricate schema 5 object-member facts", - dump.nodes.find((node) => node.name === "options")?.objectMembers, - undefined, - ); - } finally { - if (previous === undefined) delete process.env.TTSC_GRAPH_BINARY; - else process.env.TTSC_GRAPH_BINARY = previous; - } - }; From 17af77c3c91143595d9b86d7c2018633af01c052 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Sun, 19 Jul 2026 21:39:32 +0900 Subject: [PATCH 11/16] Sanitize mixed canonical file identities without node-order dependence Reduce absolute paths outside the root to a normalized identity even when no shared root exists, and reroot only when every project file is absolute, so the same input yields the same identities regardless of node order. The package reducer and benchmark viewer reducer stay byte-aligned; structure docs adopt the shared relative / normalized-absolute / `bundled:///` identity vocabulary with matching parity substitutions. Closes #115 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018G2eNarjGmcWmEjFTW2Qcz --- packages/graph/src/reduce.ts | 42 +++--- .../src/structures/ISamchonGraphDetails.ts | 6 +- .../src/structures/ISamchonGraphDiagnostic.ts | 6 +- .../graph/src/structures/ISamchonGraphDump.ts | 8 +- .../structures/ISamchonGraphEntrypoints.ts | 4 +- .../src/structures/ISamchonGraphEvidence.ts | 6 +- .../src/structures/ISamchonGraphLookup.ts | 2 +- .../graph/src/structures/ISamchonGraphNode.ts | 6 +- .../src/structures/ISamchonGraphOverview.ts | 4 +- .../graph/src/structures/ISamchonGraphSpan.ts | 4 +- .../graph/src/structures/ISamchonGraphTour.ts | 6 +- .../src/structures/ISamchonGraphTrace.ts | 4 +- tests/benchmark/graph/viewer.mjs | 47 ++++--- ...k_viewer_reduce_matches_package_reducer.ts | 60 ++++++++- ...reduce_preserves_the_reference_contract.ts | 56 ++++++-- .../test-graph/src/internal/ContractParity.ts | 122 +++++++++++++++++- 16 files changed, 313 insertions(+), 70 deletions(-) diff --git a/packages/graph/src/reduce.ts b/packages/graph/src/reduce.ts index a8ef550..3d86eba 100644 --- a/packages/graph/src/reduce.ts +++ b/packages/graph/src/reduce.ts @@ -68,6 +68,7 @@ function isWindowsPath(p: string): boolean { function directoryOf(file: string): string { const normalized = posix(file).replace(/\/+$/, ""); const slash = normalized.lastIndexOf("/"); + /* c8 ignore next -- callers pass absolute file identities, never a slashless value. */ if (slash < 0) return ""; return slash === 0 ? "/" : normalized.slice(0, slash); } @@ -92,15 +93,21 @@ function commonRoot(directories: string[]): string { return parts.join("/"); } -// A null root means the dump's paths are already project-relative (the current -// `samchon-graph dump` contract); they pass through structure-intact. -function relativize(abs: string, root: string | null): string { - const a = posix(abs); - if (root === null) return a; +// A null root means at least one project identity is already relative. Preserve +// those current identities, while still sanitizing an absolute compiler-loaded +// sibling through the same outside-root policy as a legacy dump. +function relativize(file: string, root: string | null): string { + const normalized = posix(file); + if (root === null) + return isAbsolute(normalized) + ? outsideRootPath(normalized) + : normalized; const normalizedRoot = posix(root); const r = normalizedRoot === "/" ? "/" : normalizedRoot.replace(/\/+$/, ""); - const caseInsensitive = isWindowsPath(a) && isWindowsPath(r); - const comparedPath = caseInsensitive ? a.toLowerCase() : a; + const caseInsensitive = isWindowsPath(normalized) && isWindowsPath(r); + const comparedPath = caseInsensitive + ? normalized.toLowerCase() + : normalized; const comparedRoot = caseInsensitive ? r.toLowerCase() : r; if ( comparedRoot && @@ -108,11 +115,15 @@ function relativize(abs: string, root: string | null): string { comparedPath === comparedRoot || comparedPath.startsWith(comparedRoot + "/")) ) - return a.slice(r.length).replace(/^\/+/, ""); - const nm = a.lastIndexOf("node_modules/"); - if (nm >= 0) return a.slice(nm); - const slash = a.lastIndexOf("/"); - return slash >= 0 ? a.slice(slash + 1) : a; + return normalized.slice(r.length).replace(/^\/+/, ""); + return outsideRootPath(normalized); +} + +function outsideRootPath(file: string): string { + const nodeModules = file.lastIndexOf("node_modules/"); + if (nodeModules >= 0) return file.slice(nodeModules); + const slash = file.lastIndexOf("/"); + return slash >= 0 ? file.slice(slash + 1) : file; } function rewriteId(id: string, root: string | null): string { @@ -162,11 +173,12 @@ export function reduce( }: { maxNodes?: number; keepExternal?: boolean } = {}, ): ViewerPayload { const keptByExternal = raw.nodes.filter((n) => keepExternal || !n.external); - // Reroot only absolute paths (the legacy dump contract); a current dump's - // paths are already project-relative and keep their structure as-is. + // Reroot only when every authored identity is absolute (the legacy dump + // contract). A current dump can mix relative project files with canonical + // absolute compiler-loaded siblings; that form is handled per file below. const projectFiles = raw.nodes.filter((n) => !n.external).map((n) => n.file); const root = - projectFiles.length > 0 && isAbsolute(projectFiles[0]!) + projectFiles.length > 0 && projectFiles.every(isAbsolute) ? commonRoot(projectFiles.map(directoryOf)) : null; diff --git a/packages/graph/src/structures/ISamchonGraphDetails.ts b/packages/graph/src/structures/ISamchonGraphDetails.ts index 1563f57..a6cf5fc 100644 --- a/packages/graph/src/structures/ISamchonGraphDetails.ts +++ b/packages/graph/src/structures/ISamchonGraphDetails.ts @@ -102,7 +102,7 @@ export namespace ISamchonGraphDetails { /** Declaration kind (`class`, `method`, `function`, ...). */ kind: string; - /** Project-relative path of the file that declares this node. */ + /** Declaration identity: project-relative, normalized absolute, or `bundled:///`. */ file: string; /** 1-based declaration line, when known. */ @@ -120,7 +120,7 @@ export namespace ISamchonGraphDetails { /** Declaration kind (`class`, `method`, `function`, ...). */ kind: string; - /** Project-relative path of the file that declares this node. */ + /** Declaration identity: project-relative, normalized absolute, or `bundled:///`. */ file: string; /** 1-based declaration line, when known. */ @@ -200,7 +200,7 @@ export namespace ISamchonGraphDetails { /** Neighbor declaration kind. */ kind: string; - /** Project-relative declaration file for the neighbor. */ + /** Neighbor identity: project-relative, normalized absolute, or `bundled:///`. */ file: string; /** 1-based declaration line, when known. */ diff --git a/packages/graph/src/structures/ISamchonGraphDiagnostic.ts b/packages/graph/src/structures/ISamchonGraphDiagnostic.ts index 6017dca..665ef1d 100644 --- a/packages/graph/src/structures/ISamchonGraphDiagnostic.ts +++ b/packages/graph/src/structures/ISamchonGraphDiagnostic.ts @@ -10,7 +10,11 @@ * does, and it is cheaper to delete than to defend. */ export interface ISamchonGraphDiagnostic { - /** Project-relative path of the file the diagnostic is reported in. */ + /** + * Graph file identity the diagnostic names, or `""` for a global finding. + * Project files are relative; compiler-loaded out-of-root and virtual files + * retain normalized absolute or `bundled:///` identities. + */ file: string; /** 1-based line of the diagnostic. */ diff --git a/packages/graph/src/structures/ISamchonGraphDump.ts b/packages/graph/src/structures/ISamchonGraphDump.ts index c3b378c..010234a 100644 --- a/packages/graph/src/structures/ISamchonGraphDump.ts +++ b/packages/graph/src/structures/ISamchonGraphDump.ts @@ -18,8 +18,12 @@ import { ISamchonGraphSpan } from "./ISamchonGraphSpan"; * here records when it was built — a timestamp would move under an unchanged * source, which is exactly the property a cache and a diff depend on. * - * Paths in `project` are absolute; `file` fields on nodes, edges, and - * diagnostics are project-relative. + * `project` is absolute. A graph file identity uses normalized forward slashes: + * a project-owned file is relative to `project`, a compiler-loaded file outside + * that root keeps its normalized absolute identity, and a virtual compiler + * library keeps its `bundled:///` identity. The same identity flows through a + * node's `file` and id prefix, reconstructed edge evidence, diagnostics, and + * operation results. */ export interface ISamchonGraphDump { /** Absolute path of the project root the graph was built for. */ diff --git a/packages/graph/src/structures/ISamchonGraphEntrypoints.ts b/packages/graph/src/structures/ISamchonGraphEntrypoints.ts index 734ef5f..6b65be6 100644 --- a/packages/graph/src/structures/ISamchonGraphEntrypoints.ts +++ b/packages/graph/src/structures/ISamchonGraphEntrypoints.ts @@ -63,7 +63,7 @@ export namespace ISamchonGraphEntrypoints { /** Declaration kind (`class`, `method`, `function`, ...). */ kind: string; - /** Project-relative path of the declaration file. */ + /** Declaration identity: project-relative, normalized absolute, or `bundled:///`. */ file: string; /** 1-based declaration line, when known. */ @@ -114,7 +114,7 @@ export namespace ISamchonGraphEntrypoints { /** Neighbor declaration kind. */ kind: string; - /** Project-relative declaration file for the neighbor. */ + /** Neighbor identity: project-relative, normalized absolute, or `bundled:///`. */ file: string; /** 1-based declaration line, when known. */ diff --git a/packages/graph/src/structures/ISamchonGraphEvidence.ts b/packages/graph/src/structures/ISamchonGraphEvidence.ts index cf27a22..6462b45 100644 --- a/packages/graph/src/structures/ISamchonGraphEvidence.ts +++ b/packages/graph/src/structures/ISamchonGraphEvidence.ts @@ -6,7 +6,11 @@ * as coordinates, so read the file yourself when you truly need source text. */ export interface ISamchonGraphEvidence { - /** Project-relative path of the file the span lives in. */ + /** + * Graph file identity of the span: normally project-relative, but normalized + * absolute for a compiler-loaded out-of-root file or `bundled:///` for a + * virtual library. + */ file: string; /** 1-based line where the span starts. */ diff --git a/packages/graph/src/structures/ISamchonGraphLookup.ts b/packages/graph/src/structures/ISamchonGraphLookup.ts index fa61e06..d15904b 100644 --- a/packages/graph/src/structures/ISamchonGraphLookup.ts +++ b/packages/graph/src/structures/ISamchonGraphLookup.ts @@ -51,7 +51,7 @@ export namespace ISamchonGraphLookup { /** Declaration kind (`class`, `method`, `function`, ...). */ kind: string; - /** Project-relative path of the declaration file. */ + /** Declaration identity: project-relative, normalized absolute, or `bundled:///`. */ file: string; /** 1-based declaration line, when known. */ diff --git a/packages/graph/src/structures/ISamchonGraphNode.ts b/packages/graph/src/structures/ISamchonGraphNode.ts index bf5645e..018adfb 100644 --- a/packages/graph/src/structures/ISamchonGraphNode.ts +++ b/packages/graph/src/structures/ISamchonGraphNode.ts @@ -33,7 +33,11 @@ export interface ISamchonGraphNode { */ qualifiedName?: string; - /** Project-relative path of the file that declares this node. */ + /** + * Graph file identity of the declaration. Project-owned files are + * project-relative; compiler-loaded files outside the root keep normalized + * absolute identities, and virtual libraries use `bundled:///`. + */ file: string; /** diff --git a/packages/graph/src/structures/ISamchonGraphOverview.ts b/packages/graph/src/structures/ISamchonGraphOverview.ts index ebc960b..331ee33 100644 --- a/packages/graph/src/structures/ISamchonGraphOverview.ts +++ b/packages/graph/src/structures/ISamchonGraphOverview.ts @@ -57,7 +57,7 @@ export namespace ISamchonGraphOverview { /** One folder layer: its source files and export surface. */ export interface ILayer { - /** Directory, project-relative. */ + /** Directory identity: project-relative or normalized absolute. */ dir: string; /** Distinct source files under it. */ files: number; @@ -73,7 +73,7 @@ export namespace ISamchonGraphOverview { name: string; /** Its declaration kind (`class`, `interface`, `function`, ...). */ kind: string; - /** Project-relative path of the file that declares it. */ + /** Declaration identity: project-relative, normalized absolute, or `bundled:///`. */ file: string; /** 1-based declaration line, when known. */ line?: number; diff --git a/packages/graph/src/structures/ISamchonGraphSpan.ts b/packages/graph/src/structures/ISamchonGraphSpan.ts index b606d77..7f75ff7 100644 --- a/packages/graph/src/structures/ISamchonGraphSpan.ts +++ b/packages/graph/src/structures/ISamchonGraphSpan.ts @@ -15,7 +15,9 @@ export interface ISamchonGraphSpan { /** * Present only when it cannot be derived: an `implementation` can live in a - * different file from the declaration that owns it. + * different file from the declaration that owns it. Uses the same + * project-relative, normalized absolute, or `bundled:///` identity as a + * complete graph evidence span. */ file?: string; diff --git a/packages/graph/src/structures/ISamchonGraphTour.ts b/packages/graph/src/structures/ISamchonGraphTour.ts index d554618..6b2670b 100644 --- a/packages/graph/src/structures/ISamchonGraphTour.ts +++ b/packages/graph/src/structures/ISamchonGraphTour.ts @@ -89,7 +89,7 @@ export namespace ISamchonGraphTour { /** Declaration kind (`class`, `method`, `function`, ...). */ kind: string; - /** Project-relative declaration file. */ + /** Declaration identity: project-relative, normalized absolute, or `bundled:///`. */ file: string; /** 1-based declaration line, when known. */ @@ -168,7 +168,7 @@ export namespace ISamchonGraphTour { /** Declaration kind, when this anchor belongs to a node. */ kind?: string; - /** Project-relative file. */ + /** Graph file identity: project-relative, normalized absolute, or `bundled:///`. */ file: string; /** 1-based start line. */ @@ -180,7 +180,7 @@ export namespace ISamchonGraphTour { /** Source coordinates without source text. */ export interface ISpan { - /** Project-relative file. */ + /** Graph file identity: project-relative, normalized absolute, or `bundled:///`. */ file: string; /** 1-based start line. */ diff --git a/packages/graph/src/structures/ISamchonGraphTrace.ts b/packages/graph/src/structures/ISamchonGraphTrace.ts index b7b6aa0..886fd2c 100644 --- a/packages/graph/src/structures/ISamchonGraphTrace.ts +++ b/packages/graph/src/structures/ISamchonGraphTrace.ts @@ -63,7 +63,7 @@ export namespace ISamchonGraphTrace { /** Declaration kind (`variable`, `method`, `class`, ...). */ kind: string; - /** Project-relative path of the file that declares it. */ + /** Declaration identity: project-relative, normalized absolute, or `bundled:///`. */ file: string; /** 1-based declaration line, when known. */ @@ -187,7 +187,7 @@ export namespace ISamchonGraphTrace { /** Declaration kind (`class`, `method`, `function`, ...). */ kind: string; - /** Project-relative path of the declaration file. */ + /** Declaration identity: project-relative, normalized absolute, or `bundled:///`. */ file: string; /** 1-based declaration line, when known. */ diff --git a/tests/benchmark/graph/viewer.mjs b/tests/benchmark/graph/viewer.mjs index 871b64f..c1efba3 100644 --- a/tests/benchmark/graph/viewer.mjs +++ b/tests/benchmark/graph/viewer.mjs @@ -1,6 +1,7 @@ // viewer.mjs — turn a raw @samchon/graph dump into the reduced JSON the 3D viewer -// renders. The package launcher emits nodes and edges keyed by absolute realpath; -// this script makes it web-ready: +// renders. The package launcher emits project-relative identities plus +// canonical absolute or virtual identities for compiler-loaded files outside +// that root; this script makes them web-ready: // // 1. relativize the absolute paths in node ids and files (no machine path ships) // 2. drop external boundary leaves (node_modules / lib .d.ts) by default @@ -73,17 +74,22 @@ function directoryOf(file) { /** * Make an absolute path project-relative; a path outside the project keeps the * portion from its last node_modules/ segment, or its base name, so nothing - * leaks an absolute machine path. A null root means the dump's paths are - * already project-relative (the current `samchon-graph dump` contract), so they - * pass through with their directory structure intact. + * leaks an absolute machine path. A null root means at least one project + * identity is already relative: preserve those current identities, while still + * sanitizing any absolute compiler-loaded sibling. */ -function relativize(abs, root) { - const a = posix(abs); - if (root === null) return a; +function relativize(file, root) { + const normalized = posix(file); + if (root === null) + return isAbsolute(normalized) + ? outsideRootPath(normalized) + : normalized; const normalizedRoot = posix(root); const r = normalizedRoot === "/" ? "/" : normalizedRoot.replace(/\/+$/, ""); - const caseInsensitive = isWindowsPath(a) && isWindowsPath(r); - const comparedPath = caseInsensitive ? a.toLowerCase() : a; + const caseInsensitive = isWindowsPath(normalized) && isWindowsPath(r); + const comparedPath = caseInsensitive + ? normalized.toLowerCase() + : normalized; const comparedRoot = caseInsensitive ? r.toLowerCase() : r; if ( comparedRoot && @@ -91,11 +97,15 @@ function relativize(abs, root) { comparedPath === comparedRoot || comparedPath.startsWith(comparedRoot + "/")) ) - return a.slice(r.length).replace(/^\/+/, ""); - const nm = a.lastIndexOf("node_modules/"); - if (nm >= 0) return a.slice(nm); - const slash = a.lastIndexOf("/"); - return slash >= 0 ? a.slice(slash + 1) : a; + return normalized.slice(r.length).replace(/^\/+/, ""); + return outsideRootPath(normalized); +} + +function outsideRootPath(file) { + const nodeModules = file.lastIndexOf("node_modules/"); + if (nodeModules >= 0) return file.slice(nodeModules); + const slash = file.lastIndexOf("/"); + return slash >= 0 ? file.slice(slash + 1) : file; } /** @@ -144,13 +154,14 @@ export function reduce( const keep = (n) => (keepExternal || !n.external) && (keepIgnored || !n.ignored); const keptBoundary = raw.nodes.filter(keep); - // Reroot only absolute paths (the legacy dump contract); a current dump's - // paths are already project-relative and keep their structure as-is. + // Reroot only when every authored identity is absolute (the legacy dump + // contract). A current dump can mix relative project files with canonical + // absolute compiler-loaded siblings; that form is handled per file below. const projectFiles = raw.nodes .filter((n) => !n.external && !n.ignored) .map((n) => n.file); const root = - projectFiles.length > 0 && isAbsolute(projectFiles[0]) + projectFiles.length > 0 && projectFiles.every(isAbsolute) ? commonRoot(projectFiles.map(directoryOf)) : null; diff --git a/tests/test-graph/src/features/test_benchmark_viewer_reduce_matches_package_reducer.ts b/tests/test-graph/src/features/test_benchmark_viewer_reduce_matches_package_reducer.ts index 83a2ef7..96e1937 100644 --- a/tests/test-graph/src/features/test_benchmark_viewer_reduce_matches_package_reducer.ts +++ b/tests/test-graph/src/features/test_benchmark_viewer_reduce_matches_package_reducer.ts @@ -20,7 +20,10 @@ export const test_benchmark_viewer_reduce_matches_package_reducer = async () => reduce: ( dump: { nodes: unknown[]; edges: unknown[] }, options?: { keepExternal?: boolean }, - ) => { nodes: Array<{ id: string; file: string }>; links: Array<{ kind: string }> }; + ) => { + nodes: Array<{ id: string; name: string; file: string }>; + links: Array<{ kind: string }>; + }; }; const sameFile = viewer.reduce({ nodes: [ @@ -44,8 +47,63 @@ export const test_benchmark_viewer_reduce_matches_package_reducer = async () => sameFile.links[0]?.kind, "heritage", ); + + const relativeFirst = mixedPathReduction(viewer.reduce, false); + const absoluteFirst = mixedPathReduction(viewer.reduce, true); + TestValidator.equals( + "the benchmark viewer sanitizes a sibling absolute identity beside a project-relative identity", + pathCoordinates(relativeFirst), + [ + ["Local", "src/local.ts", "src/local.ts"], + ["Sibling", "sibling.ts", "sibling.ts"], + ], + ); + TestValidator.equals( + "the benchmark viewer's mixed path reduction is independent of node order", + pathCoordinates(absoluteFirst), + pathCoordinates(relativeFirst), + ); }; +type ViewerReduce = ( + dump: { nodes: unknown[]; edges: unknown[] }, +) => { nodes: Array<{ id: string; name: string; file: string }> }; + +const mixedPathReduction = ( + reduce: ViewerReduce, + reversed: boolean, +): ReturnType => { + const nodes = [ + node("src/local.ts", "Local"), + node("D:/sibling/sibling.ts", "Sibling"), + ]; + return reduce({ + nodes: reversed ? nodes.reverse() : nodes, + edges: [ + edge( + "src/local.ts", + "Local", + "D:/sibling/sibling.ts", + "Sibling", + "calls", + ), + ], + }); +}; + +const pathCoordinates = ( + payload: ReturnType, +): Array<[string, string, string]> => + payload.nodes + .map( + (entry): [string, string, string] => [ + entry.name, + entry.file, + entry.id.slice(0, entry.id.indexOf("#")), + ], + ) + .sort(([left], [right]) => left.localeCompare(right)); + const node = (file: string, name: string) => ({ id: `${file}#${name}:method`, name, diff --git a/tests/test-graph/src/features/test_viewer_reduce_preserves_the_reference_contract.ts b/tests/test-graph/src/features/test_viewer_reduce_preserves_the_reference_contract.ts index d265cd5..8335154 100644 --- a/tests/test-graph/src/features/test_viewer_reduce_preserves_the_reference_contract.ts +++ b/tests/test-graph/src/features/test_viewer_reduce_preserves_the_reference_contract.ts @@ -181,19 +181,19 @@ export const test_viewer_reduce_preserves_the_reference_contract = () => { ["A/a.ts", "a/b.ts"], ); - const mixedPathForms = reduce({ - nodes: [ - node("/work/a.ts", "A", "class"), - node("bare.ts", "B", "class"), - ], - edges: [ - edge("/work/a.ts", "A", "class", "bare.ts", "B", "class", "calls"), + const mixedPathForms = mixedPathReduction(false); + TestValidator.equals( + "a relative-first current dump preserves its project path and sanitizes its absolute sibling", + pathCoordinates(mixedPathForms), + [ + ["Local", "src/local.ts", "src/local.ts"], + ["Sibling", "sibling.ts", "sibling.ts"], ], - }); + ); TestValidator.equals( - "mixed legacy paths fall back to portable basenames", - mixedPathForms.nodes.map((entry) => entry.file), - ["a.ts", "bare.ts"], + "mixed current path reduction is independent of node order", + pathCoordinates(mixedPathReduction(true)), + pathCoordinates(mixedPathForms), ); const unc = reduce({ @@ -251,6 +251,40 @@ export const test_viewer_reduce_preserves_the_reference_contract = () => { ); }; +const mixedPathReduction = (reversed: boolean) => { + const nodes = [ + node("src/local.ts", "Local", "class"), + node("/workspace-sibling/sibling.ts", "Sibling", "class"), + ]; + return reduce({ + nodes: reversed ? nodes.reverse() : nodes, + edges: [ + edge( + "src/local.ts", + "Local", + "class", + "/workspace-sibling/sibling.ts", + "Sibling", + "class", + "calls", + ), + ], + }); +}; + +const pathCoordinates = ( + payload: ReturnType, +): Array<[string, string, string]> => + payload.nodes + .map( + (entry): [string, string, string] => [ + entry.name, + entry.file, + entry.id.slice(0, entry.id.indexOf("#")), + ], + ) + .sort(([left], [right]) => left.localeCompare(right)); + const id = (file: string, name: string, kind: string) => `${file}#${name}:${kind}`; const node = (file: string, name: string, kind: string, external = false) => ({ id: id(file, name, kind), diff --git a/tests/test-graph/src/internal/ContractParity.ts b/tests/test-graph/src/internal/ContractParity.ts index 08d7e0c..e2c4aee 100644 --- a/tests/test-graph/src/internal/ContractParity.ts +++ b/tests/test-graph/src/internal/ContractParity.ts @@ -528,14 +528,54 @@ export namespace ContractParity { from: "/** The complete value set a type alias or enum admits, in __LANG__ source form (`\"a\"`, `1`, `true`, `null`) — the checker's resolved union members, not the quoted tokens that happened to fit in `signature`. Absent when the type has no enumerable value set. A `signature` is capped at the declaration head, so for a union or enum written across several lines this is the field that carries the members. */", to: "/** The complete value set a type alias or enum admits, in __LANG__ source form (`\"a\"`, `1`, `true`, `null`) — the provider-resolved union members, not quoted tokens scraped from `signature`. Absent when the active index cannot prove an enumerable value set. */", }, + { + reason: + "Compiler-owned providers can retain canonical absolute or virtual identities for files loaded outside the project, so selected declarations must not promise every file is project-relative.", + layer: "prose", + from: "/** Project-relative path of the file that declares this node. */", + to: "/** Declaration identity: project-relative, normalized absolute, or `bundled:///`. */", + occurrences: 2, + }, + { + reason: + "A dependency neighbor can be a compiler-loaded declaration outside the project and carries the same canonical identity as its graph node.", + layer: "prose", + from: "/** Project-relative declaration file for the neighbor. */", + to: "/** Neighbor identity: project-relative, normalized absolute, or `bundled:///`. */", + }, ], - Trace: [ + Entrypoints: [ { reason: - "Path mode now reports bounded depth and dispatch-hub omissions instead of falsely proving that no connection exists, so truncation applies to both trace forms.", + "An entrypoint candidate can name a compiler-loaded declaration outside the project, whose canonical identity is absolute or virtual.", layer: "prose", - from: "/** In an open trace, true when a bound omitted an eligible node or hop. */", - to: "/** True when a walk or path-search bound omitted an eligible node or hop. */", + from: "/** Project-relative path of the declaration file. */", + to: "/** Declaration identity: project-relative, normalized absolute, or `bundled:///`. */", + }, + { + reason: + "An entrypoint dependency neighbor preserves the canonical identity of its graph node even when the compiler loaded it outside the project.", + layer: "prose", + from: "/** Project-relative declaration file for the neighbor. */", + to: "/** Neighbor identity: project-relative, normalized absolute, or `bundled:///`. */", + }, + ], + Evidence: [ + { + reason: + "Evidence cites the graph identity that produced the fact; strict compiler providers retain normalized absolute and virtual identities for out-of-root sources.", + layer: "prose", + from: "/** Project-relative path of the file the span lives in. */", + to: "/** Graph file identity of the span: normally project-relative, but normalized absolute for a compiler-loaded out-of-root file or `bundled:///` for a virtual library. */", + }, + ], + Lookup: [ + { + reason: + "A lookup result can select a compiler-loaded declaration outside the project and must describe its canonical absolute or virtual identity honestly.", + layer: "prose", + from: "/** Project-relative path of the declaration file. */", + to: "/** Declaration identity: project-relative, normalized absolute, or `bundled:///`. */", }, ], Dump: [ @@ -644,10 +684,10 @@ export namespace ContractParity { }, { reason: - "The product's public dump has no single TypeScript config or provenance manifest; it names only the project-relative graph and generic diagnostic paths shared by every indexing lane.", + "The product's public dump has no single TypeScript config or provenance manifest, but a strict compiler provider can contribute out-of-root and virtual source identities that must remain canonical across every result surface.", layer: "prose", from: "`project` is absolute. Every other path is relative to it — `tsconfig`, and the `file` fields on nodes, edges, diagnostics, and the provenance manifest. Two kinds of path fall outside the project and so cannot be relative to it: a dependency keeps its `node_modules/`-relative tail, which is what makes a dependency leaf readable, and anything else the compiler loaded keeps the identity the compiler gave it — a virtual lib stays `bundled:///…`.", - to: "Paths in `project` are absolute; `file` fields on nodes, edges, and diagnostics are project-relative.", + to: "`project` is absolute. A graph file identity uses normalized forward slashes: a project-owned file is relative to `project`, a compiler-loaded file outside that root keeps its normalized absolute identity, and a virtual compiler library keeps its `bundled:///` identity. The same identity flows through a node's `file` and id prefix, reconstructed edge evidence, diagnostics, and operation results.", }, { reason: @@ -766,6 +806,29 @@ export namespace ContractParity { from: "the checker could not fold", to: "the index could not fold", }, + { + reason: + "A node can represent an out-of-root source loaded by a strict compiler provider, so its canonical identity may be normalized absolute or virtual rather than project-relative.", + layer: "prose", + from: "/** Project-relative path of the file that declares this node. */", + to: "/** Graph file identity of the declaration. Project-owned files are project-relative; compiler-loaded files outside the root keep normalized absolute identities, and virtual libraries use `bundled:///`. */", + }, + ], + Overview: [ + { + reason: + "Overview layers can include a canonical out-of-root compiler directory, so the directory identity is not universally project-relative.", + layer: "prose", + from: "/** Directory, project-relative. */", + to: "/** Directory identity: project-relative or normalized absolute. */", + }, + { + reason: + "Overview declarations preserve canonical compiler-loaded file identities across the public result boundary.", + layer: "prose", + from: "/** Project-relative path of the file that declares it. */", + to: "/** Declaration identity: project-relative, normalized absolute, or `bundled:///`. */", + }, ], NodeModifier: [ { @@ -843,6 +906,53 @@ export namespace ContractParity { from: "This shape exists only between the Go builder and the loader.", to: "This shape exists only between the indexer and the loader.", }, + { + reason: + "An implementation can live in an out-of-root or virtual compiler source and keeps the same canonical graph identity as complete evidence.", + layer: "prose", + from: "/** Present only when it cannot be derived: an `implementation` can live in a different file from the declaration that owns it. */", + to: "/** Present only when it cannot be derived: an `implementation` can live in a different file from the declaration that owns it. Uses the same project-relative, normalized absolute, or `bundled:///` identity as a complete graph evidence span. */", + }, + ], + Tour: [ + { + reason: + "A tour candidate can cite a compiler-loaded declaration outside the project and preserves its canonical identity.", + layer: "prose", + from: "/** Project-relative declaration file. */", + to: "/** Declaration identity: project-relative, normalized absolute, or `bundled:///`. */", + }, + { + reason: + "Tour anchors preserve the graph file identity that backs their citation, including absolute and virtual compiler sources.", + layer: "prose", + from: "/** Project-relative file. */", + to: "/** Graph file identity: project-relative, normalized absolute, or `bundled:///`. */", + occurrences: 2, + }, + ], + Trace: [ + { + reason: + "Trace nodes preserve canonical compiler-loaded declaration identities across the public result boundary.", + layer: "prose", + from: "/** Project-relative path of the file that declares it. */", + to: "/** Declaration identity: project-relative, normalized absolute, or `bundled:///`. */", + }, + { + reason: + "Trace omissions name the same canonical declaration identity as the omitted graph node.", + layer: "prose", + from: "/** Project-relative path of the declaration file. */", + to: "/** Declaration identity: project-relative, normalized absolute, or `bundled:///`. */", + }, + { + reason: + "Path mode now reports bounded depth and dispatch-hub omissions instead of falsely proving that no connection exists, so truncation applies to both trace forms.", + layer: "prose", + from: "/** In an open trace, true when a bound omitted an eligible node or hop. */", + to: "/** True when a walk or path-search bound omitted an eligible node or hop. */", + }, ], }; From 38e77d4bd7daf49b3f3fd292f91ba382f1470abd Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Sun, 19 Jul 2026 21:39:33 +0900 Subject: [PATCH 12/16] Settle every generic LSP request and own its retired child on failure Settle each pending request exactly once on stdin/write/exit/malformed failure, cache the failure so later requests reject before the wire, and thread an abort signal so a resident `close()` cancels an in-flight readiness/symbol/reference refresh. Await each retired child's exit and terminate its exact process tree (SIGTERM before SIGKILL; `taskkill /T /F` on Windows), sparing unrelated processes. `refreshLanguageSession` keeps its three-argument form. Closes #100 Closes #107 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018G2eNarjGmcWmEjFTW2Qcz --- packages/graph/src/indexer/ILspSession.ts | 6 +- packages/graph/src/indexer/buildLspGraph.ts | 6 +- .../src/indexer/createResidentGraphSource.ts | 10 +- .../src/indexer/refreshLanguageSession.ts | 7 +- packages/graph/src/indexer/scanSession.ts | 2 +- packages/graph/src/lsp/LspClient.ts | 210 ++++++++++++--- ...rvers_that_break_the_shutdown_handshake.ts | 253 +++++++++++++++++- ..._session_retains_the_three_argument_api.ts | 29 ++ ..._interrupts_stalled_generic_lsp_refresh.ts | 138 ++++++++++ .../src/internal/fake-lsp-server.cjs | 65 ++++- 10 files changed, 675 insertions(+), 51 deletions(-) create mode 100644 tests/test-graph/src/features/test_refresh_language_session_retains_the_three_argument_api.ts create mode 100644 tests/test-graph/src/features/test_resident_close_interrupts_stalled_generic_lsp_refresh.ts diff --git a/packages/graph/src/indexer/ILspSession.ts b/packages/graph/src/indexer/ILspSession.ts index caac93d..f1802db 100644 --- a/packages/graph/src/indexer/ILspSession.ts +++ b/packages/graph/src/indexer/ILspSession.ts @@ -30,7 +30,11 @@ export interface ILspSession { * is used after `didOpen`, while lazy reference warmups use `false` and only * wait when that request actually triggered progress. */ - waitForReady?(since: number, allowStart: boolean): Promise; + waitForReady?( + since: number, + allowStart: boolean, + signal?: AbortSignal, + ): Promise; /** * What the server currently says about each open document, keyed by diff --git a/packages/graph/src/indexer/buildLspGraph.ts b/packages/graph/src/indexer/buildLspGraph.ts index 037c11f..21fd685 100644 --- a/packages/graph/src/indexer/buildLspGraph.ts +++ b/packages/graph/src/indexer/buildLspGraph.ts @@ -483,7 +483,7 @@ async function openLanguageSession( opened: new Map(), diagnostics, progressVersion: () => progressVersion, - waitForReady: (since, allowStart) => + waitForReady: (since, allowStart, signal) => waitForIndexing( since, allowStart, @@ -493,7 +493,7 @@ async function openLanguageSession( () => activeProgress.size, quietMs, options.lspReadyTimeoutMs, - options.signal, + signal, ), }; const didOpenFence = session.progressVersion!(); @@ -507,7 +507,7 @@ async function openLanguageSession( // // The wait ends once progress goes quiet for `lspReadyQuietMs`. Its overall // ceiling is optional, so normal callers still wait as long as needed. - await session.waitForReady!(didOpenFence, true); + await session.waitForReady!(didOpenFence, true, options.signal); return session; } catch (error) { // A server that never answers `initialize` (or fails before the session diff --git a/packages/graph/src/indexer/createResidentGraphSource.ts b/packages/graph/src/indexer/createResidentGraphSource.ts index 596c2b2..50ce9ff 100644 --- a/packages/graph/src/indexer/createResidentGraphSource.ts +++ b/packages/graph/src/indexer/createResidentGraphSource.ts @@ -102,6 +102,7 @@ export function createResidentGraphSource( async function refreshStale( current: IResidentState, prefetched: ReadonlyMap, + signal: AbortSignal, ): Promise { const nodes: ISamchonGraphNode[] = []; const edges: ISamchonGraphEdge[] = []; @@ -142,7 +143,12 @@ export function createResidentGraphSource( extensions: allExtensions([language]), maxFiles: options.maxFiles, }); - const result = await refreshLanguageSession(session, files, options); + const result = await refreshLanguageSession( + session, + files, + options, + signal, + ); assertOpen(); nodes.push(...result.nodes); edges.push(...result.edges); @@ -239,7 +245,7 @@ export function createResidentGraphSource( ) { const discovered = discoverLanguages(root, options); if (sameLanguages(state.languages, discovered)) { - await refreshStale(state, prefetched); + await refreshStale(state, prefetched, controller.signal); } else { await replaceLanguages(state, controller.signal); } diff --git a/packages/graph/src/indexer/refreshLanguageSession.ts b/packages/graph/src/indexer/refreshLanguageSession.ts index c2dd9a8..d15e799 100644 --- a/packages/graph/src/indexer/refreshLanguageSession.ts +++ b/packages/graph/src/indexer/refreshLanguageSession.ts @@ -16,12 +16,15 @@ export async function refreshLanguageSession( session: ILspSession, files: readonly string[], options: IBuildGraphOptions, + signal?: AbortSignal, ): Promise<{ nodes: ISamchonGraphNode[]; edges: ISamchonGraphEdge[]; diagnostics: ISamchonGraphDiagnostic[]; warnings: string[]; }> { + const refreshOptions = + signal === undefined ? options : { ...options, signal }; // The session's diagnostics are not cleared: `publishDiagnostics` replaces a // document's findings, and a server republishes only what it re-analysed, so // what it said about an untouched file still stands. What it said about a file @@ -34,9 +37,9 @@ export async function refreshLanguageSession( progressFence !== undefined && session.waitForReady !== undefined ) { - await session.waitForReady(progressFence, true); + await session.waitForReady(progressFence, true, refreshOptions.signal); } - return scanSession(session, options); + return scanSession(session, refreshOptions); } // Reconciles the session's open files against what is on disk right now: diff --git a/packages/graph/src/indexer/scanSession.ts b/packages/graph/src/indexer/scanSession.ts index 0fd6f12..26f88e1 100644 --- a/packages/graph/src/indexer/scanSession.ts +++ b/packages/graph/src/indexer/scanSession.ts @@ -137,7 +137,7 @@ export async function scanSession( // query. The first answer can be a valid-but-incomplete empty array while // that query starts a work-done lifecycle. Wait for its end and ask once // more; the second answer is from the completed index. - await session.waitForReady(progressFence, false); + await session.waitForReady(progressFence, false, options.signal); warm = await safeReferences( client, referenceParams(referenceTargets[0]!), diff --git a/packages/graph/src/lsp/LspClient.ts b/packages/graph/src/lsp/LspClient.ts index 2ca2ccb..2df1c2a 100644 --- a/packages/graph/src/lsp/LspClient.ts +++ b/packages/graph/src/lsp/LspClient.ts @@ -1,6 +1,10 @@ import { ChildProcessWithoutNullStreams, spawn } from "node:child_process"; import { EventEmitter } from "node:events"; +const SHUTDOWN_GRACE_MS = 1_000; +const TERMINATION_GRACE_MS = 1_000; +const FORCED_EXIT_GRACE_MS = 2_000; + interface IRequest { resolve: (value: unknown) => void; reject: (error: Error) => void; @@ -11,11 +15,14 @@ interface IRequest { export class LspClient { private readonly process: ChildProcessWithoutNullStreams; + private readonly exit: Promise; private readonly pending = new Map(); private readonly events = new EventEmitter(); private buffer = Buffer.alloc(0); private nextId = 1; private exited = false; + private failure: Error | undefined; + private termination: Promise | undefined; private closing: Promise | undefined; public constructor( @@ -29,22 +36,26 @@ export class LspClient { stdio: "pipe", windowsHide: true, }); + this.exit = exitOf(this.process); this.process.stdout.on("data", (chunk: Buffer) => this.onData(chunk)); this.process.stderr.on("data", () => { // Language servers often log noisy progress to stderr. }); this.process.on("error", (error) => { this.exited = true; - this.rejectAll(error); + this.fail(error); }); this.process.on("exit", (code, signal) => { this.exited = true; - this.rejectAll( + this.fail( new Error( - `Language server exited (${code ?? "null"}, ${signal ?? "null"}).`, + `Language server exited (${String(code)}, ${String(signal)}).`, ), ); }); + this.process.stdin.on("error", (error) => { + this.failTransport(stdinError(error)); + }); } public async request( @@ -54,6 +65,7 @@ export class LspClient { signal?: AbortSignal, ): Promise { if (signal?.aborted) throw abortedError(method); + if (this.failure !== undefined) throw this.failure; // Requests are unlimited when neither the client nor this call specifies a // deadline or cancellation signal. Bounded callers can still prevent a // non-answering server from holding an experiment or resident shutdown @@ -108,57 +120,48 @@ export class LspClient { // request it was never meant to answer, e.g. a bad `initialize`) cannot // answer `shutdown`; sending it anyway would just wait out the full // request timeout for nothing. - if (!this.exited) { + if (!this.exited && this.failure === undefined) { // Teardown is the one bounded place: indexing requests wait forever, but a // `shutdown` that never comes back must not leak the child process. Wait // briefly for a graceful shutdown, then fall through to the kill below. await Promise.race([ this.request("shutdown", null).catch(() => {}), new Promise((resolve) => { - const timer = setTimeout(resolve, 1000); + const timer = setTimeout(resolve, SHUTDOWN_GRACE_MS); timer.unref?.(); }), ]); /* c8 ignore start */ - try { - this.notify("exit", null); - } catch { - // Ignore close errors. + if (!this.exited && this.failure === undefined) { + try { + this.notify("exit", null); + } catch { + // Ignore close errors. + } } /* c8 ignore stop */ - await this.waitForExit(1000); + if (await waitForExit(this.exit, SHUTDOWN_GRACE_MS)) return; } - if ( - this.process.pid !== undefined && - this.process.exitCode === null && - this.process.signalCode === null - ) - this.process.kill(); - } - - private waitForExit(timeoutMs: number): Promise { - if (this.exited) return Promise.resolve(); - return new Promise((resolve) => { - const onExit = (): void => { - clearTimeout(timer); - resolve(); - }; - const timer = setTimeout(() => { - this.process.off("exit", onExit); - resolve(); - }, timeoutMs); - timer.unref?.(); - this.process.once("exit", onExit); - }); + if (this.termination !== undefined) await this.termination; + else if (!this.exited) await this.terminate(); } private write(payload: unknown): void { + if (this.failure !== undefined) return; const body = Buffer.from(JSON.stringify(payload), "utf8"); const header = Buffer.from( `Content-Length: ${body.length}\r\n\r\n`, "ascii", ); - this.process.stdin.write(Buffer.concat([header, body])); + try { + this.process.stdin.write(Buffer.concat([header, body]), (error) => { + if (error !== null && error !== undefined) { + this.failTransport(stdinError(error)); + } + }); + } catch (error) { + this.failTransport(stdinError(error)); + } } private onData(chunk: Buffer): void { @@ -237,6 +240,31 @@ export class LspClient { } } + private fail(error: Error): void { + this.failure ??= error; + this.rejectAll(this.failure); + } + + private failTransport(error: Error): void { + this.fail(error); + if (this.exited) return; + void this.terminate().catch(() => undefined); + } + + private terminate(): Promise { + if (this.termination === undefined) { + // `taskkill /T /F` reports an ordinary numeric exit for the wrapper on + // Windows. Preserve the transport's forced-termination contract for + // pending requests while the termination promise below still waits for + // the exact owned process tree to disappear. + if (process.platform === "win32" && this.failure === undefined) { + this.fail(new Error("Language server exited (null, SIGKILL).")); + } + this.termination = terminateOwnedProcess(this.process, this.exit); + } + return this.termination; + } + private deletePending(id: number, request: IRequest): void { this.pending.delete(id); if (request.timer !== undefined) clearTimeout(request.timer); @@ -251,3 +279,119 @@ function abortedError(method: string): Error { error.name = "AbortError"; return error; } + +function stdinError(error: unknown): Error { + const message = error instanceof Error ? error.message : String(error); + return new Error(`Language server stdin failed: ${message}`); +} + +async function terminateOwnedProcess( + child: ChildProcessWithoutNullStreams, + exit: Promise, +): Promise { + if (!child.stdin.destroyed) child.stdin.destroy(); + /* c8 ignore next -- child exit can win the race into its owned teardown. */ + if (!isRunning(child)) return; + /* c8 ignore start -- exactly one OS termination lane can execute on a + * coverage host. Windows and POSIX integration tests still exercise their + * respective process-tree and SIGTERM-to-SIGKILL behavior. */ + if (process.platform === "win32") { + await killWindowsProcessTree(child.pid!); + if (await waitForExit(exit, FORCED_EXIT_GRACE_MS)) return; + throw new Error( + "Language server process tree did not exit after forced termination.", + ); + } + signalChild(child, "SIGTERM"); + if (await waitForExit(exit, TERMINATION_GRACE_MS)) return; + signalChild(child, "SIGKILL"); + if (!(await waitForExit(exit, FORCED_EXIT_GRACE_MS))) { + throw new Error( + "Language server did not exit after forced termination.", + ); + } + /* c8 ignore stop */ +} + +function isRunning(child: ChildProcessWithoutNullStreams): boolean { + return ( + child.pid !== undefined && + child.exitCode === null && + child.signalCode === null + ); +} + +/* c8 ignore start -- POSIX-only helper; its fixed valid signals and owned + * handle are covered by the POSIX lifecycle integration test. */ +function signalChild( + child: ChildProcessWithoutNullStreams, + signal: NodeJS.Signals, +): void { + try { + child.kill(signal); + } catch { + return; + } +} +/* c8 ignore stop */ + +/* c8 ignore start -- Windows-only helper; the Windows lifecycle integration + * test proves exact-tree termination and unrelated-process preservation. */ +function killWindowsProcessTree(pid: number): Promise { + return new Promise((resolve) => { + const killer = spawn( + "taskkill.exe", + ["/pid", String(pid), "/t", "/f"], + { + stdio: "ignore", + windowsHide: true, + }, + ); + let settled = false; + const finish = (): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(); + }; + const timer = setTimeout(() => { + killer.kill(); + finish(); + }, FORCED_EXIT_GRACE_MS); + timer.unref(); + killer.once("error", finish); + killer.once("exit", finish); + }); +} +/* c8 ignore stop */ + +function waitForExit(exit: Promise, timeoutMs: number): Promise { + return new Promise((resolve) => { + let settled = false; + const finish = (value: boolean): void => { + /* c8 ignore next -- the timer and child-exit promise may race after the + * first one has already settled this bounded wait. */ + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(value); + }; + const timer = setTimeout(() => finish(false), timeoutMs); + timer.unref(); + void exit.then(() => finish(true)); + }); +} + +function exitOf(child: ChildProcessWithoutNullStreams): Promise { + return new Promise((resolve) => { + const settled = (): void => { + child.off("error", settled); + child.off("exit", settled); + child.off("close", settled); + resolve(); + }; + child.once("error", settled); + child.once("exit", settled); + child.once("close", settled); + }); +} 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 6e50110..4faa99e 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 @@ -1,4 +1,6 @@ import { TestValidator } from "@nestia/e2e"; +import { ChildProcess, spawn } from "node:child_process"; +import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; @@ -11,9 +13,26 @@ interface ILspClient { timeoutMs?: number, signal?: AbortSignal, ): Promise; + notify(method: string, params: unknown): void; close(): Promise; } +interface ILspClientInternals { + process: { + stdin: { + destroy(error?: Error): void; + write: (...args: unknown[]) => boolean; + }; + }; +} + +type LspClientConstructor = new ( + command: string, + args: readonly string[], + timeoutMs?: number, + cwd?: string, +) => ILspClient; + /** `LspClient` is internal transport, reached through the shipped artifact. */ const importLib = (relative: string): Promise => import( @@ -23,12 +42,7 @@ const importLib = (relative: string): Promise => export const test_lsp_client_closes_servers_that_break_the_shutdown_handshake = async () => { const { LspClient } = await importLib<{ - LspClient: new ( - command: string, - args: readonly string[], - timeoutMs?: number, - cwd?: string, - ) => ILspClient; + LspClient: LspClientConstructor; }>("lsp/LspClient.js"); // A language server that acknowledges `shutdown` and then ignores `exit` is @@ -86,6 +100,10 @@ export const test_lsp_client_closes_servers_that_break_the_shutdown_handshake = await abrupt.close(); await stubborn.close(); + await assertStubbornProcessTreeIsOwned(LspClient); + await assertClosedInputRejectsRequests(LspClient); + await assertSynchronousWriteFailureRejectsRequests(LspClient); + // An already-cancelled request never enters the wire or waits for the // otherwise-unlimited default deadline. The client still owns its child and // closes it normally, which is the negative twin of aborting an in-flight @@ -106,3 +124,226 @@ export const test_lsp_client_closes_servers_that_break_the_shutdown_handshake = ); await cancelled.close(); }; + +const assertStubbornProcessTreeIsOwned = async ( + LspClient: LspClientConstructor, +): Promise => { + const root = GraphPaths.createTempDirectory("samchon-graph-stubborn-lsp-"); + const pidFile = path.join(root, "stubborn.pid"); + const sigtermFile = path.join(root, "stubborn.sigterm"); + const previousPidFile = process.env.SAMCHON_GRAPH_FAKE_LSP_PID_FILE; + const previousSigtermFile = + process.env.SAMCHON_GRAPH_FAKE_LSP_SIGTERM_FILE; + process.env.SAMCHON_GRAPH_FAKE_LSP_PID_FILE = pidFile; + process.env.SAMCHON_GRAPH_FAKE_LSP_SIGTERM_FILE = sigtermFile; + const fakeArgs = [GraphPaths.fakeLspServer, "--ignore-termination"]; + const wrapper = path.join(root, "stubborn-lsp.cmd"); + if (process.platform === "win32") { + fs.writeFileSync( + wrapper, + `@echo off\r\n"${process.execPath}" "${fakeArgs[0]}" ${fakeArgs[1]}\r\n`, + ); + } + const command = process.platform === "win32" ? "cmd.exe" : process.execPath; + const args = + process.platform === "win32" + ? ["/d", "/s", "/c", wrapper] + : fakeArgs; + const client = new LspClient(command, args); + const unrelated = spawn( + process.execPath, + ["-e", "setInterval(() => undefined, 1_000)"], + { stdio: "ignore", windowsHide: true }, + ); + let pid: number | undefined; + try { + await client.request("initialize", {}); + await waitForFile(pidFile); + pid = Number(fs.readFileSync(pidFile, "utf8")); + await settleWithin(client.close(), 5_000, () => terminate(pid!)); + TestValidator.equals( + "close returns only after a signal-resistant LSP child exits", + isProcessAlive(pid), + false, + ); + TestValidator.equals( + "closing one LSP process tree preserves an unrelated process", + isProcessAlive(unrelated.pid!), + true, + ); + if (process.platform !== "win32") { + TestValidator.equals( + "POSIX shutdown escalates through SIGTERM before SIGKILL", + fs.existsSync(sigtermFile), + true, + ); + } + } finally { + if (pid !== undefined) terminate(pid); + await Promise.allSettled([client.close(), stop(unrelated)]); + restoreEnv("SAMCHON_GRAPH_FAKE_LSP_PID_FILE", previousPidFile); + restoreEnv("SAMCHON_GRAPH_FAKE_LSP_SIGTERM_FILE", previousSigtermFile); + } +}; + +const assertClosedInputRejectsRequests = async ( + LspClient: LspClientConstructor, +): Promise => { + const root = GraphPaths.createTempDirectory("samchon-graph-closed-lsp-input-"); + const marker = path.join(root, "input.closed"); + const previousMarker = + process.env.SAMCHON_GRAPH_FAKE_LSP_INPUT_CLOSED_FILE; + if (process.platform !== "win32") { + process.env.SAMCHON_GRAPH_FAKE_LSP_INPUT_CLOSED_FILE = marker; + } + const client = new LspClient(process.execPath, [ + GraphPaths.fakeLspServer, + process.platform === "win32" + ? "--hang-method=workspace/symbol" + : "--close-input-after-initialize", + ]); + try { + await client.request("initialize", {}); + if (process.platform !== "win32") await waitForFile(marker); + const pending = client.request("workspace/symbol", {}); + if (process.platform === "win32") { + // Windows keeps the child-side inherited named-pipe handle alive until + // process exit even after fd 0 is closed. Destroy the exact client stream + // with the error that the OS defers, while the real peer-close path above + // remains exercised on POSIX. + (client as unknown as ILspClientInternals).process.stdin.destroy( + new Error("synthetic closed request pipe"), + ); + } + const rejection = await rejectionWithin( + pending, + 2_000, + ); + TestValidator.predicate( + "a closed LSP input rejects pending requests without an unhandled stream error", + rejection.message.includes("stdin") || + rejection.message.includes("write"), + ); + const later = await rejectionWithin( + client.request("workspace/symbol", {}), + 100, + ); + TestValidator.equals( + "transport failure rejects later requests before they enter the wire", + later.message, + rejection.message, + ); + client.notify("workspace/didChangeConfiguration", {}); + await settleWithin(client.close(), 5_000, () => undefined); + } finally { + await Promise.allSettled([client.close()]); + restoreEnv("SAMCHON_GRAPH_FAKE_LSP_INPUT_CLOSED_FILE", previousMarker); + } +}; + +const assertSynchronousWriteFailureRejectsRequests = async ( + LspClient: LspClientConstructor, +): Promise => { + const client = new LspClient(process.execPath, [ + GraphPaths.fakeLspServer, + "--hang-method=workspace/symbol", + ]); + try { + await client.request("initialize", {}); + (client as unknown as ILspClientInternals).process.stdin.write = () => { + throw "synthetic synchronous write failure"; + }; + const rejection = await rejectionWithin( + client.request("workspace/symbol", {}), + 2_000, + ); + TestValidator.predicate( + "a synchronous non-Error stdin failure rejects the request", + rejection.message.includes("stdin") && + rejection.message.includes("synthetic synchronous write failure"), + ); + await settleWithin(client.close(), 5_000, () => undefined); + } finally { + await Promise.allSettled([client.close()]); + } +}; + +const rejectionWithin = async ( + task: Promise, + timeoutMs: number, +): Promise => { + const result = await settleWithin( + task.then( + () => ({ error: undefined }), + (error: unknown) => ({ + error: error instanceof Error ? error : new Error(String(error)), + }), + ), + timeoutMs, + () => undefined, + ); + if (result.error !== undefined) return result.error; + throw new Error("expected LSP request to reject"); +}; + +const settleWithin = async ( + task: Promise, + timeoutMs: number, + onTimeout: () => void, +): Promise => { + let timer: NodeJS.Timeout | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => { + onTimeout(); + reject(new Error(`LSP lifecycle exceeded ${String(timeoutMs)} ms`)); + }, timeoutMs); + }); + try { + return await Promise.race([task, timeout]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } +}; + +const waitForFile = async (file: string): Promise => { + const deadline = Date.now() + 5_000; + while (!fs.existsSync(file)) { + if (Date.now() >= deadline) throw new Error(`fake LSP did not announce ${file}`); + await delay(10); + } +}; + +const isProcessAlive = (pid: number): boolean => { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +}; + +const terminate = (pid: number): void => { + if (!isProcessAlive(pid)) return; + try { + process.kill(pid, "SIGKILL"); + } catch { + return; + } +}; + +const stop = async (child: ChildProcess): Promise => { + if (child.exitCode !== null || child.signalCode !== null) return; + const exited = new Promise((resolve) => { + child.once("exit", () => resolve()); + }); + child.kill("SIGKILL"); + await exited; +}; + +const restoreEnv = (name: string, value: string | undefined): void => { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; +}; + +const delay = (milliseconds: number): Promise => + new Promise((resolve) => setTimeout(resolve, milliseconds)); diff --git a/tests/test-graph/src/features/test_refresh_language_session_retains_the_three_argument_api.ts b/tests/test-graph/src/features/test_refresh_language_session_retains_the_three_argument_api.ts new file mode 100644 index 0000000..6fd99a6 --- /dev/null +++ b/tests/test-graph/src/features/test_refresh_language_session_retains_the_three_argument_api.ts @@ -0,0 +1,29 @@ +import { + ILspSession, + LspClient, + refreshLanguageSession, +} from "@samchon/graph"; +import { TestValidator } from "@nestia/e2e"; + +import { GraphPaths } from "../internal/GraphPaths"; + +/** The released refresh helper still accepts options without a fourth signal. */ +export const test_refresh_language_session_retains_the_three_argument_api = + async () => { + const session: ILspSession = { + client: { + notify: () => undefined, + } as unknown as LspClient, + root: GraphPaths.createTempDirectory("samchon-graph-refresh-api-"), + language: "typescript", + opened: new Map(), + diagnostics: new Map(), + }; + + const result = await refreshLanguageSession(session, [], {}); + TestValidator.equals( + "a three-argument refresh remains an empty, successful scan", + result, + { nodes: [], edges: [], diagnostics: [], warnings: [] }, + ); + }; diff --git a/tests/test-graph/src/features/test_resident_close_interrupts_stalled_generic_lsp_refresh.ts b/tests/test-graph/src/features/test_resident_close_interrupts_stalled_generic_lsp_refresh.ts new file mode 100644 index 0000000..dd25c85 --- /dev/null +++ b/tests/test-graph/src/features/test_resident_close_interrupts_stalled_generic_lsp_refresh.ts @@ -0,0 +1,138 @@ +import { TestValidator } from "@nestia/e2e"; +import { createResidentGraphSource } from "@samchon/graph"; +import fs from "node:fs"; +import path from "node:path"; + +import { GraphPaths } from "../internal/GraphPaths"; + +/** A resident close cancels every phase of an established LSP refresh. */ +export const test_resident_close_interrupts_stalled_generic_lsp_refresh = + async () => { + await exercise("readiness", ["--hang-refresh-readiness"]); + await exercise("symbols", [ + "--hang-refresh-method=textDocument/documentSymbol", + ]); + await exercise("references", [ + "--hang-refresh-method=textDocument/references", + ]); + }; + +const exercise = async (phase: string, serverArgs: string[]): Promise => { + const root = GraphPaths.createTempDirectory( + `samchon-graph-stalled-lsp-refresh-${phase}-`, + ); + const source = path.join(root, "main.py"); + fs.writeFileSync(source, "answer = 1\n"); + const pidFile = path.join(root, "lsp.pid"); + const hangFile = path.join(root, "refresh.stalled"); + const previousPidFile = process.env.SAMCHON_GRAPH_FAKE_LSP_PID_FILE; + const previousHangFile = process.env.SAMCHON_GRAPH_FAKE_LSP_HANG_FILE; + process.env.SAMCHON_GRAPH_FAKE_LSP_PID_FILE = pidFile; + process.env.SAMCHON_GRAPH_FAKE_LSP_HANG_FILE = hangFile; + + let pid: number | undefined; + const resident = createResidentGraphSource({ + cwd: root, + languages: ["python"], + server: process.execPath, + serverArgs: [GraphPaths.fakeLspServer, ...serverArgs], + lspReadyQuietMs: 10, + }); + try { + await resident.load(); + await waitForFile(pidFile); + pid = Number(fs.readFileSync(pidFile, "utf8")); + fs.writeFileSync(source, "answer = 2\n"); + + const refreshing = resident.load(); + await waitForFile(hangFile); + const settled = await settleWithin( + Promise.allSettled([refreshing, resident.close()]), + 5_000, + () => terminate(pid!), + ); + TestValidator.equals( + `${phase} refresh rejects after shutdown`, + settled[0].status, + "rejected", + ); + TestValidator.equals( + `${phase} shutdown settles`, + settled[1].status, + "fulfilled", + ); + await waitForExit(pid); + TestValidator.equals( + `${phase} child exits after refresh shutdown`, + isProcessAlive(pid), + false, + ); + } finally { + await Promise.allSettled([resident.close()]); + if (pid !== undefined) terminate(pid); + restoreEnv("SAMCHON_GRAPH_FAKE_LSP_PID_FILE", previousPidFile); + restoreEnv("SAMCHON_GRAPH_FAKE_LSP_HANG_FILE", previousHangFile); + } +}; + +const waitForFile = async (file: string): Promise => { + const deadline = Date.now() + 5_000; + while (!fs.existsSync(file)) { + if (Date.now() >= deadline) { + throw new Error(`fake LSP did not announce ${file}`); + } + await delay(10); + } +}; + +const settleWithin = async ( + task: Promise, + timeoutMs: number, + onTimeout: () => void, +): Promise => { + let timer: NodeJS.Timeout | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => { + onTimeout(); + reject( + new Error(`resident LSP refresh shutdown exceeded ${String(timeoutMs)} ms`), + ); + }, timeoutMs); + }); + try { + return await Promise.race([task, timeout]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } +}; + +const waitForExit = async (pid: number): Promise => { + const deadline = Date.now() + 2_000; + while (isProcessAlive(pid) && Date.now() < deadline) await delay(10); +}; + +const isProcessAlive = (pid: number): boolean => { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +}; + +const terminate = (pid: number): void => { + if (!isProcessAlive(pid)) return; + try { + process.kill(pid, "SIGKILL"); + } catch { + return; + } +}; + +const restoreEnv = (name: string, value: string | undefined): void => { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; +}; + +const delay = (milliseconds: number): Promise => + new Promise((resolve) => setTimeout(resolve, milliseconds)); diff --git a/tests/test-graph/src/internal/fake-lsp-server.cjs b/tests/test-graph/src/internal/fake-lsp-server.cjs index 3df3782..690f0c1 100644 --- a/tests/test-graph/src/internal/fake-lsp-server.cjs +++ b/tests/test-graph/src/internal/fake-lsp-server.cjs @@ -8,6 +8,7 @@ const options = { badHeader: false, badJson: false, emptySymbols: false, + closeInputAfterInitialize: false, exitOnInitialize: false, exitOnShutdown: false, messageLessError: false, @@ -31,6 +32,8 @@ const options = { omitChildren: false, progress: false, hangProgressLifecycle: false, + hangRefreshReadiness: false, + ignoreTermination: false, progressLifecycle: false, referenceProgressLifecycle: false, rustImpls: false, @@ -48,6 +51,7 @@ const options = { const symbolCallCountByUri = new Map(); let diagnosticSeverities = [2]; let hangMethod; +let hangRefreshMethod; if (process.env.SAMCHON_GRAPH_FAKE_LSP_ARGS_FILE) { fs.writeFileSync( process.env.SAMCHON_GRAPH_FAKE_LSP_ARGS_FILE, @@ -67,6 +71,8 @@ let slowFirstReferencesMs = 0; let referenceCallCount = 0; let progressLifecycleStarted = false; let progressLifecycleReady = false; +let refreshStarted = false; +let refreshProgressStarted = false; let lateProgressLifecycleMs = 0; let referenceProgressLifecycleStarted = false; let referenceProgressLifecycleReady = false; @@ -87,6 +93,8 @@ for (const arg of process.argv.slice(2)) { options.badJson = true; } else if (arg === "--empty-symbols") { options.emptySymbols = true; + } else if (arg === "--close-input-after-initialize") { + options.closeInputAfterInitialize = true; } else if (arg === "--exit-on-initialize") { options.exitOnInitialize = true; } else if (arg === "--exit-on-shutdown") { @@ -133,6 +141,10 @@ for (const arg of process.argv.slice(2)) { options.progress = true; } else if (arg === "--hang-progress-lifecycle") { options.hangProgressLifecycle = true; + } else if (arg === "--hang-refresh-readiness") { + options.hangRefreshReadiness = true; + } else if (arg === "--ignore-termination") { + options.ignoreTermination = true; } else if (arg === "--progress-lifecycle") { options.progressLifecycle = true; } else if (arg.startsWith("--late-progress-lifecycle=")) { @@ -165,6 +177,8 @@ for (const arg of process.argv.slice(2)) { options.typeQueries = true; } else if (arg.startsWith("--hang-method=")) { hangMethod = arg.slice("--hang-method=".length); + } else if (arg.startsWith("--hang-refresh-method=")) { + hangRefreshMethod = arg.slice("--hang-refresh-method=".length); } else if (arg.startsWith("--slow-first-references=")) { slowFirstReferencesMs = Number(arg.slice("--slow-first-references=".length)); } else if (arg.startsWith("--hang-references-after=")) { @@ -178,6 +192,16 @@ for (const arg of process.argv.slice(2)) { .map((value) => Number(value)); } } +if (options.ignoreTermination && process.platform !== "win32") { + process.on("SIGTERM", () => { + if (process.env.SAMCHON_GRAPH_FAKE_LSP_SIGTERM_FILE) { + fs.writeFileSync( + process.env.SAMCHON_GRAPH_FAKE_LSP_SIGTERM_FILE, + "received", + ); + } + }); +} writeDocumentVersionLog(); process.stdin.on("data", (chunk) => { @@ -202,7 +226,10 @@ process.stdin.on("data", (chunk) => { }); function handle(message) { - if (message.method === hangMethod) { + if ( + message.method === hangMethod || + (refreshStarted && message.method === hangRefreshMethod) + ) { if (process.env.SAMCHON_GRAPH_FAKE_LSP_HANG_FILE) { fs.writeFileSync( process.env.SAMCHON_GRAPH_FAKE_LSP_HANG_FILE, @@ -220,7 +247,7 @@ function handle(message) { process.stdout.write(`Content-Length: ${Buffer.byteLength(bad)}\r\n\r\n${bad}`); } if (options.unknownResponse) respond(999999, { ignored: true }); - return respond(message.id, { + respond(message.id, { capabilities: { textDocumentSync: 1, documentSymbolProvider: true, @@ -228,6 +255,20 @@ function handle(message) { }, serverInfo: { name: "fake-lsp", version: "0.0.0" }, }); + if (options.closeInputAfterInitialize) { + process.stdin.removeAllListeners("data"); + process.stdin.on("error", () => undefined); + process.stdin.destroy(); + fs.closeSync(0); + if (process.env.SAMCHON_GRAPH_FAKE_LSP_INPUT_CLOSED_FILE) { + fs.writeFileSync( + process.env.SAMCHON_GRAPH_FAKE_LSP_INPUT_CLOSED_FILE, + "closed", + ); + } + setInterval(() => undefined, 1_000); + } + return; } if (message.method === "initialized") return; if (message.method === "textDocument/didOpen") { @@ -319,6 +360,21 @@ function handle(message) { } if (message.method === "textDocument/didChange") { recordDocumentVersion(message.method, message.params.textDocument); + refreshStarted = true; + if (options.hangRefreshReadiness && !refreshProgressStarted) { + refreshProgressStarted = true; + request("window/workDoneProgress/create", { token: "refresh-index" }); + notify("$/progress", { + token: "refresh-index", + value: { kind: "begin", title: "refresh indexing forever" }, + }); + if (process.env.SAMCHON_GRAPH_FAKE_LSP_HANG_FILE) { + fs.writeFileSync( + process.env.SAMCHON_GRAPH_FAKE_LSP_HANG_FILE, + "indexing readiness", + ); + } + } return; } if (message.method === "textDocument/didClose") { @@ -1589,7 +1645,10 @@ function handle(message) { if (options.shutdownError) return respondError(message.id, "shutdown failed"); return respond(message.id, null); } - if (message.method === "exit") process.exit(0); + if (message.method === "exit") { + if (!options.ignoreTermination) process.exit(0); + return; + } if (message.id !== undefined) respond(message.id, null); } From f7f4876586633e59df57e4be7d1a55ec6c830152 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Sun, 19 Jul 2026 21:39:34 +0900 Subject: [PATCH 13/16] Make closed-pipe lifecycle coverage deterministic across operating systems Publish the stdin-closed fence only after descriptor 0 is actually closed, so the client's next write deterministically reaches the closed-pipe path instead of racing the fake's teardown. Reorder the platform-specific transport probe after the independent closed-session assertions so a pipe failure cannot hide their coverage. Closes #113 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018G2eNarjGmcWmEjFTW2Qcz --- ...ttscgraph_provider_surfaces_process_failures.ts | 14 ++++++++------ .../src/internal/fake-ttscgraph-server.cjs | 12 ++++++++++++ 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/tests/test-graph/src/features/test_ttscgraph_provider_surfaces_process_failures.ts b/tests/test-graph/src/features/test_ttscgraph_provider_surfaces_process_failures.ts index e7a4d90..102266f 100644 --- a/tests/test-graph/src/features/test_ttscgraph_provider_surfaces_process_failures.ts +++ b/tests/test-graph/src/features/test_ttscgraph_provider_surfaces_process_failures.ts @@ -61,11 +61,6 @@ export const test_ttscgraph_provider_surfaces_process_failures = async () => { // The child already exited, so close resolves immediately without touching it. await dying.close(); - // Windows named pipes retain the inherited read handle until process exit, - // so they surface this condition through the already-covered exit listener. - /* c8 ignore next */ - if (process.platform !== "win32") await assertClosedRequestPipe(root); - // A process that exits without diagnostics still rejects, and a later refresh // reports the process is gone without inventing an empty snapshot. const silent = new TtscGraphClient({ @@ -99,6 +94,13 @@ export const test_ttscgraph_provider_surfaces_process_failures = async () => { "a refresh after close reports the session is closed", ); + // Windows named pipes retain the inherited read handle until process exit, + // so they surface this condition through the already-covered exit listener. + // Keep this platform-specific transport probe after the independent closed + // session assertions so a pipe failure cannot hide their coverage. + /* c8 ignore next */ + if (process.platform !== "win32") await assertClosedRequestPipe(root); + // Closing before the first request must not spawn a process merely to stop it. const marker = path.join(root, "stubborn-closed.txt"); const stubborn = new TtscGraphClient({ @@ -141,7 +143,7 @@ async function assertClosedRequestPipe(root: string): Promise { args: [ GraphPaths.fakeTtscGraphServer, "--close-stdin-after-first", - `--marker=${stdinClosed}`, + `--stdin-closed-marker=${stdinClosed}`, ], requestTimeoutMs: 5_000, }); diff --git a/tests/test-graph/src/internal/fake-ttscgraph-server.cjs b/tests/test-graph/src/internal/fake-ttscgraph-server.cjs index b61de15..177e767 100644 --- a/tests/test-graph/src/internal/fake-ttscgraph-server.cjs +++ b/tests/test-graph/src/internal/fake-ttscgraph-server.cjs @@ -9,6 +9,12 @@ const project = cwdIndex === -1 ? process.cwd() : path.resolve(args[cwdIndex + 1 const invalidMode = args.find((arg) => arg.startsWith("--invalid")); const markerArg = args.find((arg) => arg.startsWith("--marker=")); const marker = markerArg?.slice("--marker=".length); +const stdinClosedMarkerArg = args.find((arg) => + arg.startsWith("--stdin-closed-marker="), +); +const stdinClosedMarker = stdinClosedMarkerArg?.slice( + "--stdin-closed-marker=".length, +); const requestLogArg = args.find((arg) => arg.startsWith("--request-log=")); const requestLog = requestLogArg?.slice("--request-log=".length); // Stands in for a producer that speaks a protocol this client refuses, so the @@ -334,6 +340,12 @@ input.on("line", (line) => { input.close(); process.stdin.destroy(); fs.closeSync(0); + // This marker is a transport fence, not a readline lifecycle hint: publish + // it only after descriptor 0 is actually closed so the client's next write + // cannot race the fake's teardown. + if (stdinClosedMarker !== undefined) { + fs.writeFileSync(stdinClosedMarker, "closed\n"); + } setInterval(() => undefined, 1_000); } }); From 217f27160174f07e2d1ca21a75713160e3f313d8 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Sun, 19 Jul 2026 21:39:34 +0900 Subject: [PATCH 14/16] Isolate benchmark language tools and skip Flutter-only Dart packages Discover per-language benchmark tools under root/`bin`/`server/bin`/nested `bin` instead of a single `bin`, and skip Flutter-only Dart packages (`pubspecRequiresFlutter`) and their unit test so the sweep provisions only what it can honestly build. Benchmark infrastructure for the #96 parity sweep. Refs #96 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018G2eNarjGmcWmEjFTW2Qcz --- tests/benchmark/graph/README.md | 14 +++++++---- tests/benchmark/graph/language.mjs | 37 +++++++++++++++++++++++++----- tests/benchmark/test/run.mjs | 9 ++++++++ 3 files changed, 50 insertions(+), 10 deletions(-) diff --git a/tests/benchmark/graph/README.md b/tests/benchmark/graph/README.md index c890fbc..d47bab6 100644 --- a/tests/benchmark/graph/README.md +++ b/tests/benchmark/graph/README.md @@ -12,14 +12,20 @@ adapter are the only intended differences. language and commit. 3. The harness refuses a prompt hash mismatch and records prompt provenance on every report. -4. A paid graph cell starts only after the package launcher exists. The measured +4. Language tools stay under the benchmark's isolated `.work/tools` tree. The + launcher discovers packaged root, `bin`, `server/bin`, and one-level nested + `bin` layouts without depending on user-global `PATH` entries. +5. Dart dependency preparation runs only for packages the provisioned Dart SDK + can resolve. A `pubspec.yaml` that declares `sdk: flutter` is left for a + separately provisioned Flutter SDK and is not misreported as a Dart failure. +6. A paid graph cell starts only after the package launcher exists. The measured MCP process starts the full, uncapped LSP index inside the cell. -5. Baseline and graph arms receive the same user utterance. Tool guidance comes +7. Baseline and graph arms receive the same user utterance. Tool guidance comes from MCP instructions; `prompt.mjs` only supplies the reference grounding and tool-discovery nudge used by both upstream harnesses. -6. Minimal per-arm MCP/Codex configuration prevents user-global instructions or +8. Minimal per-arm MCP/Codex configuration prevents user-global instructions or unrelated MCP servers from leaking into a cell. -7. Raw traces and answers remain available for `audit-codex-traces.mjs`; only +9. Raw traces and answers remain available for `audit-codex-traces.mjs`; only zero-token infrastructure failures are retried away. ## Main commands diff --git a/tests/benchmark/graph/language.mjs b/tests/benchmark/graph/language.mjs index 622f8fc..86c108f 100644 --- a/tests/benchmark/graph/language.mjs +++ b/tests/benchmark/graph/language.mjs @@ -32,13 +32,23 @@ if (fs.existsSync(toolsRoot)) { process.env.DOTNET_ROOT_X64 = dotnetRoot; process.env.DOTNET_HOST_PATH = dotnetHost; } - const extra = fs + const toolDirectories = fs .readdirSync(toolsRoot, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) - .map((entry) => { - const bin = path.join(toolsRoot, entry.name, "bin"); - return fs.existsSync(bin) ? bin : path.join(toolsRoot, entry.name); + .flatMap((entry) => { + const root = path.join(toolsRoot, entry.name); + const children = fs + .readdirSync(root, { withFileTypes: true }) + .filter((child) => child.isDirectory()) + .map((child) => path.join(root, child.name, "bin")); + return [ + root, + path.join(root, "bin"), + path.join(root, "server", "bin"), + ...children, + ]; }); + const extra = [...new Set(toolDirectories)].filter((dir) => fs.existsSync(dir)); if (extra.length > 0) { process.env.PATH = `${extra.join(path.delimiter)}${path.delimiter}${process.env.PATH ?? ""}`; } @@ -213,8 +223,13 @@ export function assertPreparedFixture(spec, repoDir) { } if (spec.language === "dart") { const missing = findFiles(repoDir, "pubspec.yaml").filter( - (dir) => - !fs.existsSync(path.join(dir, ".dart_tool", "package_config.json")), + (dir) => { + const pubspec = fs.readFileSync(path.join(dir, "pubspec.yaml"), "utf8"); + return ( + !pubspecRequiresFlutter(pubspec) && + !fs.existsSync(path.join(dir, ".dart_tool", "package_config.json")) + ); + }, ); if (missing.length > 0) { throw new Error(`${spec.name} has ${missing.length} unresolved Dart package(s)`); @@ -234,6 +249,11 @@ export function assertPreparedFixture(spec, repoDir) { function ensureDartPubDeps(root) { for (const pubspecDir of findFiles(root, "pubspec.yaml")) { + const pubspec = fs.readFileSync(path.join(pubspecDir, "pubspec.yaml"), "utf8"); + if (pubspecRequiresFlutter(pubspec)) { + console.log(`Skipping Flutter-only Dart package in ${pubspecDir}.`); + continue; + } if ( fs.existsSync( path.join(pubspecDir, ".dart_tool", "package_config.json"), @@ -248,6 +268,11 @@ function ensureDartPubDeps(root) { } } +/** Whether a Dart package requires the separately provisioned Flutter SDK. */ +export function pubspecRequiresFlutter(text) { + return /^\s*sdk:\s*flutter\s*(?:#.*)?$/m.test(text); +} + function findFiles(root, name) { const ignored = new Set([ ".dart_tool", diff --git a/tests/benchmark/test/run.mjs b/tests/benchmark/test/run.mjs index 4b8adc8..bd71395 100644 --- a/tests/benchmark/test/run.mjs +++ b/tests/benchmark/test/run.mjs @@ -9,6 +9,7 @@ import { fileURLToPath } from "node:url"; import { CORPUS, PROJECTS, projectDir } from "../graph/corpus.mjs"; import { analyzePreflightDump, + pubspecRequiresFlutter, assertPinnedCheckout, } from "../graph/language.mjs"; import { assertPublicationCandidates } from "../graph/publication-gate.mjs"; @@ -306,6 +307,14 @@ function testFixtureAndPreflightIntegrity() { assert.equal(structuralOnly.ok, false); assert.match(structuralOnly.failures.join("; "), /fatal warning/); assert.match(structuralOnly.failures.join("; "), /semantic edges/); + assert.equal( + pubspecRequiresFlutter("dependencies:\n flutter:\n sdk: flutter\n"), + true, + ); + assert.equal( + pubspecRequiresFlutter("environment:\n sdk: ^3.4.0\n"), + false, + ); fs.rmSync(root, { recursive: true, force: true }); } From 3218579eb75aabb983328cf4d829a38625c415ae Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Sun, 19 Jul 2026 22:00:08 +0900 Subject: [PATCH 15/16] Keep the signal-resistant LSP fixture alive until SIGKILL on POSIX The stubborn-tree regression spawns the fake LSP with --ignore-termination to prove the client escalates SIGTERM before SIGKILL. terminateOwnedProcess destroys the client's stdin write end before it signals, so without a ref'd handle the fake drained its event loop at stdin EOF and exited before its SIGTERM handler ran, leaving no SIGTERM evidence on POSIX (macOS/Ubuntu) while Windows takes the taskkill lane. Hold the loop open with a ref'd keep-alive timer (the same pattern the close-input fixture already uses) and swallow stdin errors, so SIGTERM is observed deterministically and only SIGKILL ends the child. Refs #107 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018G2eNarjGmcWmEjFTW2Qcz --- tests/test-graph/src/internal/fake-lsp-server.cjs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test-graph/src/internal/fake-lsp-server.cjs b/tests/test-graph/src/internal/fake-lsp-server.cjs index 690f0c1..08041b3 100644 --- a/tests/test-graph/src/internal/fake-lsp-server.cjs +++ b/tests/test-graph/src/internal/fake-lsp-server.cjs @@ -193,6 +193,18 @@ for (const arg of process.argv.slice(2)) { } } if (options.ignoreTermination && process.platform !== "win32") { + // A signal-resistant server holds its own work: it does not exit when the + // client closes stdin, and it ignores SIGTERM, so the client must escalate to + // SIGKILL. `terminateOwnedProcess` destroys the client's stdin write end + // before it signals; without a ref'd handle the child's stdin EOF would drain + // the event loop and exit this process before the SIGTERM handler runs, + // leaving the SIGTERM-before-SIGKILL evidence unwritten on POSIX hosts. The + // keep-alive timer holds the loop open so SIGTERM is observed deterministically + // and only SIGKILL ends the process; a swallowed stdin error keeps a closed + // pipe from surfacing as an uncaught exit. + const keepAlive = setInterval(() => undefined, 1_000); + keepAlive.ref?.(); + process.stdin.on("error", () => undefined); process.on("SIGTERM", () => { if (process.env.SAMCHON_GRAPH_FAKE_LSP_SIGTERM_FILE) { fs.writeFileSync( From 4ffa407d0fef30962ddc97b683637dfff8841ace Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Sun, 19 Jul 2026 22:22:21 +0900 Subject: [PATCH 16/16] Close the cross-OS coverage gaps in the LSP and native clients Three per-OS gaps kept the coverage gate red: LspClient's stdin stream error listener was reached only through the Windows synthetic-destroy path, its terminate() forced-exit fail is Windows-only, and TtscGraphClient's superseded-child guard was exercised on no host. Cover the stdin error listener cross-platform by destroying the write stream with an error, ignore the Windows-only terminate branch that POSIX short-circuits, and drop a stale chunk into consume() while the client owns no child to prove the guard directly. Refs #100 Refs #107 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018G2eNarjGmcWmEjFTW2Qcz --- packages/graph/src/lsp/LspClient.ts | 4 ++ ...rvers_that_break_the_shutdown_handshake.ts | 30 +++++++++++++ ...ph_client_drops_superseded_child_output.ts | 42 +++++++++++++++++++ 3 files changed, 76 insertions(+) create mode 100644 tests/test-graph/src/features/test_ttscgraph_client_drops_superseded_child_output.ts diff --git a/packages/graph/src/lsp/LspClient.ts b/packages/graph/src/lsp/LspClient.ts index 2df1c2a..a843df4 100644 --- a/packages/graph/src/lsp/LspClient.ts +++ b/packages/graph/src/lsp/LspClient.ts @@ -257,9 +257,13 @@ export class LspClient { // Windows. Preserve the transport's forced-termination contract for // pending requests while the termination promise below still waits for // the exact owned process tree to disappear. + /* c8 ignore start -- Windows-only forced-termination contract: POSIX + * hosts short-circuit at `win32` and never fail here, while the Windows + * lifecycle integration test exercises this branch. */ if (process.platform === "win32" && this.failure === undefined) { this.fail(new Error("Language server exited (null, SIGKILL).")); } + /* c8 ignore stop */ this.termination = terminateOwnedProcess(this.process, this.exit); } return this.termination; 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 4faa99e..5ebfab2 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 @@ -103,6 +103,7 @@ export const test_lsp_client_closes_servers_that_break_the_shutdown_handshake = await assertStubbornProcessTreeIsOwned(LspClient); await assertClosedInputRejectsRequests(LspClient); await assertSynchronousWriteFailureRejectsRequests(LspClient); + await assertStdinStreamErrorRejectsRequests(LspClient); // An already-cancelled request never enters the wire or waits for the // otherwise-unlimited default deadline. The client still owns its child and @@ -268,6 +269,35 @@ const assertSynchronousWriteFailureRejectsRequests = async ( } }; +/** + * A stream-level stdin error is the handle failure surface: destroying the + * write stream with an error emits it on every platform, whereas the real + * peer-close path only reaches the write callback on POSIX. Exercising it + * cross-platform keeps the client's stdin `error` listener honest everywhere. + */ +const assertStdinStreamErrorRejectsRequests = async ( + LspClient: LspClientConstructor, +): Promise => { + const client = new LspClient(process.execPath, [ + GraphPaths.fakeLspServer, + "--hang-method=workspace/symbol", + ]); + try { + await client.request("initialize", {}); + const pending = client.request("workspace/symbol", {}); + (client as unknown as ILspClientInternals).process.stdin.destroy( + new Error("synthetic stdin stream error"), + ); + const rejection = await rejectionWithin(pending, 2_000); + TestValidator.predicate( + "a stdin stream error rejects pending requests", + rejection.message.includes("stdin"), + ); + } finally { + await settleWithin(client.close(), 5_000, () => undefined); + } +}; + const rejectionWithin = async ( task: Promise, timeoutMs: number, diff --git a/tests/test-graph/src/features/test_ttscgraph_client_drops_superseded_child_output.ts b/tests/test-graph/src/features/test_ttscgraph_client_drops_superseded_child_output.ts new file mode 100644 index 0000000..53d0c23 --- /dev/null +++ b/tests/test-graph/src/features/test_ttscgraph_client_drops_superseded_child_output.ts @@ -0,0 +1,42 @@ +import { TestValidator } from "@nestia/e2e"; +import path from "node:path"; + +// `TtscGraphClient` is internal to the package, so it is reached by path rather +// than the public entry. +import { TtscGraphClient } from "../../../../packages/graph/src/provider/ttscgraph/TtscGraphClient"; +import { GraphPaths } from "../internal/GraphPaths"; + +/** + * A stdout chunk delivered for a child the client no longer references is + * dropped rather than folded into the live session's line buffer. + * + * `consume` is bound to the exact child whose stream produced it, so a buffered + * read that lands after a restart or a failure — when `this.child` has moved on + * or been cleared — must be ignored. Nothing is spawned: the guard is exercised + * directly against a superseded child while the client owns none, which is the + * one-input reduction of that otherwise timing-dependent race. + */ +export const test_ttscgraph_client_drops_superseded_child_output = async () => { + const root = GraphPaths.createTempDirectory("samchon-graph-superseded-child-"); + const client = new TtscGraphClient({ + root, + command: path.join(root, "unused-ttscgraph-binary"), + }); + try { + const superseded = { stdoutChunks: [] as string[] }; + ( + client as unknown as { consume(child: unknown, chunk: string): void } + ).consume(superseded, "superseded serve line\n"); + TestValidator.equals( + "a superseded child's output is dropped", + superseded.stdoutChunks, + [], + ); + TestValidator.predicate( + "and the client still owns no session", + client.current === undefined && client.generation === 0, + ); + } finally { + await client.close(); + } +};